Operations
Error Reference
Luma normalizes its model and tool errors: each of those carries a code, a message and a retryable flag, and that flag directly determines how the retry loop and the run policies behave. Parse errors, validation errors and the entire v0.4.0 data surface stay outside that normalization — they carry a message and nothing else.
Error shape
Error: [TOOL_INPUT_INVALID] calculate.input.a must be a number.
CLI errors have the form Error: [CODE] message and terminate with exit code 1. The code appears only on normalized model/tool errors. Parse errors, validation errors and every data surface error — datasource/query/command, std/http, std/state, parallel over — carry no code, so their CLI line starts straight at the message with no [CODE] in front of it.
When it is caught inside try/catch, the error is an object:
{
"code": "TOOL_TIMEOUT",
"message": "Tool 'http_get' timeout occurred (3000 ms).",
"retryable": true,
"details": { "url": "https://…" }
}import agent from "std/agent"
} catch error {
code = agent.errorCode(error) // "TOOL_TIMEOUT"
msg = agent.errorMessage(error)
if agent.isRetryable(error) {
...
}
}Errors that were never normalized carry only message: a type error inside a pure fn, and every data surface error as well. For those agent.errorCode returns null and agent.isRetryable returns false — the flag reads the error's own retryable field, and an uncoded error has none. Match them on the message text.
Model errors
| Code | Status | When | What to do |
|---|---|---|---|
MODEL_PROVIDER_ERROR | permanent / retryable | The gateway answered with something other than 2xx (429 and 408 aside). | 5xx, 409 and 425 are retryable. For the permanent ones, check base_url, the model identifier and your authorization. |
MODEL_RATE_LIMIT | retryable | HTTP 429. | Switch to a second model with a fallback policy, or slow the request rate down. |
MODEL_TIMEOUT | retryable | HTTP 408, or a client-side timeout. | Raise the timeout value; split large prompts. |
MODEL_CANCELLED | permanent | HTTP 499 or a cancellation signal (the loser of a race, a parallel timeout). | Usually the expected behavior. |
MODEL_EMPTY_CONTENT | retryable | The provider returned no content. | max_tokens may be too low; a streaming response may have been cut off early. |
MODEL_INVALID_RESPONSE | retryable | The response could not be parsed, was not valid JSON or did not pass schema validation. | The most frequent error. Give it retry, narrow the schema, add @desc and @feedback. |
MODEL_REFUSAL | permanent | The model explicitly refused the request. | Rewrite the prompt — it does not retry, and it should not. |
MODEL_INVALID_TOOL_CALL | retryable | The tool call arguments were not valid JSON. | Simplify the tool's parameter schema. |
MODEL_UNSUPPORTED | permanent | An unrecognized provider value. Normally caught at parse time; this code exists only as a defensive fallback. | Check the valid provider names here. |
MODEL_BUDGET_EXCEEDED | permanent | token_budget, cost_budget or fusion_input_tokens was exceeded. | Raise the budget, or reduce the prompt/memory size. |
MODEL_VOTE_NO_QUORUM | retryable | No quorum formed under a vote policy. | Lower quorum or narrow the schema — free-text fields make unanimity impossible. |
MODEL_VOTE_TIE | permanent | Quorum was reached but there is a tie, and tie: "fail". | Use tie: "first" or "highest_confidence". |
MODEL_VOTE_CONFIDENCE_UNAVAILABLE | permanent | tie: "highest_confidence" is selected but the results carry no numeric confidence. | Add confidence: float [0..1] to the schema. |
MODEL_FUSION_NO_INPUTS | retryable | No candidate succeeded under a fusion policy. | Check the candidate models and their schema compatibility. |
HTTP status mapping
| Status | Code | Retryable |
|---|---|---|
408 | MODEL_TIMEOUT | retryable |
409 · 425 | MODEL_PROVIDER_ERROR | retryable |
429 | MODEL_RATE_LIMIT | retryable |
499 | MODEL_CANCELLED | permanent |
5xx | MODEL_PROVIDER_ERROR | retryable |
| other 4xx | MODEL_PROVIDER_ERROR | permanent |
The error.code and error.type from the provider's body, together with the first 1000 characters of the raw response, are redacted and then added to the details field.
Tool errors
| Code | Status | When | What to do |
|---|---|---|---|
TOOL_NOT_FOUND | permanent | The contract exists but there is no handler in the registry. | Add the handler to src/tool-registry.ts, or load it with --tool-module. |
TOOL_INPUT_INVALID | permanent | The input did not pass schema validation. | On native calls the arguments the model produced are the faulty part — improve the parameter @descs. |
TOOL_OUTPUT_INVALID | permanent | The value the handler returned does not match the output schema. | Fix the handler; add type safety with --generate-tool-types. |
TOOL_TIMEOUT | retryable | The effective timeout was exceeded. | Raise timeout/tool_timeout; write the handler so that it responds to AbortSignal. |
TOOL_CANCELLED | permanent | The enclosing flow was cancelled (a parallel timeout, a race, Ctrl-C). | Usually the expected behavior. |
TOOL_PERMISSION_DENIED | permanent | The credential is missing, the scope is not permitted, deny_write blocked it, or approval was not granted. | The message tells you which one it was. Set LUMA_APPROVED_TOOLS or tool_scopes. |
TOOL_CONTEXT_INVALID | permanent | The needs context does not exist, tool_contexts does not allow it, or its type does not match. | Check tool_contexts in luma.config.json and the agent's allowlist. |
TOOL_EXECUTION_ERROR | retryable / permanent | The handler threw an exception. | Use LumaToolError for a typed error and decide retryable yourself. |
TOOL_ERROR_INVALID | permanent | The thrown error does not match the error: schema in the contract. | Make the error object conform to the schema. |
TOOL_IDEMPOTENCY_CONFLICT | permanent | The same idempotency key was used with different input. | Review how the key is generated — it must be unique per request. |
TOOL_CIRCUIT_OPEN | permanent | tool_failure_limit was exceeded. | Fix the subsystem; raise the limit only deliberately. |
Data surface errors
The v0.4.0 data surface — datasource/query/command, std/http, std/state and parallel over — raises named errors, and not one of them carries a code. They are thrown as plain errors, so in a try/catch agent.errorCode(error) returns null and agent.isRetryable(error) returns false for every message on this page. Match them by message text. The declarations themselves are documented under Data & Integration; this page owns their error view, Telemetry owns their trace view.
Error: query 'Orders' expected exactly one row, got 3.
No [CODE] in front of it — that is the shape of every message below. Some are raised at parse time, before the program starts; those are marked in the tables.
datasource · query · command
| Message | Cause and fix |
|---|---|
datasource 'X' has no registered bridge; export it from a tool module as datasources.X. | The declaration exists but nothing registered a bridge for it. Luma owns the contract, you own the driver: a tool module must export datasources.X. Load it with --tool-module or via tool_modules in luma.config.json. |
datasource 'X' is missing credential: <ENV>. | The environment variable named by credential: is not set. Only presence is checked — the value is never read into a prompt, a trace or an error message. |
query 'X' bridge must return a list of rows. | The bridge answered a query with something that is not an array. Return [] for an empty result, never null. |
command 'X' bridge must return the affected row count as an integer. | A command is declared -> int and the bridge must produce exactly that number — not the rows, not a driver result object. |
query 'X' expected exactly one row, got N. | -> Schema (rather than Schema[]) declares exactly one row; N is what the bridge actually returned, zero included. Declare Schema[] when the count is not guaranteed. |
query|command 'X' timed out after N ms on datasource 'Y'. | The datasource's timeout elapsed while the bridge was running. The message opens with the declaration kind, so the same shape covers both. |
Result set exceeded the row limit (200000). Process in batches with col.chunk, or raise --max-rows. | Global to every row-producing path: the same ceiling and the same message guard query results and io.readFiles alike. The number in the message is the effective limit, which --max-rows changes. See limits. |
query 'X' sql cannot interpolate; use $1 parameters. | Parse time. A {name} shape inside the sql string. Parameters bind positionally as $1..$n, so the injection surface is closed in the language itself. A command is rejected with the same message. |
Datasource 'X' does not support 'transaction'; it is deliberately out of this package — handle transactions inside the bridge (ROADMAP § U4). | Parse time. Transactions are absent on purpose: a transaction boundary belongs inside the bridge, and declaring one here would commit the language to semantics no contract can enforce. Any other unknown key gets has no key 'k'; known keys: … instead. |
std/http · std/state
| Message | Cause and fix |
|---|---|
http.<fn> could not reach '<url>': <reason>. | The only network failure that is raised at all. reason is timed out after N ms when Luma's own timeout fired, and connection failed for everything else — DNS failure, refused connection and TLS error normalize into that one bucket because engines disagree on how to classify them. A third form appears if the request child process itself never answers (child exited with status N). |
http.<fn> response exceeded the byte limit (10485760); raise --max-response-bytes if this is intended. | The response body is larger than the ceiling (10 MiB by default). The body is discarded, not truncated. |
http.<fn> timeout must be a positive integer of milliseconds. | timeout is a mandatory argument with no default, and 0, negatives and fractions are all rejected: a silent infinite wait is exactly the failure class this package exists to remove. |
http.<fn> expects a url string. · http.<fn> expects headers as a map. | Argument shapes. Pass {} when there are no headers — the argument itself is not optional. |
state.read needs a state file; pass --state-file <path>. | Every std/state function refuses by name when --state-file is missing; the name in the message is the function you called. There is no default location, because a silent default is silent data loss. |
state store could not be read: <file> (<reason>). | The state file exists but is not a JSON object. A missing file is not an error — it reads as an empty store. |
parallel over
| Message | Cause and fix |
|---|---|
parallel over 'X' requires a limit. | Parse time. The concurrency ceiling is mandatory — an unbounded fan-out over a query result is a way to take down the datasource behind it. |
parallel over 'X' on_error fallback is not supported; use on_error continue or the default fail_fast. | Parse time. Data-parallel work has no fallback branch to fall into; only continue and fail_fast exist here. |
parallel over cannot be nested. | Parse time. A nested fan-out multiplies the two limits together and the effective concurrency stops being readable from the source. |
parallel over 'X' body must return a <Schema>. | Parse time. The body contains no return at all. |
parallel over 'X' body did not return a value for '<item>'. | Runtime: a return exists but the path this element took never reached it — a return inside an if whose condition was false. Every element must produce a value; use on_error continue if some elements are meant to drop out. |
parallel over 'X' expected a list from '<expression>', got <kind>. | The collection expression did not resolve to a list. <kind> is the JavaScript-level kind of what arrived (object, null, nothing). |
http.get returns { status, body, headers } for a 404 and a 500 exactly the way it does for a 200: the status code is data, not a failure. A try/catch around an HTTP call never fires for a bad status — the only failure it raises is not reaching the host. Check response.status yourself, and treat an unchecked status as an unhandled error path.
Retry semantics
an error occurs ↓ retryable == false ? ├── yes → the loop stops immediately, the error is thrown upward └── no ↓ attempt count < retry ? ├── no → the error is thrown └── yes ↓ the error text is appended to the prompt → new attempt_id → request again
| Layer | Setting | What it repeats |
|---|---|---|
ask retry | retry N, or the agent's retry: | The entire LLM request; the error text is appended to the prompt. |
| Tool retry | retries in the tool contract | Only the handler; it never touches the LLM. |
| Run policy | fallback + on: [...] | Moves on to a different model. |
Under a fallback policy every candidate model runs with retry: 0 — the resilience comes from the policy itself. Tool retries, on the other hand, are entirely independent of the LLM retry and use the tool's own retry_backoff timing.
When a tool throws a retryable: false error, it is not only the tool retry that stops but the LLM retry loop that called it as well. This is deliberate: on a permanent permission error there is no point in making the model repeat the same request.
Validation messages
Validation errors carry no code, and all the errors of a single round are combined into one report:
Error: A.f.v.name must be at least 2 characters. A.f.v.age must be an integer. A.f.v.email must be in email format. A.f.v.tags must contain unique items. A.f.v.score must be at most 100. A.f.v.role must be one of these values: admin, user. A.f.v.extra is not defined in the schema.
| Message pattern | Cause |
|---|---|
X.y missing. | A required field is absent. |
X.y must be a string. · must be an integer. · must be a number. · must be a boolean. | Type mismatch. must be a number is also emitted for NaN/Infinity. |
X.y must be one of these values: … | Enum violation. |
X.y must be an array. | An array was expected and another type arrived. |
X.y must contain exactly N items. | Tuple length. |
X.y must contain at least/at most N items. | @minItems / @maxItems. |
X.y must contain unique items. | @unique. |
X.y.field values must be unique. | @uniqueBy. |
X.y must contain the expected item. | @contains. |
X.y must be at least/at most N characters. | @length. |
X.y does not match the expected format. | @match. |
X.y must be in <format> format. | @fmt. |
X.y must be at least/at most N. | @range, or the range shorthand. |
X.y does not satisfy its @valid rule: <expression> | @valid. |
X.k is not defined in the schema. | Unknown field. |
X.y was given more than one alias: a, b | The canonical field is absent and two or more of its @alias names arrived at once — the runtime will not pick a winner. |
X does not match any union type: … | … | No member of the union held. |
X does not satisfy its where rule: <expression> | A where invariant. A where that returns a non-boolean fails separately with X where rule must return a boolean result: …. |
X conditional rule 'a' requires/forbids field: <condition> | A when rule; 'a' is the field name the branch demands or forbids. |
X.sources.sources contains an invalid context source: <id> | An undefined context id. sources is the runtime's built-in source-validation field; the name is hardcoded and cannot be renamed — and it appears twice in the message, because the path already ends in the field name. |
X.y cannot be given by its canonical name and an alias at the same time. | Both the canonical name and the alias were sent. |
If @feedback is defined, these messages are replaced with Path.field: <your message>, where your own text takes the place of the standard one.
When the rule is soft, the same text arrives elsewhere
A rule carrying @severity("warn") produces the message above, but it does not produce an error on a result path. The text travels in the finding's message field instead, and the run continues. Two lines are specific to that path and are not errors at all:
| Line | Where it appears | What it means |
|---|---|---|
Warning: 2 findings accepted but never read: A.f.return (1), B.g.return (1). Read them with findings.of(...) from std/findings. | Standard error, at the end of the run | Findings were produced and nobody read them. The exit code stays 0 — see @severity. |
Findings cap reached (1000); 412 more soft violations were not recorded. | The message of a finding whose rule is findings.capped | An operation went past the per-operation ceiling. The count of what was cut is always in the record. |
On an input path — a tool argument, a method or pure fn parameter — @severity is ignored and the ordinary error above is what you get.
Parse and startup errors
These occur before the program starts running, and they carry no code.
| Message | Cause |
|---|---|
Invalid schema field: … | The field syntax is wrong — a stray token, or a keyword continuation line such as breaking before require. |
A schema line may define only one field: … | Two name: type pairs on the same line. A trailing comma after a single field is fine. |
Unknown annotation: @x | An unrecognized annotation. @prompt cannot be used on a field — only in the schema header. |
@x can only be used once on a field. | A single-use annotation was repeated. |
@length can only be used on string fields… | The annotation does not match the field's type. |
Field range cannot be specified with both legacy syntax and @range. | float [0..1] was written together with @range. |
No schema named 'X' was found. | An undefined schema reference. |
No model named 'X' was found. | An undefined model profile in an agent. |
'X' is not registered in the tool registry. | The contract exists, the handler does not. |
Agent method f cannot use ask and return together. | A terminal ask together with a return. |
fn f return schema differs from the ask schema. | The method's output does not line up with the ask target. |
Agent 'A' does not allow tool 'x' to be called directly. | The tool is not in the agent's tools list. |
'x' is not defined as a dependency. | An x = name.f(...) through a name that was never declared with uses. It also shows up with std module aliases → reserved namespaces. |
'dependency' cannot be used inside a pure fn. | The same cause, this time in a pure fn body. |
IO cannot be used inside pure fn 'f'. | A std/io call inside a pure fn. Use an io fn. |
where is deterministic and cannot use IO. | It is where that must stay deterministic, so any io.* call inside it is rejected. |
ask, agent call or parallel not found. | The program has no top-level operation. |
.env must define base_url, api_key, and model. | A legacy gateway is required but has not been configured. |
Model 'X' is missing credential: api_key. | The environment variable requested through getEnv does not exist. |
Import module may contain only schemas and exported pure fns: … | The imported file contains an agent/tool/operation. |
Import cycle found: … · Agent dependency cycle found: … · Pure fn cycle found: … | A circular dependency. |
io path cannot escape the project directory: … | The IO path is outside the root. |
Model 'X' has unknown provider 'y'. Valid values: … | An unrecognized provider value; the error is raised at parse time and it lists the valid values. |
@valid expression is not supported: … | An unsupported @valid lambda body; it is rejected at parse time whether it sits on a schema field, a tool parameter or an fn parameter. |
@severity value must be "error" or "warn": review | An unknown severity. The set is closed at two — a tier vocabulary of your own belongs in the code that reads the findings. |
@severity must follow the @valid rule it modifies. | @severity with no @valid in front of it. On a field it modifies the rule it follows, never the field as a whole. |
where only supports the @feedback and @severity annotations: @desc | Any other annotation on a where line. |
findings is io and cannot be used inside @valid or where rules: findings.count | std/findings reached from a rule that must stay deterministic. It stops a rule from reading its own violations. |
pure fn X parameter block not closed. | An unclosed parenthesis in the signature. |
Operator '%' is not supported: y % 2 | An operator the language does not have. The message names the operator and quotes the line, so it cannot be mistaken for the undefined-variable error below. Applies to ?. ** % & | and ? :. |
'??' cannot be mixed with '&&' or '||' without parentheses: n ?? b || b | The reading would be ambiguous; parenthesise the side you meant. Both (n ?? b) || b and b || (n ?? b) are legal. |
Diagnosis guide
| Symptom | Where to look first |
|---|---|
| The model keeps producing the same field incorrectly | Add @desc and @feedback to the field; give it retry. Filter the validation events in the trace and see which rule is blowing up. |
| The model never calls the tool | A native call depends on the model's own decision and cannot be forced. If it has to run for certain, use a direct tool. |
| The first response comes back malformed and the second attempt fixes it | Expected behavior: when tools is defined, response_format is not sent on the first request. Give it retry. |
| A strict gateway is rejecting the request | Is there a map<T> in the schema? Move to the Entry[] pattern. |
| I expect an array in the field but a single value arrives (or the other way round) | float[0..1] and float [0..1] may have been confused → shorthands. |
Boolean if condition expected: … | There is a non-boolean value in a logical context — ! works only on booleans, and the parts of &&/|| must be booleans as well → expression language. |
'x' is not defined as a dependency. | The std module alias is not one of the reserved names → import rules. |
| Budgets never fire | The gateway may not be returning usage; cost_per_1k_tokens may not be set. Check the trace metrics. |
| The tool timeout is shorter than I expected | The effective duration is min(tool.timeout, tool_timeout_max). |
vote never reaches quorum | Free-text fields make exact JSON equality impossible. Use a narrow schema → vote. |
| The image does not reach the model | Does the model profile have multimodal: true? Without it the marker travels as plain text. |
| I want to see the raw model response | Run with LUMA_DEBUG_MODEL_RESPONSE=1 — the first 4000 characters of the response are added to the error details. |
try/catch around http.get never fires for a 500 | A status code is not an error: 404 and 500 come back as an ordinary { status, body, headers } value. Check response.status yourself → std/http. |
parallel over returned fewer results than the input had elements | That is the on_error continue contract: a failed element is skipped, not left as a hole. The only signal is the parallel_over telemetry event → the drop counter. |
A failed command is not retried even though the datasource declares retry | Deliberate: reads are retried, writes are not. A retried write with no idempotency key can double-apply, so the attempt count is fixed at 1 for command. Retry inside the bridge if the statement really is idempotent → datasource. |
# Quick diagnosis set luma program.luma --all --trace-file /tmp/t.json jq -r '.events[] | select(.ok==false) | "\(.type) \(.name) \(.error)"' /tmp/t.json jq -r '.events[] | select(.type=="validation" and .ok==false) | .metadata.rule' /tmp/t.json | sort | uniq -c | sort -rn jq '.metrics' /tmp/t.json