5 min read
Operable agents: schemas, traces, and the 3 a.m. test
Agents are distributed systems with a stochastic scheduler. Bound the tools, version the graph, and refuse anything you cannot explain from a trace.
In this edition

An agent that can plan, call tools, and loop is easy to demo. An agent you can operate is a different artefact. Operable means: when it fails, you know which tool, which state, which prompt version, and whether a human should have been in the loop.
I treat agents as distributed systems with a stochastic scheduler. The model proposes; the runtime disposes. If that sentence feels harsh, good — it is the difference between a workshop notebook and something that touches identity, money, or production data.
The 3 a.m. test
Before an agent ships, I ask:
- Can I answer “what did it do?” from traces without replaying a chat?
- Can I stop it — max steps, max tokens, kill switch — without redeploying?
- Can it refuse when tools return low confidence or empty results?
- Can a human approve irreversible actions?
- If the model starts inventing ids, does the schema reject the call?
If any answer is no, it is not an agent product yet. It is a prompt with side effects.
Graphs over hidden while-loops
A while tool_calls: loop in a request handler looks simple and becomes unownable. I prefer an explicit graph (LangGraph or equivalent) with typed state:
- what the user asked
- what we have already retrieved or fetched
- which tools ran, with arguments and results
- prompt / graph version
- stop reason
Rendering diagram…
Named nodes are what you trace, rate-limit, and evaluate. A blob of “the agent thought” is not.
Keep the HTTP layer thin. The request starts a run or resumes one. The graph lives in an application service. Infrastructure owns the LLM client, the tool adapters, and the queue. Long tool chains do not belong on the event loop of a user-facing worker — enqueue them.
Tools are APIs, not English
The model does not get a shell. It gets a catalogue of tools with Pydantic (or JSON Schema) bounds:
- required fields, enums, ranges
- ids that must already exist in state
- no free-form SQL, no unrestricted HTTP, no “run this code”
class FetchPolicyArgs(BaseModel):
tenant_id: str
policy_id: str
version: str | None = None
async def fetch_policy(args: FetchPolicyArgs, ctx: ToolContext) -> PolicyRecord:
if args.tenant_id != ctx.tenant_id:
raise ToolDeniedError("tenant mismatch")
return await ctx.policies.get(args.policy_id, version=args.version)
Validate before side effects. If the model invents policy_id, the call never leaves the process. Return structured errors the graph can branch on — not_found, denied, timeout — not a string the model can reinterpret as success.
Side-effecting tools (create ticket, grant access, send mail) get a second gate: idempotency keys and, for irreversible work, a human approval node. “The model was quite sure” is not an authorisation model.
Prompt and graph versions are config
Hardcoding the system prompt inside a service method makes every experiment a redeploy and every regression unattributable. Store prompts named and versioned. Log prompt_version and graph_version on every run.
When faithfulness drops on Tuesday, you want to know whether retrieval changed, the model router flipped, or someone “just tweaked the prompt.” Without versions, you will guess. Guessing is how agents earn a reputation for being unshippable.
Traces are the product surface for operators
A chat transcript is for the user. A trace is for you:
- run id, tenant, actor
- node timeline with latency
- every tool call: name, args, result size, error class
- retrieved chunk ids (not always the full text in the hot index)
- token counts and cost
- stop reason: completed, max_iterations, timeout, guardrail, human_abort
Sample traces online. Replay a subset offline against the golden set when you change a node. If you cannot filter “all runs where fetch_policy returned not_found,” you cannot tell whether the agent is lost or the catalogue is.
Redact secrets at the edge of the tracer. Prompts and tool payloads will otherwise become a second copy of production credentials.
Evaluation is not a vibe check
Score the agent the way you score a backend:
| Signal | Why it matters |
|---|---|
| Tool correctness | Right tool, valid args, no extra calls |
| Faithfulness | Answer grounded in tool results / retrieved chunks |
| Stop discipline | Did it halt on empty retrieve instead of improvising? |
| Latency / cost | p95 and tokens per successful task |
| Guardrail hits | Injection attempts, tenant mismatches, schema rejects |
A golden set of tasks, not questions: “rotate this credential,” “summarise the change in policy X with citations,” “refuse this cross-tenant ask.” Include the cases you are afraid of.
Offline eval on every graph or prompt change. Online, watch tool-error rate and human-override rate. Rising overrides mean the agent is creating work, not removing it.
Guardrails that stay boring
- rate-limit model and tool calls per user and per tenant
- cap iterations and wall-clock for a single run
- allow-listed tools only; no general code execution
- tenant and actor always from the auth context, never from the model
- human-in-the-loop for irreversible actions
- a kill switch that marks the graph disabled without a deploy
Prompt-injection is a retrieval and tool problem as much as a wording problem. Untrusted document text is data. It does not get to add tools or change tenant id. If your only defence is “ignore previous instructions,” you do not have a defence.
What I will not put in the agent
I will not put an agent on the hot path of a request that must stay under a tight p95 unless the graph is short, cached, and allowed to fail closed. I will not let it hold a database transaction or a Mongo session across an LLM round-trip. I will not let it choose its own IAM role.
Agents earn their place when the task is multi-step, the tools are narrow, and the runtime is stricter than the model. Clever is cheap. Operable is the job.