JJoeven

Curriculum/Tools & Function Calling

Coerce and Structured Errors

Turn "17" into 17. Reject -1. Return error codes the model can use, not a 4,000-line traceback.

intermediate19 min10 / 24

Models type numbers as strings. They also invent ids. Your job is to coerce the boring cases and reject the rest. Coercion is not kindness. It is a narrow map from JSON that almost matches the schema to JSON that does. Everything else is an error object.

Safe coerce:

  • "17"17 for an integer id
  • Trimming whitespace on ids you still have to match exactly after strip
  • "true" → bool only if you really must (prefer real JSON booleans)

Unsafe coerce:

  • Hallucinated names
  • Negative ids
  • Extra keys
  • A string that happens to look like SQL
  • "four thousand" to 4000
  • Booleans to integers (True is not job 1)

The dispatcher validates. Coercion sits next to validation: after parse, before the handler, or inside a typed adapter. Do not coerce inside Stripe. Do not coerce twice in different directions.

Coerce the boring cases
Raw argsCoerceError objectHandler

Turn "17" into 17. Reject -1 with a small JSON error.

Coerce the boring cases

Errors are part of the interface

Return what to try next:

  • {"error": "not_found", "hint": "user_id looks like usr_..."}
  • {"error": "denied", "required_permission": "refunds.write"}
  • {"error": "invalid_args", "field": "job_id"}

Do not return a traceback. It burns tokens and teaches the model your stack. Do not return an HTML 500 page. Models recover from structured errors. They spiral on stack traces and they copy internal paths into the next call.

Stable codes matter more than prose. not_found is branchable. “Hmm we couldn’t find that :/” is not. Keep a short hint. Keep field when the problem is a field. Keep got only if it is not a secret. Do not echo a token the model just tried to pass.

HTTP status is not the observation. Normalize in the tool: 404 becomes not_found. 429 becomes rate_limited with a retry-after if you have one. Timeout becomes timeout. The loop should not parse vendor HTML.

Caps on retry

The loop will retry. Cap retries on the same invalid_args — if the model cannot fix the field in two turns, stop and hand off. Cap not_found too: guessing ids is not a search algorithm. Cap denied at one: the model should not argue with the policy. denied is for the user or the approval queue, not for a longer prompt.

Log the error code, not the whole exception. Metrics on codes tell you whether the schema is wrong (invalid_args spike after a deploy) or the world is empty (not_found spike). Tracebacks in logs (redacted, sampled) are for humans. Observations are for the model.

-1 is valid JSON and still wrong

Schema minimums catch some of this. Handlers catch the rest. A job id of 0, -1, or 2^63-1 may pass a sloppy integer type check. Positive integer, in range, then lookup. Lookup miss is not_found, not an exception.

Booleans in Python are a subclass of int. True must not become job 1. The classroom coerce rejects bools first. Copy that.

Classroom get_job

Five inputs: "17", 17, -1, "abc", 99. Only the first two should succeed. -1 and abc are invalid_args with a hint. 99 is not_found with a hint that ids look like 17 and 42. The hint is the next prompt. The code is the contract.

Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

What printed: "17" and 17 are ok failed jobs. -1, abc, and True are invalid_args. 99 is not_found. No traceback. Each line is something the loop can append as an observation and something you can count in metrics.

What goes wrong

Coercing everything with a giant try/int/float/json.loads tower. Returning empty string on error so the model thinks success. Putting exception types in the observation (KeyError: ...). Retrying denied. Coercing extra keys into nested dicts “to be nice.” Nice is how SQL arrives.

How to test errors

A table of raw inputs to expected codes. Include bool, negative, overflow if you care, missing, extra key (should never reach coerce if validate ran). Assert handlers are not called on invalid_args — use a flag. Assert observations are under a byte cap even when got is huge: truncate got.

Codes the loop can branch on

Write the error catalog next to the schema, in git, with one line per code: who may retry, who must stop, what the hint should mention. invalid_args is for the model to fix a field. not_found is usually stop-or-search, not guess-a-new-id. denied is never a debate. timeout follows the retry table in the timeouts lesson. internal_error gets a correlation id for humans and a short “try later” for the model. Unknown codes are bugs in the handler.

Do not nest a traceback under detail. Do not put HTML. Do not echo a bearer token in got. Truncate got if the model pasted a novel. Observations have a size cap; errors are not exempt. A 4,000-line exception is still a 4,000-line exception if you wrap it in JSON.

Coercion stays narrow on purpose. Digit strings to positive ints. Maybe trim. Stop there. True is not 1. "17.0" is not an int if you required integer. "INV-17" does not become 17 because you saw a dash. Each extra coerce is a new way to refund the wrong row. When you add a coerce, add a fixture that would have been rejected before.

Retry caps live in the runtime, not in a plea. Two invalid_args on the same field and the loop stops. One denied and the loop stops. not_found twice on invented ids is a handoff, not a generator of ids. Metrics on codes will tell you whether Tuesday’s deploy broke the schema (invalid_args spike) or emptied the table (not_found spike).

How agents use this

The loop treats error codes as data. It does not scrape English. It retries invalid_args a little, timeout on reads, never denied as a debate. When you add a tool, you add its error catalog to the same doc as the schema. Unknown codes are bugs.

Structured errors are how a fake model in tests still looks like production. Return dicts. Always.

Note:got is for debugging fields, not for secrets. Redact tokens even in errors.

Check your understanding

What should a tool return when job_id is -1?