Harness Patterns
Harness Patterns
A field manual for AI agent harness engineering

Harness Patterns

Software design patterns are tried-and-tested ways to solve common problems in code. This book takes every pattern in the classic catalog and shows where it fits when you build an AI agent system for a business. Each one comes with an everyday comparison, a picture, and a note on how large companies use it. The language is kept simple, and every technical word is explained when it first appears.

192patterns13chapters192diagrams1enterprise review

First, what is an agent harness?

An AI model on its own only reads text and writes text. An agent is a model that can also do things: look something up, edit a file, send a message, then decide what to do next. The harness is all the software around the model that makes this possible and safe. It sends the conversation to the model, runs the tools the model asks for, remembers what happened, checks permissions, and shows the results to people.

Think of the model as a skilled pilot and the harness as the aircraft, the control tower and the airport rules. The pilot makes the decisions, but nothing flies safely without everything around them.

The harness at a glance

The chapters follow the main parts of a harness. Find the part you are building, then read that chapter.

prompttool_usegatesspawnPersonFrontendch. 10Harness corech. 8 architectureGuardrailsch. 7Agent loopch. 1 · ch. 9Model layerch. 3Tool systemch. 2Context +memorych. 4Async workch. 6Subagentsch. 5
Fig. 0.1The thirteen chapters mapped onto one agent harness

How to read a diagram

Shapes and colours mean the same thing on every page. A box with an orange outline is the pattern itself: the piece you would write. Orange arrows show the main path. Dashed arrows show something optional, delayed, or going back the other way.

AgentModel / LLMToolStorePersonGuard / policyQueueEventMessageServiceInterfaceObject / codeSchedulerDecision
Fig. 0.2Node kinds used throughout the book

How each entry works

Everyday comparisonA scene from daily life that works the same way.
Classic meaningWhat the pattern means in software in general.
In the agent systemWhere it appears when you build an agent, and why it helps.
In a large companyHow it is used in real business platforms, and what to be careful about.
Steps and code sketchWhat happens, in order, plus a few lines of simple example code for developers.
01
Chapter 1 · The agent's heartbeat

The Agent Loop

An AI agent works in a loop. It reads what has happened so far, asks the model what to do next, does it, and repeats until the job is done. The patterns in this chapter shape that loop: what stage it is in, where extra checks can plug in, how the model's reply is turned into actions, and how a run can be paused, resumed or rolled back.

1.01

Game Loop

Run the agent as a loop: build the request, call the model, run any tools it asks for, and repeat until it stops asking.

noyesappend resultsnext turnBuild requestprompt +transcriptCall modelstream responsetool_use?Final answerend_turnRun toolsexecute callsTranscript+ tool_result
Fig. 1.01The run is one cycle, repeated until the model stops asking for tools.
Classic meaning

A main loop that keeps taking input, updating state and producing output. It keeps the pace of the program separate from any single event.

In the agent system

Every harness has a loop at its centre, wrapped around the call to the model. Each pass sends the system prompt (the standing instructions) and the transcript (the conversation so far). It also sends the tool schemas (descriptions of the tools the model may use). Then the harness looks at the reply. If the reply has tool_use blocks (the model's requests to run a tool), the harness runs those tools. It puts the matching tool_result blocks (the tools' answers) into the next user message and goes round again. If stop_reason (the field that says why the model stopped) is end_turn, the model has finished. The loop ends and the final text goes to the user. Other stop reasons need their own handling. max_tokens means the reply was cut off at the length limit. A cut-off tool_use must never be run. pause_turn means a server-side tool (a tool the provider runs) paused a long turn. The harness should then send the request again to continue. The harness checks its guards at the start of every pass. These are max_turns (a limit on turns), a token budget (a limit on how much text the run may use) and a cancel flag. This is also where it handles compaction (shrinking old context to save space) and user messages waiting in a queue.

In a large company

In a large company platform, the agent loop usually runs inside a durable execution engine such as Temporal or AWS Step Functions. A durable execution engine saves each step, so the work survives a crash. If a worker (the server running the job) crashes, the run picks up from the last finished model call or tool call. Each pass of the loop is traced with OpenTelemetry, a standard for recording what software does. It uses the GenAI semantic conventions, which are agreed names for model and tool records (called spans). Each pass is also charged against a per-tenant token budget. A tenant is one customer company sharing the platform. The LLM gateway (a central service that all model calls pass through) enforces this budget. The key control is a hard limit per run on turns, tokens and wall-clock time (real elapsed time). Tenant policy sets this limit, not the prompt.

Step by step
  1. Build the request from the system prompt, the transcript and the tool definitions.
  2. Call the model and stream the reply to the screen as it arrives.
  3. If the reply has tool_use blocks, run them and add the tool_result blocks.
  4. Check the stop conditions (end_turn, max_turns, budget, cancel), then loop again or stop.
Code sketch
while turn < max_turns and not cancelled:
    turn += 1
    resp = model.create(system=sys_prompt,
                        messages=msgs,
                        tools=tools)
    msgs.append(resp.as_message())
    if resp.stop_reason == 'max_tokens':
        raise Truncated(resp)  # never run it
    if resp.stop_reason == 'pause_turn':
        continue  # resend to resume
    calls = resp.tool_use_blocks()
    if not calls:
        break  # end_turn: answer is ready
    msgs.append(run_tools(calls))

Be carefulA loop with no turn limit or budget check can run forever. This happens when the model keeps calling the same failing tool.

1.02

Update Method

Adapted fit

Give each part of the harness its own update hook, and let the loop call every hook once per turn.

1 on_turn2 on_turn3 on_turntrim msgsAgent loopevery turnBudget meteron_turn(ctx)Compactoron_turn(ctx)Task polleron_turn(ctx)Turn contextshared state
Fig. 1.02The loop knows one call, and each subsystem decides what that call does.
Classic meaning

Each object in a simulation has an update method. The main loop calls it on every active object once per frame.

In the agent system

Between model calls, a harness has many small jobs that must move forward a little each turn. The token meter works out how much text has been used so far. The compactor watches the context window (the text the model can see at one time). It checks whether the window is nearly full and must be shrunk. A todo tracker reminds the model if its task list is out of date. A poller collects background tasks that have finished. The harness does not write all of this into the loop itself. Instead, each subsystem (each separate part of the harness) registers an object with an on_turn(ctx) method. Here ctx is the context object that holds this turn's information. The loop goes through the list of registered objects once per turn. So adding a new behaviour means one new registration, not a change to the loop.

In a large company

Enterprise harnesses use per-turn update hooks for duties that touch every part of the system. One example is a budget meter. It reports spending to the chargeback ledger (the record used to bill each customer). Another is a policy refresher that picks up changed tenant settings. A tenant is one customer company sharing the platform. A third is a DLP monitor that samples new context. DLP (data loss prevention) means tools that stop sensitive data from leaking. Each hook should send its own OpenTelemetry span. OpenTelemetry is a standard for tracing software, and a span is one timed record of work. This way a slow part shows up against the turn latency SLOs (reliability targets for how fast a turn finishes). The control to enforce is this. The platform registers the hooks that tenants require, and a user or plugin setting cannot remove them or change their order.

Step by step
  1. Subsystems register objects that have an on_turn(ctx) method.
  2. At the start of each turn, the loop builds a TurnContext (this turn's shared information).
  3. The loop calls on_turn(ctx) on every registered object, in order.
  4. Each object reads or changes the context, then the model call goes ahead.
Code sketch
class Compactor:
    def on_turn(self, ctx):
        if ctx.tokens > 0.8 * ctx.window:
            ctx.msgs = summarise(ctx.msgs)

systems = [BudgetMeter(), Compactor(),
           TaskPoller(), TodoNudger()]

for turn in agent_loop():
    for s in systems:
        s.on_turn(turn.ctx)

Be carefulSometimes one update needs another update's result. Then the order of registration matters, so write that order down or merge those parts.

1.03

State

Direct fit

Make each phase of the loop its own state object, so each state decides how to react to messages, interrupts and tool results.

promptend_turntool_useapprovetool_resultdenyIdleaccepts inputCalling modelEsc = cancelDoneback to IdleRunning toolqueues inputAwaitingapprovalEsc = deny
Fig. 1.03Each state owns its own transitions, so a tool cannot run before approval.
Classic meaning

Let an object change its behaviour when its internal state changes. It does this by handing the work to a separate object for each state.

In the agent system

An agent session moves through clear phases: Idle, CallingModel, AwaitingApproval, RunningTool and Done. The same event means different things in each phase. A new user message starts a turn when the session is Idle. The same message waits in a queue while a tool is running (RunningTool). Pressing Esc in CallingModel stops the reply that is streaming in. Pressing Esc in AwaitingApproval counts as saying no. Each phase can be written as a state class with methods such as on_message, on_interrupt and on_tool_result. This replaces a tangle of if-statements. It also makes illegal transitions impossible to write by accident. A transition is a move from one state to another. An illegal one is, for example, running a tool without approval.

In a large company

On an enterprise platform, the harness saves the session state to storage, not just in memory. So a session in AwaitingApproval can wait hours for a human reviewer. It can then continue on another worker (another server). Durable workflow engines such as Temporal or Step Functions model these phases directly. They save each step so the work survives crashes. Every transition is written to an append-only audit log, a record that can be added to but never changed. Each record holds the actor, the tool call and the decision. The important control is in the approval step. It checks the approver's role through RBAC (role-based access control: permissions given by job role) or a policy engine. This way the person who asked cannot approve their own high-risk action.

Step by step
  1. The session keeps a reference to its current state object.
  2. Every event (message, interrupt, model reply, approval) goes to that state.
  3. The state handles the event and returns the next state.
  4. The session switches to the new state, and the screen shows it.
Code sketch
class AwaitingApproval(State):
    def on_approve(self, s):
        return RunningTool(self.call)
    def on_deny(self, s):
        s.add_result(self.call, 'denied')
        return CallingModel()
    def on_message(self, s, m):
        s.queue.append(m)  # hold it
        return self

Be carefulDo not let tools or hooks set the state field directly. Every change of state should go through the current state's methods.

1.04

Template Method

Direct fit

Fix the order of a turn's steps in a base class, and let each agent type change only the hooks it needs.

BASEAGENT.RUN_TURN()CODINGAGENToverrideoverrideBuild promptCall modelRun toolsRecord turntranscriptPrompt hooksystem_prompt()Result hookon_tool_result()
Fig. 1.04The outline is fixed, and subclasses fill in only the marked slots.
Classic meaning

Write the outline of a process in one method of a base class. Subclasses can replace certain steps, but they cannot change the outline.

In the agent system

A harness base class (shared parent code that other classes build on) defines run_turn(). This method builds the system prompt, trims the context, calls the model, sends out tool calls and records the turn. The method is final. That means subclasses (classes built on the base class) cannot replace it. A coding agent overrides (replaces) system_prompt() to add the repository's rules. It also overrides on_tool_result() to run a linter (a code style checker) after edits. A research subagent overrides only select_tools(), so it can use just search and fetch. The outline never changes. So certain rules hold for every agent type. For example, every turn is logged to the transcript, and the token budget is checked before each call.

In a large company

Platform teams ship a base agent whose fixed outline enforces rules for every tenant (each customer company on the platform). The outline passes on the user's identity and runs policy checks before each tool call. It removes PII (personal data such as names and emails) before logging. It also records OpenTelemetry spans (timed records of work, in a standard format) and checks each tenant's budget. Product teams build subclasses and override only prompts, tool choice and result handling. The risk is a team skipping the outline by calling the model client directly. To stop this, the LLM gateway (the central service all model calls pass through) should reject such calls. It rejects any call that does not carry the platform's tenant and trace headers, which are labels attached to each request.

Step by step
  1. The base class writes run_turn() as a fixed list of steps.
  2. Some steps call hook methods that have a default behaviour.
  3. A subclass overrides only the hooks it cares about.
  4. Every agent type runs the same outline with its own changes.
Code sketch
class BaseAgent:
    def run_turn(self, msgs):  # fixed
        sp = self.system_prompt()  # hook
        resp = self.model.create(
            system=sp, messages=msgs)
        for call in resp.tool_calls:
            out = self.tools.run(call)
            self.on_tool_result(call, out)
        self.transcript.record(resp)
class CodingAgent(BaseAgent):
    def system_prompt(self):
        return BASE + REPO_RULES

Be carefulIf subclasses start to override run_turn() itself, the guarantees are lost. Mark it final and add a new hook instead.

1.05

Chain of Responsibility

Pass each tool call through an ordered chain of hooks that can block, change or pass it on. Only the chain's end lets it run.

PRETOOLUSE HOOKSpasspassallowdenytool_usewrite_fileAllowlistPath guardAsk userToolexecutesError resultis_error: true
Fig. 1.05Each hook can stop the call or pass it to the next one.
Classic meaning

Give more than one object a chance to handle a request. Link the handlers in a chain and pass the request along until one handles it.

In the agent system

Before a tool call runs, the harness passes it through a chain of PreToolUse hooks. A hook is a small piece of code that runs at a set moment, here just before a tool is used. The chain might start with an allowlist check (a list of allowed tools). Next comes a path guard that blocks writes outside the workspace (the project folder the agent works in). Then a secrets scanner looks for passwords and keys. Last comes a prompt that asks the user to approve. Each hook gets the call and returns deny, modify or pass. A deny stops the chain at once. It becomes an error tool_result (the tool's answer, marked as an error) that the model can read. A pass hands the call to the next hook. Hooks managed by the organisation sit at the front of the chain. User settings cannot remove or override them. So a user hook that says allow can only skip the final approval prompt, never a required guard. Users and plugins add their own hooks after the managed ones, without changing harness code.

In a large company

In enterprise platforms, the chain usually starts with a call to a central policy engine such as OPA or Cedar. A policy engine is a service that decides what is allowed from written rules. Next comes DLP scanning (data loss prevention: checking for sensitive data that must not leak). Then come allowlists for each tenant (each customer company), and finally a human approval step for high-risk actions. Every decision goes into the audit trail. The record holds the deciding hook, the policy version and the principal (the user or service making the request). Auditors for SOC 2 and ISO 27001, two common security standards, expect to see this. The key risk is ordering. Required guards managed by the organisation must run first and fail closed, which means they block when something goes wrong. That way a plugin hook or a timeout can never turn into a silent allow.

Step by step
  1. The model sends a tool_use block, and the harness wraps it as a request.
  2. The first hook checks it and returns deny, modify or pass.
  3. On pass, the request goes to the next hook. On deny, the chain stops.
  4. If the last hook passes it, the tool runs with the input, which may have been changed.
Code sketch
class PathGuard(Hook):
    def handle(self, call, nxt):
        p = call.input.get('path')
        if p:
            rp = Path(ROOT, p).resolve()
            if not rp.is_relative_to(ROOT):
                return Deny('outside root')
        return nxt(call)

ROOT = Path(WORKSPACE).resolve()
chain = [Allowlist(), PathGuard(),
         SecretScan(), AskUser()]
result = run_chain(chain, call, execute)

Be carefulIn a long chain, it is hard to see which hook made the decision. Log the name of the deciding hook with every allow and every deny.

1.06

Interpreter

Read the model's structured output as action objects from a small grammar (a set of language rules), then run them on the workspace.

parseASTpatchexecModel textedit a.py; runParsergrammar rulesSeqinterpret(ctx)Editpath + diffRunpytest -qWorkspacefiles + shell
Fig. 1.06Text becomes a tree of nodes, and each node knows how to run itself.
Classic meaning

Define the rules (the grammar) of a simple language, and store each sentence as a tree of expression objects. Run the tree by calling interpret on each node.

In the agent system

Some harnesses let the model answer in a compact action language instead of one tool call per step. Examples are an edit block format, or a plan DSL with steps like edit, run and if_fail. A DSL (domain-specific language) is a small language made for one job. The harness parses (reads and breaks down) that text into a tree of expression nodes: Seq, Edit, Run and OnFail. Each node has an interpret(ctx) method that does its part of the work on the workspace (the project files). It goes through the same permission checks and sandbox as an ordinary tool call. A sandbox is a locked-down area where code runs safely. Sometimes the program arrives inside a tool_use input (the model's request to run a tool). Then parse errors go back as an error tool_result (the tool's answer, marked as an error), so the model can fix its syntax. Other times the program arrives as plain text from the model. Then errors go back in the next user message, because a tool_result needs a matching tool_use id.

In a large company

Enterprise platforms meet Interpreter in workflow DSLs (small languages for describing workflows) and structured edit formats. They also meet it in policy languages such as Rego and Cedar, which are themselves small interpreted languages for access rules. When the model writes the program, treat that program as untrusted input. Each node must run under the caller's identity, inside the sandbox, and through the same policy checks as a direct tool call. Grammar versions should be pinned (fixed to a known version) and covered by eval suites (sets of automated tests of model behaviour). Then a model upgrade that changes the output syntax fails a regression test gate. It does not break live production traffic.

Step by step
  1. The model returns text in the agreed action syntax.
  2. A parser turns the text into a tree of expression objects.
  3. The harness calls interpret(ctx) on the root node (the top of the tree).
  4. Each node acts on the workspace and passes results to its child or parent nodes.
Code sketch
class Seq(Expr):
    def interpret(self, ctx):
        return [s.interpret(ctx)
                for s in self.steps]

class Edit(Expr):
    def interpret(self, ctx):
        return ctx.fs.patch(self.path,
                            self.diff)

tree = parse(model_text)  # Seq([Edit, Run])
results = tree.interpret(ctx)

Be carefulKeep the grammar tiny. Once it needs variables and loops, you are building a programming language, and a real language in a sandbox is safer.

1.07

Memento

Direct fit

Save a snapshot of the session's messages and file edits before each risky step, so the user can go back to any earlier turn.

save(turn 6 snapshot)edit filessave(turn 7 snapshot)/rewind 6get(turn 6)snapshotrestore files + messagestranscript and workspace back at turn 6UserSessionCheckpoints
Fig. 1.07The session makes and reads snapshots, and the store only keeps them.
Classic meaning

Save an object's inner state in a sealed snapshot, without exposing its internals. Later, the object can be put back into that state.

In the agent system

Before each turn that edits files, the session makes a checkpoint. A checkpoint is a copy of the message list plus the original contents of every file the turn is about to change. A checkpoint store keeps these snapshots by turn number, without looking inside them. When the user runs a rewind command, the session restores the chosen checkpoint. It rewrites the files and cuts the transcript back to that turn. So both the conversation and the workspace (the project files) return to that point. Long sessions keep only recent checkpoints, or store file diffs (only the changes) to save disk space.

In a large company

In an enterprise harness, checkpoints hold transcripts and file contents. So they take on the strictest data classification of anything inside them. They need encryption at rest (encrypted while stored). The keys must belong to each tenant (each customer company) and come from a KMS (key management service). They need a retention period (how long data is kept) that matches the tenant's policy. They must be deleted when a GDPR erasure request arrives, which is a person's legal request under EU privacy law to delete their data. Durable execution engines give a similar rewind point at the workflow level. These are systems that save each step of a workflow so it can resume. The control to explain to users is this. Rewinding the workspace never reverses effects outside it. Those effects must be covered by compensating actions (steps that undo an effect) or by approvals instead.

Step by step
  1. Before a turn with edits, the session snapshots the messages and the files it will touch.
  2. The checkpoint store saves the snapshot under the turn number.
  3. The user asks to go back to an earlier turn.
  4. The store hands back that snapshot, and the session restores itself from it.
Code sketch
class Session:
    def checkpoint(self):
        files = self.fs.copy(self.touched)
        return Snapshot(self.msgs[:], files)
    def restore(self, snap):
        self.msgs = list(snap.msgs)
        self.fs.write_all(snap.files)

store[turn] = session.checkpoint()
session.restore(store[6])  # /rewind 6

Be carefulSnapshots cannot undo actions outside the workspace, such as a sent message or a deployed build. Tell the user this when they rewind.

1.08

Iterator

Go through stream events, turns or stored messages one at a time with next(), without loading or exposing the whole source.

read chunknext eventnext()read linenext turnnext()Model streamraw SSE bytesEvent iterator__anext__()Chat UIrenders deltasTranscriptturns.jsonlTurn iterator__next__()Compactorreads old turns
Fig. 1.08Consumers pull one item at a time and never see the source behind it.
Classic meaning

Give a way to go through the items of a collection one by one, without showing how the collection is stored.

In the agent system

A streaming model reply arrives as server-sent events (SSE: small messages the server pushes one after another). Their names include message_start, content_block_delta and content_block_stop. The SDK (the provider's code library) wraps the raw stream of bytes in an async iterator. An async iterator hands out items one at a time as they arrive, without freezing the program. So the harness can write async for event in stream and show text deltas (new pieces of text) as they come in. The same idea is used in other places. A transcript iterator reads JSONL lines (one JSON record per line) from disk only when needed. A paginated iterator over a long history fetches the next page only when the consumer asks. Consumers never care whether the items come from a network socket, a file or an API.

In a large company

Enterprise platforms usually stream model output through an LLM gateway, a central service that all model calls pass through. The gateway iterates over the provider's SSE events (server-sent events) and sends them on to clients. While doing so, it counts tokens for chargeback (billing each customer). It also scans each delta (new piece of text) for DLP violations. DLP means data loss prevention, which stops sensitive data from leaking. Long transcripts and audit logs are read with paginated or cursor-based iterators from object storage or a database. A cursor marks where the last read stopped. These records are never loaded into memory all at once. The risk is that inline streaming DLP sees text one delta at a time. So a secret split across two deltas can slip through, unless the scanner keeps a sliding window (a short memory of recent text).

Step by step
  1. The harness wraps the source (a stream, a file or a paged API) in an iterator object.
  2. The consumer asks for the next item.
  3. The iterator reads just enough of the source to make one item.
  4. When the source runs out, the iterator signals the end and the loop stops.
Code sketch
async for ev in client.stream(req):
    if (ev.type == 'content_block_delta'
        and ev.delta.type == 'text_delta'):
        ui.append(ev.delta.text)
    elif ev.type == 'message_stop':
        break

def turns(path):  # lazy over a JSONL file
    with open(path) as f:
        for line in f:
            yield json.loads(line)

Be carefulAn iterator over a live stream can be read only once. If both the screen and the transcript writer need the events, keep a copy of them.

1.09

Composite

Direct fit

Treat a message, a tool result and a single text block through the same interface, because content blocks sit inside each other.

containscontainsMessagetokens() = sumText blockleafTool resulttokens() = sumImage blockleafText blockleafImage blockscreenshot
Fig. 1.09Containers and leaves answer the same call, so the caller never checks types.
Classic meaning

Arrange objects in a tree, and let other code treat a single object and a group of objects in the same way.

In the agent system

A message's content is a list of blocks: text, image, tool_use and tool_result. A tool_result (the answer from a tool) can itself contain text and image blocks. The harness gives every block the same small interface (the same set of methods), such as token_count(), render() and redact(). Leaves (blocks with nothing inside them) answer directly. Containers like Message and ToolResult ask their children and combine the answers. So counting the tokens (the small text units models count) of a whole transcript becomes one call on the root (the top of the tree). It no longer needs nested loops that check each block's type.

In a large company

In enterprise platforms, DLP, PII redaction and audit export all walk this content tree before anything leaves the trust boundary. DLP (data loss prevention) stops sensitive data from leaking. PII redaction removes personal data such as names and emails. The trust boundary is the edge of the systems the company trusts. A redactor must go down into nested tool_result content, including images and documents. A scanner that checks only top-level text blocks misses most leaked data. Token counts made locally from the tree are only estimates. So chargeback (billing each customer) and budget limits should use the usage numbers reported by the provider. They can also use the numbers from the LLM gateway, the central service all model calls pass through.

Step by step
  1. Define one Block interface with the operations the harness needs.
  2. Write leaves (TextBlock, ImageBlock) that answer directly.
  3. Write containers (Message, ToolResult) that loop over their children.
  4. Callers use the operation on any node, from one block to a full transcript.
Code sketch
class TextBlock(Block):
    def tokens(self):
        return count(self.text)

class ToolResult(Block):
    def tokens(self):
        return 8 + sum(c.tokens()
                       for c in self.content)

total = sum(m.tokens() for m in transcript)

Be carefulDo not force methods onto the shared interface when they make no sense for every block. A leaf with fifteen methods that do nothing is a warning sign.

1.10

Visitor

Keep content block classes stable, and put each operation that runs across them, like token counting or redaction, in its own visitor class.

visit_textvisit_tool_usevisit_imageimplementsText blockaccept(v)Tool use blockaccept(v)Image blockaccept(v)Visitorone visit per typeRedactorscrubs secretsToken countersums tokens
Fig. 1.10Blocks send the call to the right method, and operations live in visitors.
Classic meaning

Put an operation on a structure's elements into a separate object. Then you can add new operations without changing the element classes.

In the agent system

Content blocks (the pieces of a message, such as text, images and tool calls) rarely change. But the list of operations run over them keeps growing. The harness counts tokens, redacts (hides) secrets before logging, and renders blocks to the terminal or to HTML. It also converts them to another provider's format. With Visitor, each block has a single accept(v) method. That method calls the matching method on the visitor v, such as v.visit_text(self) or v.visit_tool_use(self). A new operation is a new visitor class with one method per block type. The block classes and the code that walks the transcript stay the same.

In a large company

Enterprise platforms run several visitors over every transcript. One does PII redaction (removing personal data such as names and emails) before logs and traces are exported. One adds classification tags for data residency, the rules about which country data must stay in. One converts formats for an LLM gateway (a central service all model calls pass through) that sits in front of several providers. One exports records for audit. Keeping each as a separate visitor lets compliance teams own and test the redactor apart from the rendering code. The risk is coverage. A redaction visitor must handle every block type, including nested tool results and documents. Otherwise sensitive data reaches log storage.

Step by step
  1. Each block class writes accept(visitor) so it calls the matching visit method.
  2. An operation is a visitor class with one visit method per block type.
  3. The harness walks the transcript and calls block.accept(visitor) on each block.
  4. The visitor builds up its result, such as a token total or a redacted copy.
Code sketch
class ToolUseBlock(Block):
    def accept(self, v):
        return v.visit_tool_use(self)

class Redactor(Visitor):
    def visit_text(self, b):
        return scrub(b.text)
    def visit_tool_use(self, b):
        return scrub_json(b.input)  # deep

clean = [b.accept(Redactor()) for b in blocks]

Be carefulAdding a new block type means updating every visitor. If new block types arrive often, use Acyclic Visitor or plain methods on the blocks instead.

1.11

Acyclic Visitor

Let each visitor handle only the block types it knows, so a new content block type never forces edits elsewhere.

accept(v)visit_imagenoonly thisImage blockaccept(v)handlesimages?ThumbnailerImageVisitor onlySkip blockvisitor unchangedDocument blocknew typeDoc visitornew interface
Fig. 1.11The block asks the visitor what it handles, so visitors never list every type.
Classic meaning

A form of Visitor with no single interface that lists every element type. Each visitor uses small interfaces, one per type, and elements check for them while the program runs.

In the agent system

New content block types keep arriving: thinking blocks, citations, documents and server tool results. In a classic Visitor, every new type means adding a new method to every visitor. In the acyclic version, ImageBlock.accept(v) checks whether v implements ImageVisitor. ImageVisitor is a small interface, meaning a promised set of methods, for image blocks. The block calls visit_image only if v has it. A thumbnail generator implements only ImageVisitor. A new DocumentBlock comes with its own DocumentVisitor interface, and the thumbnail generator does not change. Safety visitors are the exception. A secret redactor must treat any block type it does not handle as an error. So a new DocumentBlock is blocked until the redactor learns to scan it.

In a large company

Enterprise platforms add block types and provider formats faster than compliance tools get updated. This is where Acyclic Visitor helps and where it hurts. Feature visitors such as thumbnail makers, renderers and analytics can safely skip unknown types. Security visitors such as DLP scanners, PII redactors and audit exporters must fail closed. DLP means data loss prevention, and PII means personal data such as names and emails. Fail closed means they block anything they cannot handle. Add a CI test, an automatic check run on every code change. It fails when any block type has no handler in a required security visitor.

Step by step
  1. Declare one small visitor interface for each block type.
  2. Each visitor class implements only the interfaces it needs.
  3. A block's accept(v) checks whether v supports its type.
  4. If it does, the block calls the visit method. If not, it skips or uses a default.
Code sketch
@runtime_checkable
class ImageVisitor(Protocol):
    def visit_image(self, b): ...

class ImageBlock(Block):
    def accept(self, v):
        if isinstance(v, ImageVisitor):
            return v.visit_image(self)
        return v.unhandled(self)  # skip/raise

class Thumbnailer(ImageVisitor):
    def visit_image(self, b):
        return shrink(b.data, 256)

Be carefulSkipping unknown types is safe for a thumbnail maker. It is not safe for a redactor or a DLP scanner (a tool that stops sensitive data from leaking). These must fail closed and reject any block type they do not handle.

1.12

Double Dispatch

Direct fit

Choose the handler from two runtime types at once: the incoming event's type and the session's current state.

dispatchdispatchon_interrupton_user_msgInterruptEsc pressedUser messagetyped mid-runRunningToolcurrent stateKill processthen IdleQueue messagestay runningIn Idle, a user messagestarts a turn
Fig. 1.12The event type picks the method, and the current state picks its behaviour.
Classic meaning

A way to pick which method to call from the runtime types of two objects (their actual types while the program runs). Usually the first object calls back into the second.

In the agent system

The harness receives several event types: UserMessage, Interrupt, ToolResult and ApprovalReply. Meanwhile the session can be in several states: Idle, Streaming and RunningTool. The right action depends on both. A UserMessage in Idle starts a turn, but in RunningTool it waits in a queue. An Interrupt in Streaming cancels the request, but in RunningTool it kills the child process (the separate program running the tool). The event's dispatch(state) method calls state.on_user_message(self) or state.on_interrupt(self). So the language's own method lookup picks the right cell in the event-by-state grid. No type switches (long if-else checks on type) are needed.

In a large company

In practice, enterprise harnesses dispatch on more than two things: event type, session state, and the caller's role or tenant tier. A tenant is one customer company, and its tier is its service level. Once the role axis appears, many teams move the grid into a policy engine such as OPA or Cedar. A policy engine decides what is allowed from written rules. Event-by-state behaviour stays in code, and identity-based decisions go in policy. Then each part can be reviewed by its own owners. The control is a default cell that denies or queues. So an unexpected combination is rejected and logged instead of acted on. An example is an approval reply that arrives while the session is Idle.

Step by step
  1. An event arrives, and the harness calls event.dispatch(current_state).
  2. The event calls back the state method named after its own type.
  3. That state method runs the behaviour for this event in this state.
  4. It returns the next state, which handles the next event.
Code sketch
class Interrupt(Event):
    def dispatch(self, state):
        return state.on_interrupt(self)

class RunningTool(State):
    def on_interrupt(self, ev):
        self.proc.kill()
        return Idle()
    def on_user_message(self, ev):
        self.pending.append(ev)
        return self

Be carefulThe grid grows as types times states. When most cells share a default, a small lookup table is easier to read than many methods.

1.13

Callback

Direct fit

Pass functions into the loop, and the loop calls them when a token streams in, a tool finishes or a turn ends.

run(on_delta, on_done)stream requesttext deltaon_delta(chunk)text deltaon_delta(chunk)message_stopon_done(message)Chat clientAgent loopModel API
Fig. 1.13The client hands over functions, and the loop calls them at the right moments.
Classic meaning

Pass a piece of code to other code as an argument. The other code calls it back at the right time.

In the agent system

Several clients share one agent loop: a terminal UI, a web client, an SDK script and an eval runner. An eval runner is a tool that tests the agent automatically. Each one wants to react at different moments. The loop accepts callbacks (functions it will call later) such as on_text_delta, on_tool_start, on_tool_end and on_turn_complete. It calls each one at its moment. The terminal prints the new pieces of text (deltas). The web client pushes them over a websocket, a live two-way connection to the browser. The eval runner ignores deltas but records every tool call. The loop itself never imports any UI code.

In a large company

Enterprise platforms attach callbacks for audit records, OpenTelemetry exporters, cost metering and webhooks. OpenTelemetry is a standard for tracing software. A webhook sends a message to another system, such as a ticketing or chat tool. Callbacks that cross a trust boundary (the edge of trusted systems) should get redacted data, with sensitive parts removed. They should be delivered asynchronously, later and separately, through a queue such as Kafka, Pub/Sub or EventBridge. Then a slow receiver cannot hold up a turn. Audit callbacks are the exception to ignoring errors. A tenant is one customer company, and regulated tenants work under strict laws. If the audit record cannot be written, regulated tenants usually require the action to fail. They do not want it to go ahead without a log.

Step by step
  1. The caller passes callback functions when it starts a run.
  2. The loop stores them and starts calling the model.
  3. At each event (text delta, tool start, tool end), it calls the matching callback.
  4. When the turn ends, it calls on_done with the final message.
Code sketch
def show(delta):
    term.write(delta.text)

def save(msg):
    transcript.append(msg)

agent.run(prompt,
          on_text_delta=show,
          on_tool_end=lambda c, r: log(c, r),
          on_done=save)

Be carefulA slow or failing callback can block or crash the loop. Give callbacks a time limit and catch their errors, so the turn still finishes.

1.14

Context Object

Direct fit

Bundle everything one turn needs (ids, budget, working folder, permissions, cancel signal) into one object passed to every component.

buildctxctxctxctxAgent loopturn startTurnContextids, cwd, budgetPermissionsmode from ctxModel clientbudget, cancelTool runnercwd, permsTracersession_id
Fig. 1.14One object carries the turn's facts to every component that needs them.
Classic meaning

Put the state and services that many layers need into one object, and pass that object along. This replaces passing many separate parameters.

In the agent system

Every turn touches many components: the model client, the tool runner, hooks, the tracer and the code that starts subagents. Each one needs some of these facts: the session id, turn number, working directory, permission mode, remaining token budget, abort signal and trace span. A trace span is one timed record of work, used for tracing. The harness builds a TurnContext at the start of the turn. It passes that single object down every call. Say you add a field, such as the active MCP servers. MCP is a standard way to connect tools to an AI model. You change only the context class and the code that reads it. You do not change every function signature in between, meaning the list of inputs each function takes.

In a large company

In an enterprise harness, the context object carries the tenant id (which customer company this is) and the authenticated principal (the verified user or service). It also carries the principal's OIDC claims, which are identity facts from the login system. It holds the data residency region (where data must stay), the W3C trace context (a standard trace id) and the remaining per-tenant budget. Every tool, policy check and log line reads identity from it. The identity fields should be immutable (unchangeable), so no component can raise its own privileges mid-turn. Raw credentials do not belong in it. Instead, keep a handle, which is a reference. The tool runner swaps it for a short-lived token from Vault or workload identity. Vault is a secrets store, and workload identity gives each service its own login. This way secrets never reach prompts, logs or the model.

Step by step
  1. At the start of a turn, the loop builds a TurnContext.
  2. It passes ctx to the model client, the tool runner and the hooks.
  3. Each component reads the fields it needs and may record usage back into it.
  4. When the turn ends, the context is thrown away or saved with the trace.
Code sketch
@dataclass
class TurnContext:
    session_id: str
    turn: int
    cwd: Path
    perms: PermissionMode
    budget: TokenBudget
    cancel: AbortSignal

ctx = TurnContext(sid, n, cwd, mode, b, sig)
resp = model.call(msgs, ctx)
out = tools.run(resp.tool_calls, ctx)

Be carefulA context object that collects every global value in the system becomes a hidden bag of dependencies. Keep it for one turn only, and keep it small.

1.15

Collecting Parameter

Direct fit

Pass one collector object through a series of steps, so each step adds its findings instead of returning separate results to merge.

pass listpass listaddaddaddsummariseCheck TODOsgrepScan secretsdiff linesRun testspytestFindingsappend(issue)Review reportto orchestrator
Fig. 1.15One list travels through every check and fills up along the way.
Classic meaning

Pass a collecting object into methods, so each one can add its results. This builds one combined result across many calls.

In the agent system

A code review subagent (a helper agent started by the main one) runs several checks. It uses grep (a text search tool) to find TODO notes and scans the diff (the code changes) for secrets. It also runs the tests and checks the changelog. Each check gets the same Findings object. It adds issues with the file, line and severity. After the last check, the subagent turns the collected findings into one report message for the orchestrator (the agent in charge). The same shape appears when a harness adds up usage across smaller calls. One UsageCollector is passed into every model call and tool call. The turn total is read from it at the end.

In a large company

Enterprise platforms use collecting parameters to add up usage and cost across a turn, its subagents and its tool calls. This feeds the per-tenant chargeback ledger, the record used to bill each customer company. They also use them for compliance findings gathered by review agents. Sometimes subagents run in parallel or on other workers (other servers). Then each returns its own partial collection, and the parent merges them. This is because a shared, changeable collector cannot cross process boundaries. Some findings contain pieces of code or data. Redact them (remove the sensitive parts) before writing them to tickets or audit storage.

Step by step
  1. Create an empty collector at the start of the task.
  2. Pass it into each step or tool wrapper.
  3. Each step adds what it found or what it used.
  4. Read the collector once at the end to build the report or the totals.
Code sketch
findings = Findings()
check_todos(diff, findings)
scan_secrets(diff, findings)
run_tests(repo, findings)

def scan_secrets(diff, out):
    for ln in diff.added_lines():
        if KEY_RE.search(ln.text):
            out.add(ln.path, ln.no, 'high')
report = findings.to_markdown()

Be carefulIf steps run in parallel, the collector needs a lock, so only one step writes at a time. Otherwise each step should return its own list to merge afterwards.

1.16

Pipeline

Prepare context in fixed stages: find candidates, rerank them by relevance, shorten the best ones, then insert them into the prompt.

CONTEXT PIPELINE508injectsearchQueryuser messageRetrievek = 50Reranktop 8Compress2k tokensContext windowdocument blocksVector storeembeddings
Fig. 1.16Each stage narrows the data and passes it on.
Classic meaning

Pass data through a series of stages, where each stage's output is the next stage's input. Then each stage can be built, tested and replaced on its own.

In the agent system

Before calling the model, a retrieval-augmented harness runs a context pipeline. Retrieval-augmented means it looks up documents and adds them to the prompt. The retriever pulls fifty chunks (short pieces of text) from a vector store and a keyword index. A vector store finds text by meaning, and a keyword index finds it by words. A reranker scores the chunks against the query and keeps eight. A compressor trims each one to the sentences that matter. An injector formats them as document blocks in the prompt, with source ids. Each stage takes the query and a list of chunks, and returns a new list. So a team can swap the reranker model without touching the other stages. They can also add a stage, such as one that removes duplicates or one that checks access rights. Evals (automated quality tests) can score each stage on its own.

In a large company

Enterprise retrieval pipelines add stages that a demo leaves out. These include an access-control filter that drops chunks the user may not read. They include a residency filter, for rules on where data must stay. They also include PII redaction (removing personal data such as names and emails) and provenance tagging, which records where each chunk came from for citations. The access check must use the end user's identity. It must run before reranking and injection. Once a chunk reaches the context window (the text the model can see), the model can repeat it. Each stage is traced with OpenTelemetry, a standard for recording what software does. Each is also scored by offline evals (tests run outside live traffic). So a reranker change can be held back until retrieval quality is proven, before rollout.

Step by step
  1. The user's query enters the first stage.
  2. Retrieve returns a broad set of candidate chunks.
  3. Rerank and compress narrow down and shorten the set.
  4. Inject formats the remaining chunks into the context window for the model call.
Code sketch
stages = [retrieve(k=50), rerank(top=8),
          compress(max_tokens=2000), inject]

def run(query):
    data = []
    for stage in stages:
        data = stage(query, data)
    return data  # prompt-ready blocks

prompt = run(user_msg)

Be carefulAny stage can drop the one chunk that mattered. Log what each stage removed, so a bad answer can be traced back to its stage.

1.17

Rule Engine

Check a set of written rules against each turn's facts to decide routing, approvals and compaction, without hard-coded branches.

factsloadctx > 80%destructivesimple taskTurn factsctx %, risk, tierRule setrules.yamlRule enginematch + orderCompact firstAsk approvalSmaller model
Fig. 1.17Facts go in, rules match, and the matching actions come out.
Classic meaning

Keep business rules apart from the program code by writing them as condition-action pairs. An engine checks them against a set of facts.

In the agent system

Each turn, the harness gathers facts. These include context usage, the requested tool and a risk class for its arguments. They also include the user's plan tier, the working directory and the time of day. A rule engine checks rules loaded from a settings file. One rule says: if the context is over 80 percent full, compact it (shrink it) first. Another says: if a command classifier marks the shell command as destructive, require approval. A command classifier is code that reads a command and labels how risky it is. A third says: if the task is classified as simple, send it to a smaller model. Matching rules produce actions, and the loop applies them in priority order. Operators change behaviour by editing rules as data. The rules can be tested against recorded turns.

In a large company

Enterprise platforms keep two kinds of rules apart. Operational rules handle routing, compaction and model choice. Authorization rules decide who may do what. A dedicated policy engine checks them, such as OPA with its Rego language, or Cedar. Both kinds are kept as versioned policy-as-code, meaning rules stored and tracked like program code. They are reviewed through change management. They are unit-tested in CI (automatic checks on every change) against recorded turns. Every decision is logged with its policy version for audit. The key risk is fail-open behaviour, where the system allows actions when something breaks. If the engine or its rule bundle is not available, authorization decisions must default to deny.

Step by step
  1. Load the rules from configuration at startup or when they change.
  2. At each turn, collect the facts into a dictionary (a set of named values).
  3. Check every rule's condition against the facts.
  4. Apply the actions of matching rules in priority order.
Code sketch
RULES = [  # loaded from rules.yaml
  {'if': {'ctx_pct': {'gt': 80}},
   'then': 'compact_first', 'prio': 1},
  {'if': {'cmd_risk': {'eq': 'high'}},
   'then': 'require_approval', 'prio': 0},
  {'if': {'task': {'eq': 'simple'}},
   'then': 'use_small_model', 'prio': 2}]

hits = [r for r in RULES
        if match(r['if'], facts)]
hits.sort(key=lambda r: r['prio'])
for r in hits:
    apply(r['then'])

Be carefulRules that affect each other are hard to reason about, for example when one rule's action changes another rule's facts. Keep actions independent, or check the rules only once per turn.

1.18

Trampoline

Run deep recursive planning as a flat loop, where each step returns the next step instead of calling it.

plan(goal)next subgoal?Bounce(plan, step 1)plan(step 1)Bounce(plan, step 2)stack depth stays at one frameplan(step 2)Done(steps)Trampolineplan()Model
Fig. 1.18The driver keeps the call stack flat by running each returned step itself.
Classic meaning

Run recursive functions in a loop. Each call returns a thunk (a small package describing the next call), and a driver loop runs it until a final value appears.

In the agent system

Some agents refine a plan one subgoal at a time. This is naturally written as a tail-recursive function, where the function's last act is to call itself. Here, plan(goal, steps) asks the model for the next subgoal and calls itself with the longer list. Python has no tail-call elimination, so it does not reuse memory for such calls. A long plan driven by the model keeps every in-between frame (a record of each unfinished call). It can then hit Python's recursion limit. With a trampoline, plan returns either Bounce(fn, args) for the next step or Done(result). A small driver loop keeps calling the bounces. This also gives the harness a natural place between steps to check the turn budget, save a checkpoint and respect a cancel request. A trampoline only flattens tail calls. A planner that branches into a tree of subgoals needs an explicit work stack instead, which is a list of pending tasks it manages itself.

In a large company

In enterprise platforms, a durable execution engine such as Temporal or Step Functions usually replaces the trampoline's driver loop. Such an engine saves each step, so work survives crashes. Each step is a recorded activity. After a crash or deploy, the workflow continues from the last finished one. The point between bounces is where the platform enforces budgets for each tenant (each customer company). It also checks whether an operator has cancelled the run, and writes a checkpoint for audit. The risk is depth with no limit, driven by the model. So the driver needs a hard step limit set by tenant policy, not by the model's own judgement.

Step by step
  1. Rewrite the recursive planner so it returns the next call instead of making it.
  2. The driver loop calls the first step.
  3. While the result is a Bounce, the driver calls the function inside it.
  4. When a Done arrives, the driver returns its value.
Code sketch
def plan(goal, acc):
    sub = model.next_subgoal(goal, acc)
    if sub is None:
        return Done(acc)
    return Bounce(plan, goal, acc + [sub])

def trampoline(r):
    while isinstance(r, Bounce):
        budget.check(); cancel.check()
        r = r.fn(*r.args)
    return r.value

Be carefulTrampolining adds an extra layer to code that rarely goes deep. Use it only when the depth of recursion depends on model output you do not control.

02
Chapter 2 · The agent's hands

The Tool System

A model can only write text. Tools are what let it act: read a file, search the web, send an email. This chapter shows how a tool request is packaged, found, created, wrapped with checks and finally run. Most of the classic object-oriented patterns find a home here.

2.01

Command

Direct fit

Turn every tool call into an object that the harness can queue, log, approve, retry or undo.

wrapenqueueapprovedresultundo()Modelemits tool_useToolCommandname + args + idExec queuePermissiongateToolwrite_fileTranscriptaudit + replay
Fig. 2.01A tool call becomes a command object before anything runs it.
Classic meaning

A request is wrapped up as an object. Other code can then queue it, log it or undo it without knowing how it runs.

In the agent system

A tool call is the model's request to run a tool, like reading a file. The model sends it as a tool_use block (a part of its reply that names the tool, its inputs and a call id). The harness wraps that block in a ToolCommand object. The object holds the tool name, the arguments and the call id. It then travels through the permission gate (the step that decides if the call may run), the execution queue and the transcript (the saved record of the conversation). The call is now data, not a function that runs at once. So the harness can pause it for the user to approve. It can replay it in an eval (an automated test of the agent). It can log it for audit. And it can run a compensating undo (an action that reverses the effect) when a later step fails.

In a large company

Enterprise platforms save each tool call as a durable command record (one stored safely, so it survives crashes). The record is keyed by the tool_use id. That id also works as an idempotency key (a label that makes a repeat safe). So if a worker retries or a workflow replays, the same payment or email is never sent twice. Durable execution engines such as Temporal or AWS Step Functions keep long tasks running through crashes. They run these commands as activities (single steps) with retry rules. The command log feeds an append-only audit store (records can be added but never changed). The store is kept under SOC 2 and ISO 27001 controls, two common security standards. The key rule is this. The platform retries a command automatically only if it is marked idempotent (safe to run twice with the same result) or reversible. Every other command needs a person to approve it, or a compensating action.

Step by step
  1. The model returns a tool_use block with a name, an input and an id.
  2. The harness builds a ToolCommand and puts it on the execution queue.
  3. A permission check approves, edits or rejects the command.
  4. The executor runs it, records the result, and keeps undo data when the tool supports it.
Code sketch
class ToolCommand:
    def __init__(self, tool, args, cid):
        self.tool, self.args = tool, args
        self.id = cid
    def execute(self):
        return self.tool.run(**self.args)
    def undo(self):
        return self.tool.revert(self.id)

cmd = ToolCommand(tools['write_file'],
                  block.input, block.id)
queue.put(cmd)

Be carefulSome commands change the outside world, like sending an email or paying an invoice. These often cannot be undone. Mark them as irreversible and ask for approval before they run.

2.02

Strategy

Keep one tool name and schema, but swap the code behind it, such as which backend a search tool uses.

callsearch()activepicksModeltool_use searchsearchone fixed schemaSearchStrategyinterfaceSearch APIhosted webVector storeuser documentsLocal ripgrepoffline repoSession configsearch: vector
Fig. 2.02The tool's name and schema stay fixed while the backend behind it is swapped.
Classic meaning

Several ways of doing the same job sit behind one shared interface. The caller picks one while running, without changing its own code.

In the agent system

The model sees one search tool. Its input schema (the fixed list of inputs it accepts) is query and max_results. Its description does not promise any particular backend (the system that does the real searching). Behind it, the harness holds a SearchStrategy interface with several versions. One uses a hosted web search API. One uses a vector store (a database for search by meaning) over the user's documents. One uses local ripgrep (a fast file search program) when the machine is offline. The config or the workspace type picks the strategy when the session starts. So the tool definition in the request stays byte-identical, exactly the same down to each character. That keeps the prompt cache hitting. The prompt cache is saved work the provider can reuse when a request starts the same way. Evals (automated tests of the agent) plug in a canned strategy that returns fixed results.

In a large company

Enterprise deployments pick the strategy per tenant (one customer company sharing the platform) from central config. One tenant searches an internal index hosted in its own region, for data residency (keeping data in a set region). Another uses a public web provider through an egress proxy (a gate that controls outgoing traffic). Eval runs use a canned strategy. The platform makes this choice from tenant policy, never from the model or from user input. Every strategy must apply the same document-level access checks. Then a cheaper backend cannot get around permissions. Record the chosen strategy as a span attribute in OpenTelemetry (a standard for tracing requests). Teams can then compare quality and cost for each backend.

Step by step
  1. The harness reads its config and picks a SearchStrategy for this session.
  2. The search tool is registered with one schema, whatever the choice.
  3. The model sends a tool_use block for search with a query.
  4. The tool hands the work to the chosen strategy and formats its hits as one tool_result.
Code sketch
class SearchStrategy(Protocol):
    def search(self, q, k): ...

class SearchTool:
    name = 'search'
    def __init__(self, strategy):
        self.strategy = strategy
    def run(self, query, max_results=5):
        hits = self.strategy.search(
            query, max_results)
        return format_hits(hits)

tool = SearchTool(STRATEGIES[cfg.search])

Be carefulStrategies may return results of different shape or quality. Then the model cannot learn what the tool does. Convert every strategy's output to one format.

2.03

Adapter

Wrap MCP servers and outside APIs so each one looks like a native tool, with the harness's own name, schema and run method.

ADAPTERSrun()tools/callHTTP GETAgent loopcalls tool.run()Tool interfacename, schema, runMCP adapterrun -> JSON-RPCHTTP adapterrun -> RESTMCP servertools/callWeather APIGET /forecast
Fig. 2.03Each foreign interface gets one adapter, so the loop only ever calls Tool.run().
Classic meaning

An adapter changes the interface of an existing piece of code into the one a caller expects. Parts that do not fit together can then work together.

In the agent system

The agent loop (the cycle of calling the model and running tools) knows one contract. A Tool has a name, a description, an input_schema, and run(args) that returns a ToolResult. MCP is a standard way to connect tools to an AI model. An MCP server speaks JSON-RPC instead, a message format for calling remote functions. It uses tools/list to list its tools and tools/call to run one. A REST API speaks HTTP, with its own auth headers and status codes. The harness writes one adapter for each outside source. An MCP adapter maps tools/call replies into a ToolResult. It turns isError, JSON-RPC errors and timeouts into error results. An HTTP adapter maps 4xx and 5xx responses (the HTTP error codes) the same way. Each adapted tool carries its server name as a prefix, such as mcp__github__create_issue. So two servers cannot clash on the same name. The loop, the permission gate and the transcript never learn where a tool really lives.

In a large company

Enterprises usually keep adapters in one central MCP or tool gateway. The gateway sits in front of approved internal APIs and third-party MCP servers. So every tool reaches the loop through one path that is audited and rate-limited (capped in how often it can be called). Remote MCP servers over streamable HTTP should sign in with OAuth (a standard for granting access), as the MCP authorization spec describes. The tokens are limited to the user and held by the adapter. They are never placed in the model context (the text the model can see). Treat tool descriptions from third parties as untrusted input. Pin server versions (lock them to a known version) and review any change to a description. An altered description can steer the model.

Step by step
  1. At startup, the harness connects to each MCP server and lists its tools.
  2. It wraps every remote tool in an adapter that follows the Tool interface.
  3. The model calls the tool by name, exactly like a built-in tool.
  4. The adapter turns the call into tools/call or HTTP and maps the reply back into a ToolResult.
Code sketch
class McpToolAdapter(Tool):
    def __init__(self, client, srv, spec):
        self.client, self.spec = client, spec
        self.name = f'mcp__{srv}__{spec.name}'
    def run(self, args):
        try:
            r = self.client.call_tool(
                self.spec.name, args)
        except McpError as e:
            return ToolResult.error(str(e))
        return ToolResult(
            content=r.content,
            is_error=r.isError)

Be carefulSome adapters turn remote failures into empty successes. This misleads the model. Pass errors and timeouts through as is_error results (results marked as failures).

2.04

Converter

Translate tool specs, calls and results between the harness's own types and each provider's wire format, in both directions.

HARNESS TYPESCONVERTERPROVIDER WIREsendparse / emitsendToolSpecname, desc, schemaToolCallid, name, argsToolResultblocks, is_errorspec_out()spec to wirecall_in / outwire <-> callresult_out()result to wiretools[] entryinput_schematool_use blockfrom the modeltool_resultcontent blocks
Fig. 2.04One converter owns every mapping between internal types and the wire, both ways.
Classic meaning

A converter is a separate object that changes data from one form to another. It usually has one function for each direction.

In the agent system

Inside the harness, a tool is a ToolSpec, a call is a ToolCall and an output is a ToolResult. Each model provider wants something else on the wire, meaning in the exact format it sends and receives. One expects input_schema at the top level. Another puts parameters under a function key. Results go back as tool_result blocks with text or image content and an is_error flag. A provider converter owns all of these mappings in both directions. It turns a spec into the wire format. It turns a tool_use block into a ToolCall, and back again when the history is sent again. It turns a ToolResult into content blocks. Adding a provider then touches only one class. Round-trip tests (converting out and back again) can prove that nothing is lost.

In a large company

Some platforms send traffic to several providers through an LLM gateway (a central service that routes all model traffic). They keep one standard internal message format and one converter per provider. The converter is a natural place to pull out OpenTelemetry GenAI attributes, such as the model name and token use. OpenTelemetry is a standard for tracing requests. Round-trip tests and golden-file tests (comparisons against saved, known-good provider messages) should run in CI as a regression gate. CI is the automatic checks that run on every code change, and a regression gate blocks changes that break old behaviour. This matters because a quiet mapping change can drop tool errors or image content. PII redaction (removing personal data such as names and emails) can also happen here. It runs just before content leaves the platform's trust boundary for an outside provider.

Step by step
  1. Before a request, the converter turns each ToolSpec into the provider's tool format.
  2. When the response arrives, it reads each tool_use block into a ToolCall.
  3. After the tool runs, it turns each ToolResult into a tool_result block that points to the call id.
  4. On the next turn, it turns stored ToolCalls back into tool_use blocks, so the history replays exactly.
  5. Round-trip tests check that converting out and back gives an equal object.
Code sketch
class MessagesConverter:
    # spec_out(): ToolSpec -> tools[] entry
    def call_in(self, b):
        return ToolCall(b.id, b.name, b.input)
    def call_out(self, c):  # history replay
        return dict(type='tool_use', id=c.id,
            name=c.name, input=c.args)
    def result_out(self, r):
        return dict(type='tool_result',
            tool_use_id=r.call_id,
            content=r.blocks,
            is_error=r.is_error)

Be carefulConverters break quietly when a provider adds a new block type. Fail loudly on unknown types instead of dropping them.

2.05

Facade

Direct fit

Give the model one simple tool that hides a many-step system, so the model makes one call instead of five.

HIDDEN SUBSYSTEMcallsummaryModelwants test statusrun_testsone simple callSandboxboot or reuseDependenciesinstall lockfileTest runnerpytestReport parserXML to summary
Fig. 2.05The model makes one call, and the facade drives four parts and returns a short summary.
Classic meaning

A facade gives one simple interface to a complex system. Callers then need to know less about its parts.

In the agent system

Running a project's tests in a fresh sandbox (a sealed space where code runs safely) takes many steps. The harness must start a container, install dependencies and start the test runner. Then it must read a JUnit XML report (a standard test results file) and trim megabytes of logs. If these were five tools, the model would spend turns and context on running them. The context is the text the model can see at one time. The model could also run them in the wrong order. A run_tests facade takes one optional path filter. It does every step inside and returns a short summary of failures. The parts are still there for harness code and other facades to use. But the model's tool list stays small.

In a large company

Enterprises give the model broad facade tools such as run_tests, open_ticket or refund_order instead of raw APIs. This shrinks the model's tool list. It also lets the platform attach one check to each business action. That means one RBAC check (role-based access control: permissions given by job role), one approval rule and one audit event. The facade is also where the platform enforces per-tenant quotas, timeouts and cost limits for the whole many-step job. A tenant is one customer company sharing the platform. The risk is that a facade hides which systems it touched. So the audit record should list every call to an inner part, even though the model only sees one summary.

Step by step
  1. The model calls run_tests with an optional path filter.
  2. The facade starts or reuses the sandbox and installs dependencies.
  3. It runs the test runner and reads the report.
  4. It returns a short summary of failing tests as one tool_result.
Code sketch
def run_tests(path='.'):
    box = sandbox.acquire(repo)
    deps.install(box, lockfile='uv.lock')
    raw = runner.run(box, 'pytest', path)
    rep = junit.parse(raw.xml)
    return summarise(rep.failures,
                     max_tokens=800)

Be carefulA facade that hides too much leaves the model stuck when something unusual breaks. Include a log path so it can dig deeper.

2.06

Registry

Keep one map from names to tools, which the harness uses to list tools for the model and to route each tool_use call.

registertools[]tool_use.namedispatchBuilt-in toolsread, write, bashMCP serversmcp__* toolsPluginsextra toolsToolRegistryname -> toolModeltool_use: grepgrep handlerruns the call
Fig. 2.06Every tool enters through one map, which both lists the tools and finds each call.
Classic meaning

A registry is a well-known object that other code uses to find shared objects and services by name.

In the agent system

Three kinds of tools go into one ToolRegistry at startup. These are built-in tools, tools adapted from MCP servers and plugin tools. MCP is a standard way to connect tools to an AI model. Each tool is stored under a unique name such as read_file or mcp__github__create_issue. The registry produces the tools array sent with each request. A tool_use block is the model's request to run a tool. When one arrives, the dispatcher (the harness code that routes calls) looks up block.name and calls that handler. Duplicate names fail when they are registered, not later when they are called. An unknown name returns an is_error tool_result (a result marked as a failure), so the model can correct itself.

In a large company

In a multi-tenant platform, the registry is filtered on each request by tenant, role and data classification. A tenant is one customer company sharing the platform. This filter often uses a policy engine such as OPA or Cedar, which are rule engines that decide who may do what. So the model only sees tools the current user is allowed to use. The dispatcher must repeat the permission check on every call. The model can send any tool name. A resumed transcript may also name tools that have since been taken away. Record each session's tool list in the audit trail. Then a review after an incident can show exactly which tools were available.

Step by step
  1. Each tool source registers its tools by unique name at startup.
  2. The registry lists schemas for the request's tools array.
  3. A tool_use block arrives, and the dispatcher calls registry.get(block.name).
  4. The handler runs, or an unknown name returns an error result.
Code sketch
class ToolRegistry:
    def __init__(self):
        self._tools = {}
    def register(self, tool):
        if tool.name in self._tools:
            raise DuplicateTool(tool.name)
        self._tools[tool.name] = tool
    def get(self, name):
        return self._tools.get(name)
    def schemas(self):
        return [t.spec() for t in
                self._tools.values()]

Be carefulChanging the tools array during a session breaks the prompt cache (saved work the provider can reuse when a request starts the same way). This is because tools sit at the start of the cached prefix. Freeze the list for each session. Bring in late tools through deferred loading (adding them later without changing the start of the request).

2.07

Factory

Direct fit

Create tools from a name and a config entry, so agent definitions list tools as data instead of building them in code.

name + cfginjectnewagent.yamltools: [bash, web]ToolFactorycreate(name, cfg)Shared depssandbox, http,credsBashTooltimeout=120WebFetchdomain allowlistReadFileworkspace root
Fig. 2.07Names in config go in, and finished tools with shared dependencies come out.
Classic meaning

A factory is an object whose job is to create other objects. One creation call hides which exact class gets made.

In the agent system

An agent definition file says tools: [bash, web_fetch, read_file]. It also has settings for each tool, such as a timeout or a list of allowed domains. A ToolFactory maps each name to a constructor (the code that builds that tool). It injects shared dependencies, meaning it hands each tool the shared parts it needs. These include the sandbox handle, the HTTP client and credentials. Then it returns ready Tool objects. Subagent definitions, eval configs and user settings all go through the same factory. So a tool is always built the same way, with the same safety defaults.

In a large company

Enterprise tool factories inject credentials that belong to one user and expire soon. They get these from a secrets manager such as HashiCorp Vault, or from cloud workload identity (identity the cloud gives to running code). They do not use one shared service credential. So every call to another system can be traced to a user and is limited by that user's permissions. The factory is the right place to apply tenant defaults. A tenant is one customer company sharing the platform. These defaults include egress allowlists (the outside hosts a tool may reach), timeouts and regional endpoints. Then no agent definition can build a tool without them. Credentials stay inside the tool object. They must never be written into the transcript or the model context (the text the model can see).

Step by step
  1. The harness loads the agent definition and its tools list.
  2. For each entry, it calls ToolFactory.create(name, settings).
  3. The factory picks the class, injects shared dependencies and applies defaults.
  4. The finished tools are registered for the session.
Code sketch
class ToolFactory:
    def __init__(self, deps):
        self.deps = deps
    def create(self, name, cfg):
        if name == 'bash':
            return BashTool(
                self.deps.box, cfg.timeout)
        if name == 'web_fetch':
            return WebFetch(
                self.deps.http, cfg.domains)
        raise UnknownTool(name)

Be carefulSometimes creating a tool is one constructor call with no shared dependencies. Then a factory only adds an extra step. Call the class directly.

2.08

Factory Method

Direct fit

Let a base runner own the agent workflow, while each subclass decides which executor or tool it creates.

overridesoverridescreatescreatesBaseRunnerrun() calls make_shell()LocalRunnermake_shell()CloudRunnermake_shell()LocalShelluser machineSandboxShellcontainer, no net
Fig. 2.08The base class owns the loop, and each subclass only decides which shell is created.
Classic meaning

A base class has a method for creating an object. Its subclasses (child classes that build on it) decide which class that method actually makes.

In the agent system

A BaseRunner runs the turn loop, the approval flow and the writing of the transcript (the saved record of the conversation). Whenever it needs a command executor, it calls self.make_shell(). LocalRunner overrides make_shell, meaning it replaces it with its own version. Its version returns a shell on the user's machine. CloudRunner returns a shell inside a container with no network access. Tests make one more subclass. It returns a fake shell that replays recorded output. The loop is written and tested once, and only the creation step changes.

In a large company

Enterprise platforms use this shape to support several execution backends behind one audited loop. One is a runner on a developer's laptop. One is a sandbox runner hosted on Kubernetes (a system that runs containers at scale). One is a recorded-replay runner for evals and regression gates. Evals are automated tests of the agent, and regression gates block changes that break old behaviour. Approvals, audit logging and budget checks live in the base class. So a new backend cannot skip them by mistake. Change management should treat a new subclass as an item for security review. The factory method decides where commands really run and what network they can reach.

Step by step
  1. BaseRunner.run() starts the loop and needs an executor.
  2. It calls its own make_shell() method instead of a constructor.
  3. The subclass's version returns a local shell, a sandbox shell or a fake.
  4. The loop uses the returned shell through the shared interface.
Code sketch
class BaseRunner:
    def run(self, task):
        shell = self.make_shell()
        return self.loop(task, shell)
    def make_shell(self):
        raise NotImplementedError

class LocalRunner(BaseRunner):
    def make_shell(self):
        return LocalShell(cwd=self.repo)

class CloudRunner(BaseRunner):
    def make_shell(self):
        return SandboxShell(net=False)

Be carefulOne subclass for each variation adds up fast. When the choice is plain config, pass in a factory function instead.

2.09

Abstract Factory

Direct fit

Create a whole matching family of tools, such as a shell and a file system, for one environment, so local and sandboxed tools never mix.

LOCALSANDBOXaskslocalsandboxSessionenv = sandboxToolkitFactorymake_shell,make_fsLocalToolkitSandboxToolkitHost shelluser's machineHost filesuser's diskContainershellinside boxVolume FSsame mount
Fig. 2.09Choosing one factory chooses the whole family, so environments never mix.
Classic meaning

An abstract factory gives one interface for creating families of related objects. Callers never name the exact classes.

In the agent system

A coding agent needs a shell, a file system tool and often a browser. They must agree on where the workspace lives. A ToolkitFactory interface declares make_shell, make_fs and make_browser. LocalToolkit returns tools that act on the user's disk. SandboxToolkit returns tools that all target the same container and mounted volume. A sandbox is a sealed space where code runs safely. The session picks one factory from its environment setting. So a file the sandbox file system writes is always visible to the sandbox shell. And no code path can pair a local shell with a sandboxed file system.

In a large company

Enterprises use toolkit families to tie every tool in a session to one execution environment and one data region. For example, an EU sandbox family has a shell, file system and browser that all run in EU infrastructure. They all follow the same egress policy (rules for outgoing network traffic). Data residency (keeping data in a set region) and isolation then come from the chosen factory. Each tool no longer has to remember them. The rule is this. The platform chooses the family from tenant policy when the session starts. A tenant is one customer company sharing the platform. Any attempt to mix families within a session is rejected and logged.

Step by step
  1. The session reads its environment setting: local or sandbox.
  2. It selects the matching toolkit factory.
  3. It calls make_shell, make_fs and make_browser on that one factory.
  4. The resulting family is registered and shares one workspace.
Code sketch
class ToolkitFactory(Protocol):
    def make_shell(self): ...
    def make_fs(self): ...

class SandboxToolkit(ToolkitFactory):
    def __init__(self, box):
        self.box = box
    def make_shell(self):
        return ContainerShell(self.box)
    def make_fs(self):
        return VolumeFS(self.box.mount)

kit = KITS[session.env](env_handle)
tools = [kit.make_shell(), kit.make_fs()]

Be carefulAdding a new kind of tool means changing every factory in the family. So keep the family small and stable.

2.10

Factory Kit

Direct fit

Build a frozen kit of named tool builders once at startup, then make exactly the tool set each agent profile asks for.

REGISTERED ONCEaddnamescreate()equip'bash' builderlambda: Bash()'grep' builderlambda: Grep()'read' builderlambda: ReadFile()ToolKitfrozen name mapreviewer.yamltools: [grep,read]Fresh toolsetGrep, ReadFileReviewersubagent
Fig. 2.10Builders are registered once and frozen, and each profile picks names and gets fresh tools.
Classic meaning

A factory whose list of things it can make is set up first, usually as a map from names to builder functions. Then the list is locked so it cannot change.

In the agent system

At startup, the harness fills a ToolKit with builder functions. bash builds a sandboxed shell. grep builds a search tool. read builds a file reader that can only see the workspace. The kit is then frozen. Agent profiles such as reviewer.yaml list only names. kit.create(['grep', 'read']) returns a fresh tool set for that subagent (a helper agent for one task). The kit is closed after startup. So no profile can reach a tool the operator did not install. Each subagent also gets new tool instances, not shared ones that others can change.

In a large company

In enterprise platforms, the frozen kit is the tool catalogue the operator has approved. Only tools that passed security review are added at startup. Tenant or team profiles can choose from it but can never add to it. A tenant is one customer company sharing the platform. Per-tenant allowlists (lists of what is allowed) narrow the kit further. Each profile change goes through change management, with an eval run (an automated test of the agent) before rollout. Fresh instances for each subagent also stop per-user credentials and state from leaking between sessions that run at the same time.

Step by step
  1. The harness registers a builder function for each tool name that can be installed.
  2. It freezes the kit, so no names can be added while running.
  3. A subagent profile lists the tool names it needs.
  4. kit.create(names) calls each builder and returns a fresh tool set.
Code sketch
kit = ToolKit()
kit.add('bash', lambda: Bash(box=sandbox))
kit.add('grep', lambda: Grep(root=repo))
kit.add('read', lambda: ReadFile(root=repo))
kit.freeze()

profile = load('agents/reviewer.yaml')
tools = kit.create(profile.tools)
# -> [Grep(...), ReadFile(...)]
kit.create(['curl'])  # raises UnknownTool

Be carefulA builder might return one shared object instead of a new one. Then subagents overwrite each other's state.

2.11

Builder

Direct fit

Put together a large model request piece by piece, from system blocks, tools, history, cache breakpoints and limits, then check it once.

build()sendSystem prompt.system(base, mem)Tool schemas.tools(registry)History.messages(compact)Limits.max_tokens(8000)RequestBuildercollects partsAPI requestchecked, completeModel APImessages.create
Fig. 2.11Parts arrive from different places, and build() checks them together before sending.
Classic meaning

A builder keeps the steps for making a complex object apart from the finished object. The same step-by-step process can then make different results.

In the agent system

One model request can hold many parts. It can have a system prompt in layers, dozens of tool schemas and a long message history. It can have cache_control breakpoints (markers for where the prompt cache should save work). It can also have a thinking budget and max_tokens (a cap on reply length). A RequestBuilder lets the loop add these parts as they become known. System blocks come from settings and memory files. Tools come from the registry. History comes from the transcript after compaction (shrinking old conversation to save space). Then build() checks the whole request once. The total tokens must fit in the context window (the text the model can see at one time). The cache breakpoints must stay within the provider's limit. Every tool_result must be paired with its tool_use. Subagent and eval versions reuse the same builder with different parts.

In a large company

Enterprise harnesses put all request assembly in one builder, so platform policy is applied in one place. This policy includes required system blocks, such as acceptable-use rules. It includes tenant-specific redaction (hiding sensitive parts) of history. It includes per-tenant max_tokens and budget caps. It also includes placing cache breakpoints only on content that is safe to reuse. A tenant is one customer company sharing the platform. A multi-tenant platform usually sends all tenants through one provider account. So the builder must never put one tenant's data in a prefix built for another. build() is also a good place to stamp the tenant id and trace id as metadata. The LLM gateway (a central service that routes model traffic) uses them for cost chargeback, which bills cost to each team. OpenTelemetry traces (a standard for tracing requests) use them too.

Step by step
  1. The loop creates a RequestBuilder for this turn.
  2. Settings, memory, registry and transcript each add their part.
  3. build() checks the token budget, the cache breakpoints and the tool pairing.
  4. The finished request is sent to the model API.
Code sketch
req = (RequestBuilder(model=MODEL)
       .system(BASE_PROMPT)
       .system(memory.text(), cache=True)
       .tools(registry.schemas())
       .messages(transcript.compacted())
       .max_tokens(8000)
       .build())  # validates budget + pairs
resp = client.messages.create(**req)

Be carefulIf build() does no checking, the builder is only a long constructor. Put the checks across fields there, or skip the pattern.

2.12

Step Builder

Direct fit

Force a subagent or request spec through its required steps in a set order, so build() is out of reach until each is set.

.model().system().tools().budget()build()ModelSteponly .model()PromptSteponly .system()ToolsSteponly .tools()BudgetSteponly .budget()BuildStep.build() + extrasSubagentSpeccomplete, validNo .build() onearly steps
Fig. 2.12Each step type offers only the next call, so build() is out of reach until all are set.
Classic meaning

A step builder is a kind of builder where each step returns a type that shows only the next valid step. So the type system makes sure the steps come in order and none are missed.

In the agent system

Starting a subagent (a helper agent for one task) needs a model, a system prompt, a tool set and a token budget. A forgotten budget means a run that never stops. A SubagentSpec step builder starts with a ModelStep that has only .model(). That returns a PromptStep with only .system(). Next comes a ToolsStep with only .tools(), then a BudgetStep with only .budget(). Only the final BuildStep offers optional extras and .build(). A type checker (a tool that checks code types before it runs) flags a missing step before the harness runs. The order matches how people review specs: model, instructions, abilities, limits.

In a large company

Enterprise platforms use step builders, or similar schema checks, for specs where a missing field is a compliance or cost failure. Examples are a subagent without a budget, or a job without a data classification. Another is an agent with no named owner who answers for it in an audit. Type checks catch the gap during development. But specs that arrive as YAML files or API messages skip the type checker. So the platform must also check the same required fields while running. The harness and the LLM gateway (a central service that routes model traffic) must also enforce the budget in the spec. They must not just trust it as a statement.

Step by step
  1. SubagentSpec.new() returns a ModelStep that shows only .model().
  2. Each call returns the next step type: prompt, then tools, then budget.
  3. Only the final BuildStep has .build(), next to optional extras.
  4. A skipped step shows up as a type error before anything runs.
Code sketch
spec = (SubagentSpec.new()
        .model('fast-model')
        .system(REVIEW_PROMPT)
        .tools(['grep', 'read'])
        .budget(tokens=50_000)
        .build())

class ModelStep:
    def model(self, m) -> 'PromptStep':
        return PromptStep(m)
# ModelStep has no .build(): skipping fails

Be carefulA step builder adds one class for each step. So keep it for specs where a missing field is dangerous, not for every options object.

2.13

Prototype

Copy a tuned agent or tool config template and change only a few fields, instead of building each version from scratch.

get()deepcopy()Template storenamed configsreviewerprompt, tools,modelClone 1+ scope: api/Clone 2+ scope: web/Clone 3+ scope: db/
Fig. 2.13Each subagent starts as a deep copy of one tuned template plus a small change.
Classic meaning

You make new objects by copying an existing sample object, the prototype. Then you adjust the copy.

In the agent system

A well-tuned code reviewer subagent (a helper agent for one task) has a long system prompt and a chosen tool list. It also has a model choice, stop rules and hooks (code that runs at set moments). The orchestrator is the main agent that hands out work. To send reviewers over several folders, it deep-copies the reviewer template. A deep copy copies everything inside, including nested lists. Then it sets only the folder scope and output file on each copy. Put those changes in the first user message, not the system prompt. Then every clone keeps the template's tools and system prompt exactly the same, byte for byte. So all clones can reuse one prompt cache entry (saved work the provider can reuse when a request starts the same way). But that entry exists only once the first request starts to respond. So start one clone before the rest. The template itself is never changed. The next batch of copies starts from the same known state.

In a large company

Enterprises keep approved agent templates in a catalogue with versions, reviewed like code. Teams make new versions by copying a template and changing a small, allowed set of fields, such as scope or output location. The fields that matter for safety are locked. These include tools, model and policy hooks. This stops a copy from quietly widening its permissions. Record the template version on each subagent that starts. This supports audits and tracking of eval regressions (drops in automated test results). Templates should be plain data. Then a deep copy never copies live clients or credentials.

Step by step
  1. The harness loads a named template, such as reviewer, from the template store.
  2. The orchestrator deep-copies it once for each task.
  3. Each copy gets its own small changes, such as scope and output path.
  4. The copies start as subagents, while the template stays unchanged.
Code sketch
base = templates.get('reviewer')

def variant(scope):
    spec = copy.deepcopy(base)
    spec.scope = scope
    spec.output = f'reviews/{scope}.md'
    return spec

specs = [variant(d) for d in
         ['api/', 'web/', 'db/']]
for s in specs: harness.spawn(s)

Be carefulA shallow copy shares nested lists such as tools or hooks. So one copy's edit leaks into the template and the other copies. Always deep-copy.

2.14

Proxy

Put a permission and sandbox layer with the same interface in front of every real tool, so nothing runs without passing its checks.

run('rm -rf dist')check allow / deny rulesno rule matched: user approves in the UIrun in sandbox, 120s timeoutstdout + exit coderedact secrets, log decisiontool_resultAgent loopGuardedToolbash (real)
Fig. 2.14Every call passes the proxy's rules, approval and sandbox before the real tool runs.
Classic meaning

A proxy is a stand-in object with the same interface as the real one. It can control who gets access to the real one.

In the agent system

Every tool in the registry is wrapped in a GuardedTool proxy. It offers the same run(args) as the real tool. Before passing a call on, the proxy checks allow and deny rules against the tool name and arguments. Examples are writes outside the workspace, or network calls to hosts not on the list. When no rule decides, it shows an approval card to the user. Then it runs the real call inside the sandbox (a sealed space where code runs safely), with a timeout. Rules that match shell command text are easy to get around. So they only decide what needs approval, and the sandbox stays the hard boundary. On the way back, the proxy removes secrets from the result. It writes the decision to an append-only audit log (records can be added but never changed), and also to the transcript. The agent loop cannot tell the difference. And there is no path to the real tool that skips the proxy.

In a large company

In enterprise platforms, a central policy engine such as OPA or Cedar backs the proxy. These are rule engines that decide who may do what. The engine looks at the tool name, the arguments, the user's role, the tenant and the data classification. A tenant is one customer company sharing the platform. High-risk actions need a person to approve them (human-in-the-loop). Each decision goes to an append-only audit log outside the transcript, with the approver's identity. So it survives compaction (shrinking old conversation to save space). It also supports SOC 2 audits (a common security standard). For high-risk systems, it supports EU AI Act record-keeping too. The proxy should fail closed. If the policy service cannot be reached, calls are denied, not allowed.

Step by step
  1. The loop calls run(args) on the proxy, exactly as it would on the real tool.
  2. The proxy checks allow and deny rules against the tool name and arguments.
  3. If no rule decides, it pauses for user approval in the UI.
  4. Once allowed, it passes the call into the sandbox, then removes secrets and logs the result.
Code sketch
class GuardedTool(Tool):
    def __init__(self, real, policy, ui):
        self.real, self.policy = real, policy
        self.ui, self.name = ui, real.name
    def run(self, args):
        v = self.policy.check(self.name, args)
        if v == 'ask':
            v = self.ui.approve(self.name, args)
        if v != 'allow':
            return ToolResult.denied(v)
        out = sandbox.run(self.real, args)
        return redact(out)

Be carefulA proxy only guards what it wraps. A tool registered without it, or a raw subprocess call elsewhere in the harness, is an open side door.

2.15

Virtual Proxy

Use a light stub in place of a costly tool, and load its full schema or start its server only when first needed.

callyesno: first useswap inappend schemaModelselects db_queryLazyTool stubname + one lineloaded?Real toolMCP adapterLoaderstart server,fetch schemaContext windowschema appended
Fig. 2.15The stub costs one line in the prompt, and the real tool loads once, on first use.
Classic meaning

A virtual proxy waits to create or load a costly object until someone actually uses it.

In the agent system

A session may have hundreds of MCP tools available. MCP is a standard way to connect tools to an AI model. Putting every full JSON schema (the full description of a tool's inputs) in the system prompt would cost tens of thousands of tokens. It would also start servers that are never used. So the harness registers a stub for each tool. A stub is a light stand-in that holds only the tool's name and a one-line description. The model may pick a deferred tool (one not fully loaded yet) for the first time. It may do this through a tool search call, or directly. Then the stub starts the MCP server and fetches the full schema. It adds the schema to the context and swaps in the real adapter. Later calls go straight through.

In a large company

Enterprise platforms often reach hundreds of MCP servers through a gateway (one central entry point). So they delay both schema loading and server start-up. This keeps the token cost of each request low, and it cuts cold-start load (the cost of starting servers) for every tenant. A tenant is one customer company sharing the platform. Lazy loading (loading only when needed) must still respect permissions. Tool search should return only tools the user is allowed to use. A server started on first use should get that user's credentials at that moment. Track first-use delay and loader failures as metrics. A slow or failing loader looks to users like a turn that has stalled.

Step by step
  1. The system prompt lists deferred tools by name and one-line description only.
  2. The model picks a deferred tool for the first time, usually through a tool search call.
  3. The stub starts its server, fetches the full schema and adds it after the cached prefix.
  4. The arguments are checked against the real schema, and a mismatch returns an error that includes the schema.
  5. The stub keeps the real tool, so later calls skip the loading step.
Code sketch
class LazyTool(Tool):
    def __init__(self, name, desc, loader):
        self.name, self.desc = name, desc
        self._load, self._real = loader, None
    def real(self):
        if self._real is None:
            self._real = self._load()
            ctx.add_schema(self._real.spec)
        return self._real
    def run(self, args):
        real = self.real()
        if not real.spec.valid(args):
            return ToolResult.retry(real.spec)
        return real.run(args)

Be carefulLoading a schema during a session changes the prompt. So add new schemas after the cached prefix (the start of the request that the prompt cache saves). Do not reorder the tool list.

2.16

Dynamic Proxy

Build tool stand-ins while the program runs, from JSON schemas, and send every call through one shared handler.

tools/listreadgeneratecallforwardtools/callMCP serverremote toolsTool schemasname + inputSchemaProxy factorymake_stub()Modeltool_useGeneratedstubssearch(), fetch()invoke()one handler
Fig. 2.16Stubs are built from schemas, and every stub sends its call to one handler
Classic meaning

A proxy (a stand-in object) is created while the program runs for a given interface. Every method call on it goes to one shared handler.

In the agent system

MCP is a standard way to connect tools to an AI model. When an MCP server connects, the harness asks it for tools/list (its list of tools). The server answers with each tool's name, description and JSON input schema (a description of the allowed arguments). But the harness has no Python class written for any of these tools. So a proxy factory reads each schema and builds a stub on the spot. A stub is a small stand-in function with the right name, help text and argument checks. Every stub passes its call to one shared invoke handler. That handler packs the arguments into a tools/call request and sends it to the server over the connection. It then turns the reply into a tool_result block (the message that gives the model the tool's answer). So connecting a new server adds new tools without anyone writing or deploying code. The tool list can also change later. When the server sends notifications/tools/list_changed, the harness fetches tools/list again and rebuilds the stubs.

In a large company

Large companies rarely let a newly connected MCP server show its tools straight to the model. Instead, the stubs go through a tool registry (a central list of approved tools). The registry pins (locks in) each tool's name, schema and a hash of its description. A hash is a short fingerprint that changes when the text changes. If a description changes, the tool goes back for review. The one shared handler is the natural single checkpoint for policy. There it attaches the tenant (one customer company sharing the platform) and the user's identity. It asks the policy engine (OPA or Cedar, tools that decide what is allowed) whether the call may run. It adds credentials from a secrets manager, out of the model's sight. It also records an OpenTelemetry span (a timed trace record) for each call. The main risk is tool poisoning, or a remote server quietly changing its tool descriptions later (a rug-pull). The registry pin and the allowlist guard against both.

Step by step
  1. The harness connects to an MCP server and asks for tools/list.
  2. For each schema, a factory builds a stub with the tool's name and argument checks.
  3. The harness registers the stubs and offers their schemas to the model.
  4. When the model calls one, the stub gives the name and arguments to the shared handler. The handler sends tools/call and returns the result.
Code sketch
def invoke(client, schema, args):  # handler
    validate(args, schema['inputSchema'])
    return client.call(schema['name'], args)

def make_stub(client, s):
    stub = lambda **a: invoke(client, s, a)
    stub.__name__ = s['name']
    stub.__doc__ = s['description']
    return stub

for s in client.list_tools():
    tools[s['name']] = make_stub(client, s)

Be carefulGenerated stubs trust whatever the server sends. A hostile server can hide instructions to the model inside tool names or descriptions. So check names and descriptions against an allowlist (a list of approved items) before you show them to the model.

2.17

Decorator

Wrap a tool in layers such as logging, retries, rate limits and telemetry. Each layer adds one job behind the same interface.

web_searchthe real toolRate limitwait for a slotRetrybackoff on 5xxLoggingwrite to transcriptTelemetryspan + costSame interfacename, schema, run()Stack per toolchosen in configcall passes inward
Fig. 2.17Each wrapper adds one job and passes the same call inward
Classic meaning

You add extra jobs to an object while the program runs. You wrap it in other objects that look the same from outside and pass each call on to it.

In the agent system

The harness keeps each tool as a plain object with a run(args) method. When it builds the tool set, it wraps each tool in layers. A Telemetry wrapper records a span (a timed trace record) and the call's cost. A Logged wrapper writes the call and its result to the transcript (the record of the session). A Retry wrapper tries again after transient errors (short-lived failures), waiting longer each time. This waiting is called backoff. It also follows any Retry-After hint from the server, which says how long to wait. A RateLimit wrapper waits for a free slot before calling a paid API. Every wrapper shows the same name and schema as the real tool. So neither the model nor the executor (the part that runs tools) can tell how many layers there are. Configuration chooses the layers for each tool. A local file read might get only logging. Web search gets all four. A tool with side effects, such as create_ticket, gets no Retry. The exception is when it sends an idempotency key. That is a unique ID that lets the server spot a repeat and do the work only once.

In a large company

In a large company the platform team usually owns the stack of layers and applies it the same way to every tool. Every tool call gets an OpenTelemetry span (a standard trace record) with the GenAI semantic convention attributes (standard labels for AI calls). Each call also gets an audit record, a rate limit per tenant (one customer company sharing the platform) and a cost tag for chargeback (billing each team for its use). Tool authors supply only the bare tool. The platform's configuration decides which layers apply. This stops anyone from skipping the audit and policy layers. The key rules are these. Audit and policy wrappers sit outside the retry and caching layers. They cannot be removed for any single tool. And retries never wrap non-idempotent actions (actions that are not safe to repeat) unless the call carries an idempotency key.

Step by step
  1. The registry loads the bare tool.
  2. The builder wraps it in the configured layers, starting with the innermost.
  3. A call enters the outermost wrapper, which does its job and passes the call inward.
  4. The result travels back out through each layer. Each layer can record it, change it or retry.
Code sketch
class Retry(Tool):
    def __init__(self, inner, n=3):
        self.inner, self.n = inner, n
        self.name = inner.name
    def run(self, args):
        for i in range(self.n):
            try: return self.inner.run(args)
            except Transient as e:
                sleep(e.retry_after or 2 ** i)
        raise ToolFailed(self.name)

t = Telemetry(Logged(Retry(RateLimit(web))))

Be carefulOrder matters. A retry outside the rate limiter waits for a new slot on each attempt. A retry inside it can burst past the limit. So set the order of layers in one place.

2.18

Delegation

Direct fit

A tool hands the real work to a helper object or a subagent, then returns that answer as its own result.

research(q)spawn(brief, tools)own context window and toolssearch x 12pagesread, reasonreporttool_result(report)Modelresearch toolSubagentWeb search
Fig. 2.18The caller sees one call and one answer. The delegate does the work.
Classic meaning

An object offers a behaviour but passes each request to a second object that does the work. You combine objects instead of inheriting behaviour from a parent class.

In the agent system

A research tool that the model sees may contain no search code at all. It holds a link to a delegate (the helper that does the work) and passes the request on. The heavier version hands the work to a subagent (a second agent working for the first). The tool starts a fresh agent with its own context window (the text the model can see at one time). The subagent gets a smaller set of tools and a written brief. The tool waits for the subagent's final report. It then returns that report as the tool_result (the message that gives the model the tool's answer). The main agent's context sees one call and one answer. It does not see the dozens of tool calls the subagent made along the way. You can swap the delegate, for example for a cheaper model or a different search service. Nothing changes for the caller.

In a large company

Large companies use delegation to send work to specialist subagents. They also use it to send work to a cheaper model behind an LLM gateway (a central entry point for all model calls). The delegate must run under the caller's identity. Its tools and the data it can reach must be a subset of the caller's, never wider. Each delegation should carry a token and cost budget tied to the tenant's chargeback account. (A tenant is one customer company sharing the platform. Chargeback means billing that company for its use.) Each delegation should also pass on the trace context, so the subagent's spans (timed trace records) nest under the parent call in OpenTelemetry. Audit trails must record the delegate's own tool calls, because the parent's transcript sees only the summary.

Step by step
  1. The model calls a tool such as research(question).
  2. The tool writes a brief and gives it to its delegate, here a subagent with its own context.
  3. The delegate does the work with its own tools and returns a short report.
  4. The tool returns that report as its tool_result, as if it had done the work itself.
Code sketch
class ResearchTool(Tool):
    name = 'research'
    def __init__(self, spawn):
        self.spawn = spawn      # the delegate
    def run(self, args):
        sub = self.spawn('researcher',
                tools=['search', 'read'])
        report = sub.run(brief(args['q']))
        return report.summary

Be carefulDelegation hides cost. A one-line tool call can start a subagent that spends thousands of tokens (the units models read and write). So pass a token budget down and report usage back up.

2.19

Servant

Put shared chores, like checking paths or shortening results, in one helper that many tools call, not a copy per tool.

pathresolveraw texttrimmedPathGuardinside root?Workspace/reporead_filewrite_filegrepResultTruncatorfit to 8k tokenstool_resulthead + tail + note
Fig. 2.19Many tools hand their data to the same two helpers
Classic meaning

A helper class gives a common behaviour to a group of classes. They pass their data to it instead of each building the behaviour themselves.

In the agent system

Many tools need the same chores done to what goes in and what comes out. A PathGuard servant takes a requested file path and works out the real full path. It follows symlinks (shortcuts that point to other files). It rejects anything outside the workspace root (the top folder the agent may use). read_file, write_file and grep all call it. A ResultTruncator servant cuts long tool output down to a token budget (a size limit in tokens, the units models read). It keeps the start and the end. It adds a note telling the model how to ask for the next part. The tools hand their data to the servant. They do not inherit from a shared base class. So the rule lives in one place, and a security fix reaches every tool at once.

In a large company

On a company platform, the shared servants are where security and data rules live. These include keeping paths inside the workspace and shortening output. They also include PII redaction (removing personal data such as names and emails). And they include DLP scanning (data loss prevention: checking for data that must not leak). These checks run on tool results before the results enter the context window (the text the model can see). Keeping the servants in one reviewed library means a fix reaches every tool at once. It also gives auditors one place to check the control. A path check alone still has a gap called a time-of-check to time-of-use race. A symlink can change between the check and the moment the file is opened. So production sandboxes also enforce the limit in the filesystem itself. They use read-only mounts or openat-style resolution (opening each part of the path step by step from a trusted folder).

Step by step
  1. A tool gets arguments from the model, such as a file path.
  2. It passes the path to the PathGuard servant, which cleans up the path and checks it.
  3. The tool does its own work with the safe path.
  4. It passes its raw output to the ResultTruncator servant before returning the tool_result.
Code sketch
class PathGuard:                 # servant
    def __init__(self, root):
        self.root = root.resolve()
    def safe(self, p):
        r = (self.root / p).resolve()
        if not r.is_relative_to(self.root):
            raise Denied(p)
        return r

def read_file(args, guard, trunc):
    p = guard.safe(args['path'])
    return trunc.fit(p.read_text(), 8000)

Be carefulSometimes a servant grows branches for single tools, such as if tool == 'grep'. Then it has become a god object (one object that does too much). Keep it to the shared rule and let each tool handle its own quirks.

2.20

Null Object

Direct fit

When a feature is switched off, plug in a helper that does nothing, so the agent loop never checks for None.

callwired at startyesno[ ] no branchAgent loopmem.recall(q)Memoryinterfacerecall, rememberenabled?VectorMemoryreal recallNullMemoryreturns []
Fig. 2.20The loop calls the same interface, and a do-nothing version fills the gap
Classic meaning

Instead of an empty reference (None or null), you use an object with the expected methods that do nothing. Callers then need no checks for the empty case.

In the agent system

The agent loop calls many optional helpers: a tracer (which records what happens), a memory store, a UI renderer (which draws the screen) and an approval prompter. Sometimes the agent runs headless, with no screen, in a scheduled task. There is no chat UI then. So the harness plugs in a NullRenderer whose on_token and on_tool_card methods do nothing. When long-term memory is off, a NullMemory returns an empty list on recall. It accepts writes but stores nothing. When there is no telemetry exporter (the part that sends trace data out), a NullTracer hands out empty spans (empty trace records). The loop calls the same methods every time. There are no 'if renderer is not None' checks spread through the code.

In a large company

Company harnesses use null objects for helpers that are truly optional, such as a UI renderer in headless batch runs. The OpenTelemetry API (a standard tracing library) itself returns a do-nothing tracer when no SDK is set up. In a regulated platform, one rule matters most. Compliance helpers are never optional. These are the audit logger, the policy engine client and the DLP filter (data loss prevention: it blocks data that must not leak). They must fail closed, which means blocking the action when they do not work. They must never be swapped for a do-nothing version. Startup checks should refuse to run in production if any of them turns out to be a null object.

Step by step
  1. At startup the harness reads the settings for each optional helper.
  2. Where one is turned off or missing, it plugs in the matching do-nothing version.
  3. The loop always calls the tracer, memory and renderer methods, with no checks.
  4. The do-nothing versions return empty, neutral values and record nothing.
Code sketch
class NullMemory(Memory):
    def recall(self, query): return []
    def remember(self, fact): pass

class NullTracer(Tracer):
    def span(self, name): return nullcontext()

mem = VectorMem(url) if url else NullMemory()
tr = OTelTracer() if otel else NullTracer()

Be carefulNever give the model a tool that silently does nothing. A write_file that quietly does nothing lets the agent believe it saved its work. If a tool is missing, leave it out of the tool list instead.

2.21

Special Case

Direct fit

For expected odd outcomes, like no matches or file not found, return a special result object that knows how to behave.

callyesnosame APIrendernext turnModelgrep('retry_cfg')grep toolany hits?Hitslist of matchesNoMatchespattern + countExecutorresult.to_block()tool_result'try a broader..'
Fig. 2.21An empty search returns a real object that can explain itself
Classic meaning

A subclass gives special behaviour for one particular case, such as an unknown customer. Callers then need no extra checks for that case.

In the agent system

Search and lookup tools hit dead ends you can predict. grep finds nothing, a path does not exist, or a ticket ID is unknown. The tool does not return None or raise an error (throw an exception). Instead it returns a special case object such as NoMatches or MissingFile. That object has the same ToolResult interface as any other result. Its to_block() method makes a useful tool_result (the message that gives the model the tool's answer). For example: 'No matches for retry_cfg in 412 files. Try a broader pattern.' It keeps is_error false, because an empty search is a valid answer. It costs only a few tokens. The executor, the transcript and the renderer treat it like any other result.

In a large company

On a company platform, special case results also cover access outcomes. Take a search over a document store that is filtered by what the user is allowed to see. It may return a NoAccessibleResults object. This tells the model that nothing was visible to this user. It does not reveal that restricted matches exist. Keeping is_error false for valid empty answers keeps error-rate SLO dashboards and alerts honest. (An SLO is a reliability target.) Real failures then stay easy to tell apart. Special case messages should be fixed templates, not copies of the raw input. That way they cannot leak data across tenants (customer companies sharing the platform). Nor can they carry injected text into the context.

Step by step
  1. A lookup tool runs and finds nothing, or hits another expected odd case.
  2. Instead of None or an exception, it returns a special case result object.
  3. The executor calls to_block() on it, just as it would for real results.
  4. The model gets a short message with a suggested next step.
Code sketch
class NoMatches(SearchResult):
    def __init__(self, pat, n):
        self.pat, self.n = pat, n
    def to_block(self):
        return text(
            f'No matches for {self.pat} in '
            f'{self.n} files. Try a broader '
            'pattern or check the spelling.')

hits = index.search(q)
return Hits(hits) if hits else NoMatches(q, n)

Be carefulUse special cases only for expected situations. A disk error or a permission failure is a real error. Show it as one. Do not dress it up as 'no results'.

2.22

Extension Objects

Direct fit

Let harness parts ask a tool, while running, for optional extras such as streaming, undo or preview, without enlarging every tool's interface.

get('preview')foundalso hasget('preview')NoneApproval cardneeds a previewedit_fileget_extension()DiffPreviewPreviewableEditUndoUndoableweb_fetchno extensions
Fig. 2.22The same question gets an extension from one tool and None from another
Classic meaning

You add new interfaces to an object without changing its class. Clients ask for named extension objects that provide the extra behaviour.

In the agent system

Every tool has a small core interface: name, schema and run. But some tools can do more. A shell tool can stream its output line by line. An edit tool can show a diff preview (the changes, before it applies them). It can also keep an undo record afterwards. A browser tool can return a screenshot. The harness does not add stream(), preview() and undo() to every tool. Instead it calls tool.get_extension('preview'). It gets back either an object that offers Previewable or None. The approval card shows a diff when a preview exists. If none exists, it shows the raw arguments. The streaming renderer listens only to tools that return a Streaming extension.

In a large company

Company harnesses use optional extensions for features that only some tools support. One is a dry run or diff preview for human-in-the-loop approvals (where a person approves each action). Another is an undo record for change management. A third is a cost estimate for budget checks. The MCP protocol (a standard way to connect tools to an AI model) uses a similar idea for the whole connection. Client and server agree on capabilities during initialize, the first exchange. Sometimes an approval depends on a preview. Then the platform should save a hash (a short fingerprint) of the previewed diff with the approval in the audit trail. It should then check that the change it actually made matches that hash.

Step by step
  1. Each tool registers the extension objects it supports, under capability names.
  2. A harness part asks the tool for the extension it needs.
  3. If one comes back, that part uses its richer interface.
  4. If not, that part falls back to the plain run() path.
Code sketch
class EditTool(Tool):
    def __init__(self):
        self.ext = {'preview': DiffPreview(),
                    'undo': EditUndo()}
    def get_extension(self, name):
        return self.ext.get(name)

pv = tool.get_extension('preview')
if pv: ui.show_diff(pv.render(args))
else:  ui.show_args(args)

Be carefulIf every tool ends up with the same extension, move it into the core interface. Extension lookups are for features that are truly optional.

2.23

Marker Interface

Adapted fit

Tag tools as read-only, destructive or approval-required with empty markers. The permission gate checks them before running anything.

callread tagsdestructiveapprovedread-onlyModeltool_usePermissiongateisinstance(tag)Ask userDestructiveExecuteReadOnly: nowdelete_file+ Destructive
Fig. 2.23The gate routes calls by their tags, not by what the tools do
Classic meaning

An empty interface (or a flag) with no methods marks a class as having some property. Other code can test for it.

In the agent system

Tool classes inherit from empty marker types such as ReadOnly, Destructive or NeedsApproval. Or they carry matching flags, like readOnlyHint and destructiveHint, in an MCP tool's annotations. (MCP is a standard way to connect tools to an AI model.) The markers add no methods. The permission gate checks them before each call. Read-only tools run at once, even in plan mode. Destructive ones pause and wait for the user's approval. Network tools are blocked in an offline sandbox. The scheduler reads the same tags. It runs read-only calls in parallel and runs anything that writes one at a time.

In a large company

Company platforms tag tools with markers such as read-only, destructive, external network or handles PII (personal data such as names and emails). The policy engine (OPA/Rego or Cedar, tools that decide what is allowed) combines those tags with the user's role and the tenant's settings. (A tenant is one customer company sharing the platform.) It then decides to run, ask or deny. Tags on third-party MCP tools, including readOnlyHint and destructiveHint, count only as untrusted hints. So the platform keeps its own reviewed labels in the tool registry (the central list of approved tools). Tools with no label should need approval by default. Every decision goes into the audit trail, along with the tags that led to it.

Step by step
  1. Each tool declares its markers when it is defined.
  2. The model sends a tool_use block (its request to run a tool) naming some tool.
  3. The permission gate checks the tool's markers against the session's mode.
  4. It runs the call, asks the user, or refuses, without knowing what the tool actually does.
Code sketch
class ReadOnly: pass       # markers carry
class Destructive: pass    # no methods

class ListDir(Tool, ReadOnly): ...
class DeleteFile(Tool, Destructive): ...

def gate(tool, mode):
    if isinstance(tool, Destructive):
        return 'ask'
    if isinstance(tool, ReadOnly):
        return 'run'
    return 'run' if mode == 'auto' else 'ask'

Be carefulA marker is only a claim made by the tool's author. Do not trust the hints on third-party MCP tools. Keep your own policy list for them.

2.24

Type Object

Direct fit

Define each tool as a data record (name, schema, handler) instead of a new class, so adding a tool adds no code.

loadloadits typehandlerhandlertools.yamlor tools/listToolDefsearch_docsToolDefcreate_ticketToolCallargs + tdefHTTP handlershared code
Fig. 2.24New tools arrive as records that point at a few shared handlers
Classic meaning

You describe a family of types as instances of one single class. New types can then be added as data while the program runs, not as new subclasses.

In the agent system

An MCP tool definition is a type object. (MCP is a standard way to connect tools to an AI model.) It is a JSON record with name, description, inputSchema (the allowed arguments) and annotations. Every server's tools are instances of one ToolDef type, not separate classes. The harness has one general ToolCall class. Each ToolCall points at its ToolDef for checking and display, the way a game figure points at its card. Teams add tools by editing a JSON or YAML file (simple text formats for data). The file links a definition to an existing handler, such as a web endpoint, a shell command template or an MCP call. The harness loads these files at startup. Settings shared by every call of a type, such as the default timeout and cost class, live on the definition.

In a large company

Company platforms often run a central tool catalog. Each tool is a versioned definition record. It holds the owner, data classification, allowed tenants (customer companies), timeout and cost class. It points at one of a small set of reviewed handler types. New tools then go through change management as data. Someone opens a pull request (a proposed change) to the catalog. Then an eval run (a set of automatic tests) checks the agents that will see the tool. Then someone approves it before rollout. The handler table is the security boundary. So it stays small, and no definition can point at any URL or command without review.

Step by step
  1. The harness loads definitions from JSON, YAML or a tools/list reply into ToolDef instances.
  2. Each ToolDef names a general handler and holds the schema and shared settings.
  3. When the model calls a tool, the harness creates a ToolCall that points to its ToolDef.
  4. Checks, timeouts and display read from the ToolDef, not from a subclass.
Code sketch
class ToolDef:             # the type object
    def __init__(self, d):
        self.name = d['name']
        self.schema = d['inputSchema']
        self.timeout = d.get('timeout', 30)
        self.handler = HANDLERS[d['handler']]

class ToolCall:            # an instance
    def __init__(self, tdef, args):
        validate(args, tdef.schema)
        self.tdef, self.args = tdef, args

Be carefulTools defined as data are only as safe as the handlers they can point at. Keep the handler table small and reviewed. Do not let settings files reach any code they like.

2.25

Bytecode

The model writes a short program in a limited action language. A small harness VM checks it, then runs it step by step.

emitcheckrejectvalidnext opCLICK e12resulttool_resultModelwrites programProgramOPEN, FIND, CLICKVerifieropcodes, limitsVMpc + step budgetBrowserop handlersTracestep results
Fig. 2.25The model writes data, and only the VM's fixed opcodes ever touch the world
Classic meaning

You write behaviour as instructions for a small virtual machine (a program that runs other programs). Behaviour can then be stored as data and run safely within fixed limits.

In the agent system

The harness does not give the model a general shell. Instead it defines a tiny action language for browsing: OPEN url, FIND selector, CLICK ref, TYPE ref text, EXTRACT field. Each of these commands is an opcode. The model writes a short program as a JSON list of instructions inside one tool_use (its request to run a tool). A harness VM (a small virtual machine) checks the program before it runs. It allows only known opcodes and correct argument types. It allows at most 50 steps and only approved domains (websites). Then it runs the program with a program counter (which tracks the current step) and a step budget. The instruction set has no opcode for running any code or touching files. The VM also checks the website again at every page change. So a program can do only what those opcodes allow, and only on approved sites. That still includes submitting forms. So CLICK and TYPE on pages with side effects need an approval step or a read-only session. The program is fixed before any page is read. So text hidden in a page (a prompt injection) cannot add steps. And putting ten steps into one program saves nine round trips to the model.

In a large company

Company platforms use limited action languages for browser and RPA automation (robotic process automation: software that clicks through apps like a person). Each opcode maps to an approved, audited capability. The checker runs as policy as code (rules written as code) before anything runs. The list of approved domains must be enforced while the program runs, not only on the program text. This is done through an egress proxy (a gateway for all outgoing traffic) or a browser network policy. Login credentials for the session should cover only this task. Each program and its trace go to the audit log. Opcodes with side effects on systems of record (the official business systems) need human approval under the platform's change rules.

Step by step
  1. The tool schema describes the opcodes and their arguments to the model.
  2. The model returns a program: an ordered list of instructions.
  3. The checker rejects unknown opcodes, bad arguments, unapproved websites, or programs over the step limit.
  4. The VM runs one instruction at a time, stops at the first failure and returns a trace.
Code sketch
OPS = {'OPEN': op_open, 'CLICK': op_click,
       'TYPE': op_type, 'EXTRACT': op_extract}

def run(program, limit=50):
    verify(program, OPS, limit)   # first
    trace = []
    for pc, ins in enumerate(program):
        out = OPS[ins['op']](*ins['args'])
        trace.append((pc, ins['op'], out))
        if out.failed: break
    return trace

Be carefulEvery opcode you add widens what a model, or a prompt injection, can make the VM do. An EVAL or SHELL opcode turns the design back into running any code at all.

2.26

Abstract Document

Keep a tool call's extra details in one flexible key-value document. Small typed views read only what each part needs.

under 'input'mergeapprover()tool_use inputMCPannotationsHarnesssettingsCall documentdict of propsExecutorHasTimeout viewPermissiongateHasApproval viewTool cardHasDisplay view
Fig. 2.26One document collects every property, and each reader sees it through its own view
Classic meaning

You store an object's properties in a map (a list of key-value pairs). Small typed interfaces, called traits, read them. New properties then need no change to the schema.

In the agent system

A tool call collects metadata (details about the call) from many places. Some comes from the model's input, and some from MCP annotations. Some comes from harness settings, such as timeout and cost class. Some comes from UI hints, such as an icon and display title. Audit fields such as approved_by and duration_ms are added along the way. The harness does not use one class with dozens of optional attributes. Instead it keeps them all in a single dict-backed document (built on a key-value dictionary) with nested child documents. The model's arguments always sit in their own input child. So they cannot shadow (hide or replace) a harness key. Small typed views, such as HasTimeout, HasApproval and HasDisplay, give each part simple ways to read the same dict. So the executor, the permission gate and the tool card renderer each read only what they understand. Only the part that owns a trust-bearing key may write it. approved_by is one such key. Unknown keys from a newer MCP server pass through to the transcript untouched.

In a large company

Company platforms keep a call envelope like this because tool calls collect metadata from many owners. Examples are tenant and user identity from the OIDC token (a login token from the company's identity system), policy decisions and data classification labels. Others are cost tracking and audit fields. Each namespace (group of keys) in the document should have one writer. Only the harness should set identity and approval fields, from verified sources. It must never read them from the model's output. Before the document goes into the transcript, PII redaction and retention rules apply. (PII redaction removes personal data such as names and emails.) This matters because keys passed through from third-party servers may carry data the platform has not classified.

Step by step
  1. The harness builds a call document from the definition's metadata. It puts the tool_use input under its own input key.
  2. Parts add properties as the call moves along, each writing only the keys it owns.
  3. Each part reads through its own typed view of the document.
  4. The whole document goes into the transcript, including keys that no view knows about.
Code sketch
class Document(dict):
    def child(self, k):
        return Document(self.get(k, {}))

class HasTimeout(Document):
    def timeout(self):
        return self.get('timeout_s', 30)

class HasApproval(Document):
    def approver(self):
        return self.get('approved_by')

class CallDoc(HasTimeout, HasApproval): pass

Be carefulA bag with no schema lets typos such as 'timout_s' fail without any error. Give each view a table of defaults. Log any key that no view ever reads.

2.27

Property

Give each tool a settings map that falls back to a parent map. Tools inherit defaults and change only what differs.

getmissparent120parentExecutorget('timeout_s')web_fetchapproval: autoRemote servertimeout_s: 120Root defaultstimeout_s: 30web_searchmax_out: 4k
Fig. 2.27A lookup climbs the chain until some level has the key
Classic meaning

Objects keep their properties in a map. When a key is missing, they ask a parent object. This forms a chain of parents, called a prototype chain.

In the agent system

Tools share many settings: timeout, retry count, output token limit, sandbox profile and approval mode. The harness keeps a root map of defaults. Each MCP server gets a child map. For example, a slow remote server sets timeout_s to 120. Each tool gets a map that holds only its own differences. A lookup such as tool.get('timeout_s') checks the tool, then its server, then the root. It returns the first value it finds. Changing a root default updates every tool that has not set its own value. A subagent's tool set can be a child map that overrides approval_mode. But it may only make it stricter. So a child never loosens what its parent requires.

In a large company

Company platforms stack settings in layers. Platform defaults come first, then the tenant (one customer company), then the workspace or project, then the tool. The same fallback lookup finds timeouts, model choice and budgets. Security and compliance keys work differently. Tenant or company policy sets limits that lower levels can tighten but not loosen. A policy engine usually enforces this, not plain fallback. The final value, and the level that supplied it, should show in the admin console. They should also be recorded with each run for audit.

Step by step
  1. The harness creates a root map of default tool settings.
  2. Each MCP server or tool family gets a child map that points to the root.
  3. Each tool gets its own map with only its changes, pointing to its family.
  4. A lookup checks the tool, then each parent in turn, and returns the first value found.
Code sketch
class Props:
    def __init__(self, parent=None, **own):
        self.parent, self.own = parent, own
    def get(self, k):
        if k in self.own: return self.own[k]
        if not self.parent: raise KeyError(k)
        return self.parent.get(k)

root = Props(timeout_s=30, approval='ask')
remote = Props(root, timeout_s=120)
fetch = Props(remote, approval='auto')
fetch.get('timeout_s')    # 120, from remote

Be carefulLong chains make 'why is this timeout 120?' hard to answer. Keep the chain short. Have the tool inspector show which level supplied each value.

2.28

Parameter Object

Bundle a tool's many arguments into one typed request object. Check it once and pass it along whole.

parse onceinvalidreqreqreq + resultModeltool_use inputShellRequestcmd, cwd, timeoutPermissiongatereq.commandSandboxreq.cwd, req.envTranscriptreq + result
Fig. 2.28Loose JSON is parsed once into a typed request that travels whole
Classic meaning

Some parameters always travel together. You replace them with one object that gives the group a name, checks and default values.

In the agent system

A run_command tool can take a command, a working folder, environment variables, a timeout, stdin (input fed to the command), a sandbox profile and an output limit. Passing seven loose arguments through the permission gate, the sandbox and the executor invites mix-ups. So the harness parses the tool_use input once into a typed ShellRequest. This is a dataclass or Pydantic model (a Python class that checks data types), built from the same JSON schema the model sees. Checks and defaults happen in one place. The gate checks req.command and req.cwd. The transcript stores the request whole. Adding a field later changes the request class, not every function along the way.

In a large company

In a company harness, the policy engine checks the typed request, and the audit trail stores it. So it needs a stable, versioned schema shared by the gate, the sandbox and the log pipeline. Some fields the model can set change how secure the call is, such as env, cwd and timeout. These must have limits or an allowlist (a list of approved values). A PATH or LD_PRELOAD value from the model can break out of a sandbox. (These environment variables control which programs and libraries load.) Secrets never appear in the request. The executor adds them from a secrets manager after the gate has approved the call.

Step by step
  1. The model sends tool_use input as a JSON object.
  2. The harness parses it into a typed request, fills in defaults and rejects bad fields.
  3. The gate, sandbox and executor each get the one request object.
  4. The harness logs the same object with the result, so the call can be replayed exactly.
Code sketch
class ShellRequest(BaseModel):
    model_config = ConfigDict(
        frozen=True, extra='forbid')
    command: str
    cwd: Path = Path('.')
    timeout_s: int = Field(60, le=600)
    env: dict[str, str] = {}
    max_output: int = 30_000

req = ShellRequest.model_validate(block.input)
gate.check(req)    # command, cwd, env keys
result = sandbox.run(req)

Be carefulDo not let one request class turn into a grab bag shared by unrelated tools. One request type per tool keeps the schema the model sees honest.

2.29

Execute Around

Run every tool inside a function that sets up the sandbox first and always cleans it up afterwards, even on failure.

with sandbox(profile)start, mount, limityield boxexec('pytest')stdout / raisefinally: runs on return or errorlogs, kill, cleanupresultExecutorsandbox()ContainerTool body
Fig. 2.29Setup and cleanup belong to the wrapper, so no tool can skip them
Classic meaning

A method does the setup and the cleanup around a block of code the caller passes in. So callers cannot forget either half.

In the agent system

Running a code tool needs setup. It needs a scratch folder and a container or a slice of a VM (an isolated mini computer). It needs the workspace files mounted (made available inside), a network policy, and limits on CPU and clock time. Afterwards, the container must be stopped, temporary files deleted and logs collected. The harness offers one context manager, sandbox(profile), that does all of this. (A context manager is a Python block that runs setup at the start and cleanup at the end.) It runs the tool's body in the middle. Tool authors write only the body. A crash, a timeout or the user pressing stop still reaches the finally block (the cleanup code that always runs). So leftover containers do not pile up between turns.

In a large company

Company platforms wrap every sandboxed action this way. First they get a short-lived workload identity credential (a temporary login for the job). They open an audit span (a timed audit record). They start an isolated container or microVM (a very small virtual machine) with an egress policy (rules for outgoing network traffic). Afterwards they revoke the credential, collect logs and destroy the sandbox. But if the harness process itself crashes, its finally block never runs. So production systems pair the wrapper with an outside reaper (a process that finds and removes leftovers). Or they use a durable execution engine such as Temporal, which keeps running cleanup steps even after crashes. Platforms track sandbox lifetimes and cleanup failures as metrics. Leftover sandboxes cost money. They can also break data-residency rules (rules about where data may be stored).

Step by step
  1. The executor enters sandbox(profile) and passes control to the tool body.
  2. The wrapper starts the container, mounts files and applies limits.
  3. The body runs its command inside the sandbox and either returns or raises an error.
  4. Either way, the finally block collects logs, stops the container and deletes temporary files.
Code sketch
@contextmanager
def sandbox(profile):
    box = containers.start(profile)
    try:
        box.mount(workspace, ro=profile.ro)
        yield box
    finally:
        box.collect_logs(transcript)
        box.kill(); box.cleanup()

with sandbox(DEFAULT) as box:
    out = box.exec(req.command, timeout=60)

Be carefulCleanup cannot depend on the body behaving well. Enforce time limits from a watchdog (a separate timer process) outside the sandbox. A body that hangs never hands control back to the finally block.

2.30

Fluent Interface

Direct fit

Set up an agent with chained method calls that read like a sentence. Each call returns the builder for the next.

chainreturn selfbuild()yesnoConfig codeAgent('reviewer')AgentBuildercfg dict.model() .tools().max_turns(20)valid?AgentConfigimmutableConfigErrorshell, no sandbox
Fig. 2.30Every call returns the same builder until build() checks and freezes it
Classic meaning

An API built on method chaining. Each call returns an object for the next call, so the code reads like a small sentence in the problem's own language.

In the agent system

Agent SDKs (software kits for building agents) often offer a fluent setup API: Agent('reviewer').model(...).system(...).tools(read, grep).max_turns(20).on_tool_use(audit).build(). Each method records one setting and returns the builder. So a whole agent definition fits in one readable expression. It is easy to review in a pull request (a proposed code change). The final build() checks that the settings fit together. For example, a shell tool must have a sandbox, and the token budget must fit the model's context window (the text the model can see at one time). Then build() returns an AgentConfig that cannot be changed. Subagent definitions start from a shared base chain and add a few calls.

In a large company

Company teams use fluent builders to define agents in code. But the code usually turns into a declarative, versioned config file (one that states the settings, not the steps). That file goes through review, eval gates (automatic tests that must pass) and staged rollout, like any other change. The build step is a good place to enforce platform policy. For example, it can reject a shell tool with no sandbox profile. It can reject a model that is not approved for the tenant's data residency region (where the customer's data must stay). It can reject a token budget above the tenant's limit. Only the built, unchangeable config is deployed. So what was reviewed is exactly what runs.

Step by step
  1. Start a builder with the agent's name.
  2. Chain calls for model, system prompt, tools, limits and hooks. Each returns the builder.
  3. Call build() to check the whole setup at once.
  4. Hand the unchangeable config to the harness to start runs.
Code sketch
class Agent:
    def model(self, m):
        self.cfg['model'] = m
        return self          # enables chaining

cfg = (Agent('reviewer')
       .model('large')
       .system(load('review.md'))
       .tools(read_file, grep, git_diff)
       .max_turns(20)
       .build())           # validates once

Be carefulChains hide the in-between state. A setting given twice, or in a conflicting order, can silently win. Make build() reject contradictions instead of keeping the last call.

03
Chapter 3 · Talking to the AI model

The Model Call Layer

The model lives on someone else's servers. It can be busy, slow, or briefly down, it limits how much you can send, it charges for every word, and its replies are not always perfectly formatted. These patterns keep requests flowing anyway: picking a provider, trying again, switching to a backup, saving repeat work, and counting the cost.

3.01

Gateway

Send every model call through one client that holds the keys, picks a provider and switches over when one is down.

complete()complete()routeon outagekeysAgent loopSubagentModel gatewayauth, route, meterProvider Aprimary routeProvider Bfailover routeSecret storeAPI keys
Fig. 3.01Callers see one client. Routing, keys and failover live behind it.
Classic meaning

A gateway is one object that wraps access to an outside system. Callers use its simple interface and never deal with the system directly.

In the agent system

The agent loop (the code that runs the model turn after turn) never imports a provider SDK (the provider's own code library). It calls gateway.complete(request). The gateway adds API keys (secret passwords for the provider) from the secret store. It turns the request into the provider's wire format (the exact shape that provider expects). It then routes the request by model alias, region or tenant policy. A model alias is a short name, like 'large', that points to real models. A tenant is one customer company sharing the platform. The gateway also records usage and latency (how long each call takes) for every call in one place. Sometimes a provider returns errors or is marked unhealthy. Then the gateway sends the same request to the next configured route. It returns a response in one standard shape. So subagents (helper agents started by the main agent) and scheduled tasks all get the same behaviour.

In a large company

In an enterprise platform, this is the LLM gateway (the central entry point for all model calls). Every agent, subagent and batch job must use it. It often runs as a central service, such as LiteLLM, Envoy AI Gateway or a cloud API gateway. Traffic to model providers is blocked everywhere else. The gateway enforces several rules in one place. It uses workload identity (each service proves who it is) instead of fixed keys. It keeps a list of allowed providers for each tenant. It enforces data residency (keeping data in an approved country or region). It tracks token budgets and chargeback tags for each tenant (labels for billing costs back to each team). It also keeps an audit record of which model served each call. The main risk is that failover (switching to a backup route) crosses a residency or contract boundary. So policy must decide which routes are allowed, not provider health alone.

Step by step
  1. An agent or subagent calls gateway.complete with messages, tools and a model alias.
  2. The gateway looks up the alias, gets a list of provider routes and adds the keys.
  3. It writes the request in that provider's format and sends it.
  4. If the provider is down, the gateway marks it unhealthy and tries the next route.
  5. It returns one response in a standard shape, with usage attached.
Code sketch
class ModelGateway:
    def complete(self, req, tenant):
        routes = self.policy.allowed(
            tenant, self.routes[req.model])
        for p in routes:  # residency, vendor
            if not p.healthy():
                continue
            try:
                r = p.send(req, self.keys)
            except ProviderDown:
                p.mark_down(); continue
            self.meter.record(tenant, r.usage)
            return p.decode(r)
        raise NoProviderAvailable(req.model)

Be carefulA gateway might quietly switch to a weaker model. That can change how the agent behaves in the middle of a task. So log every reroute and record which model actually answered.

3.02

Ambassador

Run a sidecar, a helper beside the harness, that owns the model connection: keys, TLS encryption, retries, timeouts and telemetry.

SAME PODplain HTTPTLS + key, retryAPI keyemitHarnessagent loopAmbassadorsidecar :8787Model APIremote providerKey vaultMetricstraces, tokens
Fig. 3.02The agent speaks plain HTTP locally. The sidecar deals with the outside world.
Classic meaning

An ambassador is a helper service that runs next to a client. It sends network requests for the client and adds features such as retries, monitoring and security.

In the agent system

The harness does not call the provider directly. It sends plain HTTP requests to a sidecar on localhost (the same machine). The sidecar runs in the same pod or container group (a set of containers deployed together). It holds the API key and handles TLS (the encryption on the connection to the provider). It adds retries with backoff (waiting longer before each new try) and enforces timeouts. It also sends out metrics and traces (records of each call's path and timing) for every model call. The sidecar does not care what language the caller uses. So a Python agent worker and a TypeScript UI server share one connection policy. To rotate keys or change egress rules (rules about which outside addresses traffic may reach), you redeploy the sidecar, not the agent code.

In a large company

In an enterprise platform, the ambassador is usually a service-mesh or egress sidecar. A service mesh is a network layer that manages traffic between services. Examples are Envoy, a mesh proxy, or a small LLM-proxy container. It gets short-lived credentials through workload identity (each service proves who it is) or Vault (a secrets store). It never shows them to the agent process. The sidecar applies the same rules for every language. These include mTLS (both sides prove who they are with certificates) and egress allowlists (approved outside addresses). They also include timeouts and OpenTelemetry spans (standard trace records) with GenAI attributes (fields that describe model calls). The sidecar also removes PII (personal data such as names and emails) from outgoing prompts. Platform teams change its settings through change management (a formal review and approval process). So the docs must say that the sidecar owns retry and timeout policy, and that these are turned off in the SDK.

Step by step
  1. The harness sends a model request to the sidecar on localhost.
  2. The sidecar checks who the caller is. It adds the key, a timeout and a trace id (a tag that follows the request).
  3. It forwards the request to the provider over TLS. It retries only failures that happen before any response bytes arrive.
  4. It streams the response back and passes mid-stream errors through to the harness. It also records latency and token metrics.
Code sketch
# harness: no keys, no retry logic
client = ModelClient(
    base_url='http://localhost:8787')
resp = client.messages.create(**req)

# ambassador sidecar, same pod
@app.post('/v1/messages')
async def relay(req):
    req.headers['x-api-key'] = vault.key()
    return await retry(upstream.post, req,
                       timeout=60)

Be carefulIf both the harness and the ambassador retry, one failure turns into many calls. Decide which side owns retries, and turn them off on the other side.

3.03

Bridge

Direct fit

Keep the family of agent types apart from the family of model backends, so each side can grow without a class for every pair.

AGENT TYPESBACKENDShas-a: bridgeextendsimplementsAgentabstractionChatAgentCodingAgentModelBackendimplementorHostedBackendLocalBackend
Fig. 3.03Two families grow on their own, joined by one reference.
Classic meaning

A bridge separates an abstraction (what something does) from its implementation (how it does it). One reference joins the two families, so each can change on its own.

In the agent system

The agent side has its own family of classes. ChatAgent, CodingAgent and ResearchAgent differ in system prompt, tools and loop policy. The backend side has another family. A backend is the code that actually talks to a model. There is a hosted API backend, a cloud-platform backend and a local inference backend (running the model on your own machines). Each backend knows its own request format, streaming quirks and login method. Each agent holds a reference to a ModelBackend. It calls only that backend's narrow interface: generate, stream and count_tokens. Three agents and three backends need six classes, not nine combinations. A new backend works with every agent from day one.

In a large company

In an enterprise platform, the backend side often covers three options. One is a direct provider API. Another is the same models through a cloud platform such as Bedrock, Vertex AI or Azure. The third is a self-hosted model for sensitive work. Policy picks the backend for each tenant (one customer company sharing the platform). The implementor interface (the backend's side of the bridge) stays narrow. So the platform can move a regulated tenant to an in-region or private backend without touching agent classes. Add a conformance and eval suite (automated tests of behaviour and quality) and run it against every backend. Streaming, tool calling and token counting differ in small ways between deployments of the same model.

Step by step
  1. Define a narrow ModelBackend interface with generate, stream and count_tokens.
  2. Write one backend class per provider or deployment.
  3. Write agent classes against that interface, and pass in a backend when you create each agent.
  4. Choose the pairing in config. For example, use CodingAgent with a local backend in tests.
Code sketch
class Agent:                 # abstraction
    def __init__(self, backend):
        self.backend = backend

class CodingAgent(Agent):
    def step(self, msgs):
        return self.backend.generate(
            CODE_PROMPT, msgs, TOOLS)

class LocalBackend(ModelBackend): ...
class HostedBackend(ModelBackend): ...

agent = CodingAgent(LocalBackend())

Be carefulKeep the backend interface to what every provider can support. If one provider's special feature leaks into it, the bridge quietly becomes an adapter for just one provider.

3.04

Retry

Re-send a model call that failed for a short-lived reason, waiting longer each time, and give up after a fixed limit.

POST /messages529 overloadedbackoff 1s + jitterPOST /messages (retry 1)429, retry-after: 4wait 4sPOST /messages (retry 2)200 OKmessageAgent loopModel clientModel API
Fig. 3.04Each failure earns a longer, jittered wait before the same request goes again.
Classic meaning

Retry repeats an operation that failed because of a short-lived fault. It usually waits longer each time, until the operation succeeds or a limit is reached.

In the agent system

Model APIs return transient errors (short-lived errors that may pass if you try again). A 429 rate_limit_error means you hit a usage limit. A 529 overloaded_error means the service is busy. There are also occasional 500 or 504 errors, and connections that drop mid-stream. A streaming call can also fail after a 200 (success) status, with an error event inside the stream. The client treats that error the same way as the matching HTTP error. If the response has a retry-after header (the provider's advice on how long to wait), the client follows it. Otherwise it waits using capped exponential backoff plus jitter. That means the wait doubles each time up to a maximum, plus a small random extra. Then it sends the identical request again. This is safe because a model call has no side effects (it changes nothing outside itself). Some errors will never succeed on a resend. Examples are 400 invalid_request_error, 401, 403, or a 429 caused by a used-up spending cap. These fail at once. The retry budget is limited by the number of attempts and by the turn's deadline. So a user in the chat never waits forever.

In a large company

In an enterprise platform, retries live in exactly one layer, usually the LLM gateway (the central entry point for model calls). SDK retries behind it are set to zero. Each tenant (customer company) gets its own retry budget. So one tenant's storm of retries does not use up the shared quota. Every retry is logged as a span event in OpenTelemetry (a standard tracing system), with the status and the wait. So SLO dashboards (charts of reliability targets) can tell provider overload (529) apart from self-caused rate limiting (429). Retrying a model call is safe because it has no side effects. But a turn may have already run tools. That turn must resume from its checkpoint (a saved point), not replay the tools. A durable execution engine such as Temporal (a system that saves each step so work can resume) usually does this.

Step by step
  1. The client sends the request and gets a 529 overloaded error.
  2. It marks the error as transient. It works out a delay: a base time doubled for each attempt, plus jitter.
  3. If the response has retry-after, it waits at least that long.
  4. It sends the same request again, up to the maximum number of attempts.
  5. It returns the first success, or passes the last error up to the agent loop.
Code sketch
RETRYABLE = {408, 429, 500, 503, 504, 529}
for attempt in range(MAX_TRIES):
    try:
        return api.create(**req)
    except APIError as e:  # incl. SSE error
        if e.status not in RETRYABLE:
            raise
        if attempt == MAX_TRIES - 1:
            raise GaveUp(req) from e
        wait = min(CAP, BASE * 2 ** attempt)
        wait = random.uniform(wait / 2, wait)
        sleep(max(wait, e.retry_after or 0))

Be carefulRetries can be stacked at several layers, such as the SDK, the gateway and the agent loop. During an outage they multiply into dozens of calls. Retry in exactly one place.

3.05

Fallback

When the preferred model cannot answer, give the same request to a substitute, so the turn still finishes, maybe at lower quality.

request1. try2. on failure3. next4. all downAgent loopFallback chaintags served_byLarge model1st: primaryLarge, EUregion2ndSmall model3rdCanned replylast resort
Fig. 3.05The same request walks down a fixed chain until something answers.
Classic meaning

Fallback gives an alternative action or result when the main operation fails. The system then works less well instead of showing an error.

In the agent system

Sometimes retries run out, or a circuit breaker is open (a switch that blocks calls to a failing provider). Then the model client walks a fallback chain, which is an ordered list of substitutes. The chain is first filtered by the tenant's policy (the rules for that customer company). It might try the same model in another allowed region, then a smaller model. Last, it returns a typed 'busy, try again' status. Each step gets the same messages and tool definitions. They change only where the substitute needs it. Examples are a smaller max_tokens (output limit), no extended thinking, or dropping earlier thinking blocks. The substitute starts with a cold prompt cache. The prompt cache is saved work the provider can reuse when a request starts the same way, and cold means it is empty. So the substitute's first call pays the full input price. The harness tags the response with the model that actually served it. This keeps the transcript, cost records and evals (quality tests) honest. Some callers must use the strongest model, such as a review subagent. They opt out and fail loudly instead.

In a large company

In an enterprise platform, the platform team owns the fallback chain as configuration. It is limited for each tenant. A tenant pinned to EU data residency (keeping data inside the EU), or to one approved vendor, may only fall back inside that boundary, or not at all. Each fallback is logged with served_by in the audit trail and the trace. Cost reports show the shift, because the substitute starts with a cold prompt cache. Change management (the formal review process for changes) treats adding a model to the chain like a release. The same eval suite as the primary model must pass first.

Step by step
  1. Call the primary model with the request.
  2. If retries run out or the breaker is open, pick the next entry in the fallback chain.
  3. Adjust the request to the substitute's limits and send it.
  4. Tag the response with served_by and return it to the agent loop.
Code sketch
CHAIN = ['large', 'large-eu', 'small']

def complete(req, tenant):
    for model in tenant.allowed(CHAIN):
        try:
            r = call(model, fit(req, model))
            r.served_by = model
            return r
        except (Overloaded, BreakerOpen):
            continue
    return Degraded('busy, try later')

Be carefulA fallback model may not support the same tools, context length or output format. So run the agent's evals against every model in the chain.

3.06

Circuit Breaker

Stop calling a provider that keeps failing, refuse calls quickly for a cool-down period, then let one test call decide whether to resume.

5 fails in 60stimer expiresprobe failsprobe OKBreakerOpensuccessClosedcalls pass, countfailsOpenfail fast for 30sHalf-openone probe callFallback model
Fig. 3.06Three states: pass calls, refuse them fast, or test with one probe.
Classic meaning

A circuit breaker wraps calls to a remote service in a state machine (a thing with a few fixed states). It opens after repeated failures and refuses calls while open. Then it tries one test call before it closes again.

In the agent system

The model client keeps one breaker per provider, or per model and region. While the breaker is closed, calls flow normally. It counts 5xx (server error), 529 and timeout failures over a rolling window of recent time. It leaves 429s to rate limiting and retry-after. When failures cross a threshold, the breaker opens. For the next 30 seconds, every call fails at once with BreakerOpen. The fallback layer turns that into a switch to another model. So no agent wastes its retry budget on a dead endpoint. After the cool-down, the breaker goes half-open. It lets exactly one probe (test request) through. Success closes the breaker. Failure opens it again with a longer timer.

In a large company

In an enterprise platform, breakers usually sit in the LLM gateway (the central entry point for model calls) or the service mesh (the network layer between services). Examples are Envoy outlier detection and Resilience4j-style libraries. They are keyed by provider, model and region. Each state change is sent out as an event. These events page on-call staff and mark SLO dashboards (charts of reliability targets). Breaker state usually lives separately in each gateway replica (copy), and that is acceptable. But tune the thresholds so one tenant's (customer's) badly formed traffic cannot trip a shared breaker. Pair the breaker with the fallback chain and a status page entry. Then users see a degraded mode instead of silent errors.

Step by step
  1. Closed: requests pass through, and the breaker counts recent failures.
  2. Open: once failures cross the threshold, calls fail at once for a cool-down period.
  3. Half-open: when the timer ends, one test request may go through.
  4. The test result closes the breaker or opens it again.
Code sketch
def call(self, req):
    with self.lock:          # one probe only
        if self.state == 'open' and \
           now() >= self.retry_at:
            self.state = 'half_open'
        elif self.state != 'closed':
            raise BreakerOpen(self.name)
    try:
        r = self.provider.send(req)  # 4xx ok
    except (ServerError, Timeout):
        self.fail()   # half_open -> open
        raise
    self.reset()      # closed, fails = 0
    return r

Be carefulCount only failures on the provider's side. A 400 error caused by a badly formed prompt is not the provider's fault. If those errors trip the breaker, a healthy provider goes offline for every agent.

3.07

Rate Limiting

Cap how many requests and tokens each caller may use per time window, and refuse the extra with a clear time to retry.

40k tok900k tokwithin limitover limitrefillOrg A agentsOrg B agentsrunaway loopToken bucketper org, refillsModel API429 responseretry_after: 30sRefill clock+N tokens/sec
Fig. 3.07Each tenant spends from its own bucket. Overdrafts are refused with a wait time.
Classic meaning

Rate limiting limits how many operations a client may do in a time window. Requests over the limit are refused or delayed.

In the agent system

Providers already limit requests per minute, and input and output tokens per minute. Tokens are the small chunks of text that models read and write. When you go over, the provider answers 429 with a retry-after header (how long to wait). A multi-tenant harness serves many customer companies, called tenants. It adds its own limiter in front. This is usually a token bucket per organisation or per user. A token bucket is a counter that refills at a steady rate, and each call spends from it. The buckets live in a shared store. So one user's runaway agent loop cannot use up the shared provider quota. Before each call, the limiter charges the estimated input tokens plus the full max_tokens reservation (the most output the call may produce). This is on purpose, and it is stricter than the provider. The provider counts output as it is generated, and for most models it ignores cache reads. After the call, the limiter settles the real usage. A request that would overdraw the bucket is refused at once, with the time until enough tokens refill. A request that can never fit is refused outright.

In a large company

In an enterprise platform, per-tenant limits are enforced in the LLM gateway or the API gateway (the central entry points for calls). Examples are Kong, the Envoy rate-limit service, or a token bucket stored in Redis (a fast shared data store). The limit is keyed by the tenant and user taken from the OIDC token (the signed login token). It never comes from a header that the client sets. Limits come in tiers set by contract. They are reported back as rate-limit headers. They are tied to budgets and chargeback (billing costs back to each team), so finance sees the same numbers as the limiter. Every refusal is logged with the tenant and principal (the user or service that made the call). So noisy-neighbour incidents, where one tenant hurts others, can be traced.

Step by step
  1. Estimate the request's input tokens and add the max_tokens reservation.
  2. Refill the caller's bucket for the time that has passed, and check its balance.
  3. If there is room, take the tokens and forward the call. If not, return 429 with retry_after.
  4. After the response, refund or charge the difference against actual usage.
Code sketch
class TokenBucket:
    def take(self, n):
        if n > self.capacity:
            raise TooLarge(n)   # never fits
        self.refill(now())
        if self.tokens < n:
            need = n - self.tokens
            raise RateLimited(need/self.rate)
        self.tokens -= n

# shared store, so all replicas agree
bucket = buckets.get(req.org_id)
bucket.take(estimate(req) + req.max_tokens)

Be carefulBad token estimates make the limiter useless or unfair. So compare against the usage the provider reports after every call.

3.08

Throttling

Keep the harness's own agents within a safe share of the provider quota by pacing, capping, degrading or delaying their calls.

burstnextspacedrate headersAgent loopSubagents x6Scheduled jobRamp queuewaiting callsPacer1 call / 400 msModel API
Fig. 3.08A burst queues on the ramp and leaves at an even pace set by the headers.
Classic meaning

Throttling controls how many resources an app instance, a tenant (customer) or a whole service may use. Once use crosses a threshold, it rejects work, degrades it (does a cheaper version) or defers it (does it later). This keeps the system meeting its SLOs (reliability targets) under heavy load.

In the agent system

Every agent, subagent and scheduled job in the harness shares one provider quota. So the harness slows its own users before the provider starts returning 429 errors. A controller reads the rate-limit headers after each call. These headers say how many tokens remain and when the limit resets. As the remaining tokens shrink, it takes graded steps, one after another. First, a shared pacer spaces out calls. Next, fewer subagents may run at once. Then background work is degraded: shorter max_tokens, a smaller model, or no extended thinking. Last, the lowest-priority jobs are deferred (put off until later). Interactive turns, where a person is waiting, still go through, a little later. As headroom (spare quota) comes back, the harness lifts the steps in reverse order.

In a large company

In an enterprise platform, throttling is how the platform shares a fixed provider commitment among many tenants and workloads. That commitment might be provisioned throughput (capacity bought in advance) or a negotiated rate limit. Throttling caps how many agent runs each tenant may have at once. It degrades background work to a smaller model. It defers batch jobs before interactive users feel anything. Policies are set per tier in the gateway. Tenants can see them through headers and status pages, so a slowed job can be explained. Each throttling action is recorded as a metric and an audit event. This matters when an SLA credit (a refund for missing a service promise) or a chargeback dispute depends on whether a tenant was degraded.

Step by step
  1. Read the rate-limit headers after each model call.
  2. Work out a minimum gap between calls from the remaining tokens and the reset time. The reset header is a timestamp, so subtract the current time.
  3. Before each call, reserve the next slot, then wait for it.
  4. As headroom shrinks further, cap how many subagents run at once. Degrade or defer background work.
  5. Lift the limits in reverse order as headroom returns.
Code sketch
class Pacer:
    def adjust(self, h):
        left = int(h['tokens-remaining'])
        # reset is an RFC 3339 timestamp
        ts = parse_rfc3339(h['tokens-reset'])
        until = max(ts - now(), 0)
        self.gap = until / max(left / AVG, 1)

    async def wait_turn(self):
        slot = max(now(), self.next)
        self.next = slot + self.gap  # reserve
        await sleep(slot - now())

Be carefulThrottling only hides overload up to a point. If the ramp queue grows without end, you also need load shedding (dropping low-priority work) or backpressure (making the sender wait).

3.09

Load Shedding

When demand is more than capacity, drop or delay the least important model work, so interactive turns keep getting answers.

admittedshedset cut-offChat turnpriority 3Subagentpriority 2Eval runpriority 1Nightly digestpriority 0Admissioncheckcut-off: 2Model APIDeferred jobsrun laterLoad monitorp95, queue depth
Fig. 3.09Under load, low-priority calls are put off so the chat stays fast.
Classic meaning

Load shedding means refusing some incoming requests on purpose when a system is overloaded. It chooses them by priority, to protect the system's main job.

In the agent system

Each model request carries a priority. It inherits that priority from the root of its run (the request that started the work). A user waiting in the chat UI is high priority, and so is any subagent working for that turn. A subagent on a background task is medium. Scheduled digests, eval runs and memory consolidation (tidying stored memories) are low. Sometimes provider errors rise, or the queue gets long. Or p95 latency (the time within which 95% of calls finish) crosses a threshold. Or the organisation's token budget is nearly gone. Then the admission check (the gate before each call) refuses calls below the current cut-off. It gives them a 'shed' status and moves them to a later scheduled task. Interactive turns stay fast. Shed work is recorded so it can run when load drops.

In a large company

In an enterprise platform, the gateway decides what to shed using server-side context. The priority class comes from the logged-in workload and its run lineage (the chain of runs that led to this call). It never comes from a field the client sets, or every caller will claim to be interactive. Shed and deferred work is visible per tenant (customer company) in job status and metrics. The deferred queue has a size limit and is drained slowly, so recovery does not cause a second spike. Contracts and SLOs (reliability targets) should say which tiers of work can be shed. Then an eval or batch customer is not surprised.

Step by step
  1. Tag every model request with a priority class.
  2. Measure load: queue depth, latency, provider errors and remaining budget.
  3. Above a threshold, refuse or defer requests below the current cut-off priority.
  4. Record shed work, and lower the cut-off as load recovers.
Code sketch
PRIORITY = {'chat': 3, 'subagent': 2,
            'eval': 1, 'digest': 0}

def admit(req):
    cutoff = 0
    if load.p95_ms > 8000: cutoff = 2
    elif load.queue > 200: cutoff = 1
    if PRIORITY[req.kind] < cutoff:
        deferred.put(req)   # run later
        raise Shed(req.kind)
    return dispatch(req)

Be carefulShedding must be visible. A background job that quietly gets dropped every afternoon looks like a bug. So show deferrals in logs and in job status.

3.10

Backpressure

Let a slow consumer push back on its producer, so work waits at the source instead of piling up in memory.

put(task)get()4 callsawait: fullOrchestratorplans 200 tasksBounded queuemax 8: fullDispatcher4 call slots busyModel API
Fig. 3.10A full buffer makes the producer wait instead of piling up work.
Classic meaning

Backpressure is a way to control flow. The downstream part (the consumer) tells the upstream part (the producer) how much it can take. Producers slow down or pause when buffers fill up.

In the agent system

An orchestrator (the agent that hands out work) may fan out many subagents. It puts each task into a bounded queue, which has a fixed maximum size. Suppose every model-call slot in the dispatcher is busy. Then queue.put waits, and the orchestrator stops starting new subagents. So memory stays flat, however many tasks the model plans. Streaming to the chat UI works differently. If the harness pauses reading from the provider, the model does not slow down. A long pause may also make the provider close the stream. So the harness reads the provider stream at full speed. It writes into the transcript and into a bounded buffer for each client. When a slow mobile client falls behind, the harness coalesces (merges) the queued text pieces instead of holding up the provider. Wherever the producer can truly wait, the slowest stage sets the pace.

In a large company

In an enterprise platform, backpressure shows up as bounded queues and concurrency limits (caps on how many run at once). These sit between the orchestrator, the dispatcher and the gateway. It also shows up as consumer lag on event streams such as Kafka (a system for streams of messages), where consumers pull at their own pace. Backpressure is a key part of cost control. A planner that can queue unlimited subtasks is also a tenant (customer) that can spend unlimited tokens. Queue depth and wait time per tenant are sent out as metrics. So capacity planning and alerts can follow the slowest stage.

Step by step
  1. Connect the producer and consumer with a bounded buffer.
  2. The consumer empties the buffer at its own pace.
  3. When the buffer is full, the producer's put or write waits.
  4. Freed space releases the producer, and work flows again.
Code sketch
q = asyncio.Queue(maxsize=8)

async def orchestrator(plan):
    for task in plan.subtasks:
        await q.put(task)   # waits when full

async def dispatcher():   # start 4 of these
    while True:
        task = await q.get()
        try:
            await run_subagent(task)
        finally:
            q.task_done()

Be carefulAn unbounded queue anywhere in the chain breaks backpressure. The producer never feels the push, and memory grows until the process dies.

3.11

Queue-Based Load Leveling

Put model jobs in a durable queue that a fixed pool of workers empties, so bursts become a steady, affordable call rate.

burstpullsteady9am schedulesCI webhook50 PRsUsersJob queuedurableWorker 1Worker 2Worker 3Model API
Fig. 3.11Bursts fill the queue. Workers empty it at the rate the model can take.
Classic meaning

A queue sits as a buffer between tasks and a service. It smooths out sudden heavy loads. The service then handles work at its own steady rate.

In the agent system

At 9am, hundreds of scheduled tasks start at once. A CI webhook (an automatic message from the build system) can launch fifty review agents in a minute. These do not each call the model right away. Instead, the harness writes a job record to a durable queue, one that keeps jobs safe if machines restart. It returns a run id. A pool of workers pulls jobs and runs the agent loop. The pool is sized to the provider's sustained rate limit (the steady rate it allows). So the provider sees a flat line of traffic, and the 429 errors stop. Callers watch the run status or get a notice when it is done. Nothing is lost if a worker restarts.

In a large company

In an enterprise platform, the queue is a managed, durable service such as SQS, Pub/Sub or Kafka. It may also be a durable execution engine like Temporal (a system that saves each step so work can resume). The worker pool is sized to the provisioned model throughput (capacity bought in advance) of the tenant or platform. Delivery is at least once, so a job may arrive more than once. So every job carries an idempotency key (an id that makes repeats safe). Before any outside side effect, such as a PR comment or a ticket update, the worker checks that key. Separate queues or priorities per tenant (customer company) and for interactive work stop one tenant's burst from slowing everyone.

Step by step
  1. Producers (schedules, webhooks, users) add a job to the queue and get a run id.
  2. The queue stores jobs safely and soaks up the burst.
  3. A fixed pool of workers pulls jobs at a rate that fits the model budget.
  4. Each worker runs the agent and posts the result and a notice.
Code sketch
def on_webhook(event):
    run_id = f'pr-{event.pr}-{event.sha}'
    jobs.enqueue({'run': run_id,  # dedupe
                  'agent': 'pr_review',
                  'pr': event.pr})
    return {'run_id': run_id}   # at once

def worker():                    # N of these
    while job := jobs.receive(lease=900):
        if not runs.done(job['run']):
            result = run_agent(job)
            notify(job['run'], result)
        jobs.ack(job)  # redelivered if we die

Be carefulLoad leveling trades speed for stability. A user waiting in the chat should not sit behind a thousand batch jobs. So give interactive work its own lane.

3.12

Caching

Reuse work already paid for: cache the stable start of the prompt with the provider, and save repeatable tool results locally.

keyhitmissstorefast replyAgent loopnext turnCached?Cacheprefix KV + memoModel / toolfull compute
Fig. 3.12A hit skips the expensive work. A miss pays once and fills the cache.
Classic meaning

Caching stores the results of expensive operations. When the same request comes again, the answer comes from fast storage instead of being worked out again.

In the agent system

Every turn sends the tool definitions, system prompt and earlier conversation again. This stable part at the start is the prefix. The harness marks the end of the prefix with a cache_control breakpoint. The provider can then reuse its processed work when a later request starts with exactly the same prefix. The first call pays a cache-write premium, an extra charge above the normal input price. Later calls report cache_read_input_tokens. These cost a fraction of the normal input price, and the reply comes faster. The cache entry expires after about five minutes without a hit. A longer TTL (time to live) costs more to write. Prefixes shorter than a minimum length, which depends on the model, are not cached at all. The harness also has its own local cache. It memoises (saves the result of) read-only tool calls, such as fetching the same URL or reading an unchanged file. The cache key includes the tenant (customer company), the user, the tool name and the arguments. It also includes a content hash (a fingerprint of the content) or a short TTL. Anything with side effects or time-sensitive data skips the cache.

In a large company

In an enterprise platform, the provider's prompt caches are kept separate per organisation or workspace. But the harness's own caches must be keyed by tenant and principal (the user or service). These include tool results, retrieval results and rendered prompts. They must also respect data classification, retention rules and right-to-erasure (a person's legal right to have their data deleted). Teams track cache hit rate and cache-write spend per agent as cost metrics. A template change that breaks the prefix shows up as a sudden jump in the bill. Shared caches such as Redis (a fast shared data store) should be encrypted and split by tenant. They should not hold regulated data unless retention policy allows it.

Step by step
  1. Order the prompt so tools and system text come first and rarely change.
  2. Put a cache breakpoint after the stable prefix.
  3. On the next call, the provider matches the prefix and bills cached reads.
  4. For tool calls, check the local cache before running the tool, and store the result after.
Code sketch
system = [{'type': 'text', 'text': RULES,
           'cache_control':
               {'type': 'ephemeral'}}]

@memo(key=lambda ctx, a: (ctx.tenant,
      ctx.user, a['url'], day()))
def fetch_page(ctx, a):
    return http.get(a['url']).text

r = client.messages.create(model=M,
    system=system, tools=TOOLS, messages=h)
log(r.usage.cache_read_input_tokens,
    r.usage.cache_creation_input_tokens)

Be carefulA timestamp or random id near the top of the system prompt changes the prefix on every call. Then every call pays the cache-write premium, and none ever gets a cheap read.

3.13

Object Pool

Direct fit

Keep costly resources such as sandboxes, browsers and connections ready, lend them to agents, and reset them when returned.

acquire()lendrun commandreleaseback to idleAgentbash tool callSandbox pool3 warm, idleSandbox #2on loanResetwipe fs, kill procs
Fig. 3.13Borrow, use, reset, return: nothing expensive is built for each call.
Classic meaning

An object pool keeps a set of ready, reusable objects. Clients borrow one and give it back. This avoids the cost of creating and destroying them again and again.

In the agent system

Starting a code sandbox container (a sealed space for running code) or a headless browser (a browser with no screen) takes seconds. Opening a fresh TLS connection (an encrypted network link) to the model API on every call also adds delay. So the harness keeps pools. There are a few warm (already started) sandboxes, a browser pool for web tools, and the HTTP client's keep-alive connection pool. An agent checks out a sandbox for a bash or code-execution tool call. When the sandbox comes back, the pool wipes the working folder, kills leftover processes and resets environment variables. Only then does it lend it again. When every instance is busy, callers wait with a timeout, or the pool grows up to a hard limit.

In a large company

In an enterprise platform, the sandbox pool is often a pool of fresh, never-used microVMs (tiny virtual machines) or gVisor containers (containers with an extra security layer). They start in advance and are destroyed after one session instead of being reset. A reset that misses one file or credential is a data leak between tenants (customer companies). Where reuse is allowed, it stays inside one tenant, and the reset is verified. Every lease (each loan of an instance) is logged with tenant, session and instance id for the audit trail. Pool size is a cost and capacity setting per region. Teams watch it for wait time and running out, like any other SLO (reliability target).

Step by step
  1. At startup, create N warm instances and put them on the idle list.
  2. A tool call takes an instance, and waits if none is free.
  3. The tool runs inside the borrowed instance.
  4. On return, the pool resets it and puts it back on the idle list. If the reset fails, the pool destroys it.
Code sketch
class SandboxPool:
    def acquire(self, timeout=30):
        sb = self.idle.get(timeout=timeout)
        return Lease(sb, self.release)
    def release(self, sb):
        try:
            sb.reset()  # wipe fs, procs, env
            self.idle.put(sb)
        except ResetFailed:
            sb.destroy(); self.spawn_one()

with pool.acquire() as sb:
    out = sb.run(tool_input['command'])

Be carefulA resource returned dirty can leak one session's files or cookies into another user's agent. So treat the reset as critical for security, and destroy anything that fails it.

3.14

Multiton

Direct fit

Keep exactly one client per key, such as provider and model or tenant, created on first use and shared after that.

get(A, large)get(A, large)get(B, large)same instancenew on missMain agentSubagentEval runnerClient mapkey: (prov, model)Client A/largeshared poolClient A/smallnot yet usedClient B/largebuilt on 1st get
Fig. 3.14Same key, same instance. A new key creates exactly one more.
Classic meaning

A multiton is like a Singleton (a class with only one instance), but it keeps a map of named instances. It guarantees one instance for each key.

In the agent system

Each provider and model pair needs its own client. Each client has its own base URL, credentials, connection pool and rate-limit tracker. ModelClient.get(provider, model) returns the existing client for that key. On first use, it builds one. It does this under a lock (a guard that lets only one task in at a time). So two subagents running at the same time do not both build it. Every agent that asks for the same key shares one connection pool and one view of remaining quota. Another tenant's key (another customer company) gets a separate client with its own API key.

In a large company

In an enterprise platform, a multiton keyed by tenant (customer company) holds a model client for each tenant. Each client has its own credentials, base URLs for in-region endpoints, and budgets. So tenant isolation is built into the structure, not a parameter on each call. With thousands of tenants, the map needs a size limit and eviction (removing old entries). Evicted clients must be closed cleanly to release their connections. Keys must come from the logged-in tenant context, never from request input. Otherwise one tenant could get another tenant's client.

Step by step
  1. A caller asks for the client keyed by provider and model, or by tenant.
  2. The class looks up the key in its map of instances.
  3. If the key is missing, it builds the client under a lock and stores it.
  4. Every later caller with that key gets the same instance.
Code sketch
class ModelClient:
    _all = {}
    _lock = threading.Lock()

    @classmethod
    def get(cls, provider, model):
        key = (provider, model)
        with cls._lock:
            if key not in cls._all:
                cls._all[key] = cls(*key)
            return cls._all[key]

Be carefulA key might include a value that changes often, such as a per-request timeout. Then each call creates a new instance, and the multiton quietly becomes a memory leak.

3.15

Singleton

Direct fit

Share one model client and settings object across the whole process, so every agent uses the same connection pool, credentials and limits.

get_client()same objectload onceshared poolAgent loopSubagents x5SchedulerModel client1 pool, 1 keySettingsenv + configModel API
Fig. 3.15Every caller reaches the one client, which owns the only connection pool.
Classic meaning

A singleton makes sure a class has only one instance. It also gives one global way to reach that instance.

In the agent system

A harness process usually creates one SDK client (the provider's code library) at startup. It shares that client across the agent loop, subagents and background workers. That one instance owns the HTTP connection pool (open network links kept for reuse). It also owns the API key, default timeouts and the retry policy. If each subagent built its own client, the process would open extra connections. Each copy would also track rate limits on its own. Settings loaded from environment variables and config files work the same way. get_settings() returns one cached object.

In a large company

In an enterprise platform, one client per process is fine for shared plumbing. Examples are the HTTP connection pool and telemetry exporters (code that sends out metrics and traces). But credentials and limits are usually per tenant (customer company). So in a multi-tenant worker, the singleton should not hold a single API key. Settings come from a config service or the environment. Secrets come from Vault or a cloud KMS (key management service). Key rotation must refresh the cached client without a restart. Dependency injection (handing the object in, instead of grabbing it globally) keeps tests and per-tenant overrides possible.

Step by step
  1. On first use, build the client from settings and keep it at module level.
  2. Every later call returns that same instance.
  3. All agents and workers share its connection pool and retry policy.
  4. Tests replace it through an explicit override hook.
Code sketch
from functools import cache

@cache
def get_settings():
    return Settings.from_env()

@cache
def get_client():
    s = get_settings()
    return ModelClient(api_key=s.api_key,
                       max_retries=s.retries,
                       timeout=s.timeout)

Be carefulA singleton is global mutable state (shared data anyone can change) in disguise. Pass it into agents instead of calling get_client() deep inside tools. Otherwise tests cannot swap in a fake.

3.16

Tolerant Reader

Adapted fit

Read model output by taking what you need and ignoring the rest, fixing small mistakes instead of crashing on them.

textjson-ishdicttypedfield missingModel textfence, prose,extrasStrip &extractfirst {...}Lenient parsefix commas, quotesField readerrequired onlyVerdictscore: intRe-ask modelrarely needed
Fig. 3.16Messy text goes in, a strict typed object comes out, and the model is asked again only when it truly matters.
Classic meaning

A tolerant reader reads only the parts of a message it needs. It accepts unknown fields, a different order and small variations. So it keeps working when the sender changes its output.

In the agent system

A model asked for JSON (a common text format for structured data) does not always return clean JSON. It may wrap it in a markdown fence ( marks) or add a sentence before it. It may leave a trailing comma, return numbers as strings or invent an extra field. A tolerant reader in the harness removes fences and pulls out the first balanced object (from the first { to its matching }). It applies a gentle repair. Then it reads only the required fields, converts simple types and ignores unknown keys. The harness reads verdicts from grader models (models that score answers) and plans from planner subagents this way. Only when a required field is truly missing does the harness send a short correction request back to the model.

In a large company

In an enterprise platform, the first defence is to avoid parsing free text at all. Teams use tool calls or structured outputs with a JSON schema (a formal description of the expected shape). The tolerant reader stays for the remaining edges, such as grader verdicts and output from third-party models. Parse failures and repairs are counted per prompt version and model. So a regression (something that gets worse) after a model upgrade shows up in evals (quality tests) before users see it. Anything parsed from model output that drives a privileged action still gets strict checking. It also passes a policy check before it runs, for example with OPA or Cedar (policy engines that decide what is allowed).

Step by step
  1. Remove code fences and any text before the JSON.
  2. Pull out the first balanced JSON object and fix small syntax errors.
  3. Read the required fields, convert simple types and ignore extra keys.
  4. If a required field is missing, ask the model for a corrected answer.
Code sketch
def read_verdict(text):
    raw = strip_fences(text)
    obj = repair_json(first_obj(raw))
    # take what we need, ignore the rest
    return Verdict(
        score=int(obj['score']),   # '7' ok
        reason=obj.get('reason', ''))

try:
    v = read_verdict(reply.text)
except (KeyError, ValueError):
    ask_model_to_fix(reply)

Be carefulTolerance belongs only at the edge where text is parsed. Pass strict, typed objects inward. Never 'repair' input that authorises a destructive tool action (one that deletes or changes things).

3.17

Partial Response

Deliver a model's answer piece by piece as it is written, or send only the fields a caller asked for.

text_delta 'Let me check'append 'Let me check'input_json_delta (partial)buffer tool inputcontent_block_stopmessage_delta: stop_reason tool_usemessage_stopparse, run tool_usetool statusModel APIHarnessChat UI
Fig. 3.17Text reaches the user as it arrives. Tools run only once the message is complete.
Classic meaning

A partial response lets a client get part of a resource. This can be a subset of fields, or a stream of the result as it is produced. It saves bandwidth and waiting time.

In the agent system

With streaming on, the model API sends server-sent events (SSE: a stream of small messages over one connection). First comes message_start. Then each content block gets a content_block_start event. Next comes a run of content_block_delta events. These carry text_delta or input_json_delta pieces. Each block ends with content_block_stop. Then a message_delta event carries the stop_reason (why the model stopped) and usage. Last comes message_stop. The harness sends text pieces to the chat UI at once. It holds input_json_delta pieces until a tool_use block (the model's request to run a tool) is complete. It runs tools only after the stream ends with stop_reason tool_use. It can cancel the stream when the user presses stop. Field selection is the same idea for reads. The UI polls a status endpoint. That endpoint returns only the run's state and token count, never the whole transcript.

In a large company

In an enterprise platform, streaming passes through the LLM gateway (the central entry point for model calls). The gateway must stream from end to end. Proxies and load balancers must not hold back the response. The gateway still applies output DLP (data loss prevention: stopping sensitive data from leaving) or redaction. It often does this on a small rolling window, so users still see text quickly. Time to first token and stream completion are SLO metrics (measures tied to reliability targets). Cancelled streams are recorded, so partial output and its cost appear in the audit trail. Field selection on status and transcript endpoints also serves as data minimisation (sharing only the data needed). This supports GDPR (the EU data protection law) and least-privilege access to transcripts (giving each person only the access they need).

Step by step
  1. Open a streaming request to the model API.
  2. Send each text piece to the UI and add it to the turn buffer.
  3. Collect tool input pieces until each block stops. Ignore ping events and unknown events.
  4. On message_delta, record stop_reason and usage. Close the turn on message_stop.
  5. Run the buffered tool calls only if stop_reason is tool_use.
Code sketch
with client.messages.stream(**req) as s:
    for ev in s:
        if ev.type == 'content_block_delta' \
           and ev.delta.type == 'text_delta':
            ui.send(ev.delta.text)
        # input_json_delta: SDK buffers it
        # ping and unknown events: ignore
    msg = s.get_final_message()
if msg.stop_reason == 'tool_use':
    run_tools(msg.content)  # whole inputs
elif msg.stop_reason == 'max_tokens':
    continue_or_fail(msg)   # may be cut off

Be carefulNever act on a partial tool input. A half-streamed path such as '/home/user/pro' is a valid start of the real path, and it points to the wrong file.

3.18

Money

Store tokens and cost as typed amounts with a unit or currency, so sums, budgets and reports cannot mix numbers that do not match.

wrapx priceaddratestotalusage blockin, out, cache r/wTokensn, kind, modelMoneypico + 'USD'Session ledgerPrice tableper model + kindBudget checkspent + est > cap?
Fig. 3.18Counts become typed tokens, then typed money, before anything is added up.
Classic meaning

The Money pattern models an amount of money as a value object (a small object defined only by its values). It combines a quantity and a currency. Its arithmetic enforces currency rules and correct rounding.

In the agent system

Each response has a usage block with four counts. input_tokens is only the uncached part of the prompt. cache_creation_input_tokens counts tokens written to the prompt cache. cache_read_input_tokens counts tokens read from it. output_tokens counts what the model wrote. Each kind has its own price for each model, and cache writes are priced by TTL (how long the cache entry lives). Cached tokens are billed separately, so the cost ledger must include all four counts. As bare integers, these counts invite bugs. Code might treat input_tokens as the whole prompt, or add output tokens to input tokens. It might add a large model's tokens to a small model's as if they cost the same. So the harness uses value objects: Tokens(n, kind, model) and Money(pico, 'USD'). It converts between them through an explicit, versioned price table. It does arithmetic in whole-number units small enough to price one cached token exactly. These are pico-dollars (millionths of a millionth of a dollar). With streaming, it takes usage from the final message_delta. Those counts are cumulative (running totals), so it does not add up every event. Budgets for a session, a subagent tree or a tenant (customer company) are Money too. So a check like spent + estimate > budget is type-safe (the types stop mistakes).

In a large company

In an enterprise platform, the Money value object supports per-tenant budgets, chargeback to cost centres (billing costs back to each team) and invoices. So the price table is versioned and has effective dates. Every ledger entry records the model, the usage fields, the price version and the tenant. A daily job reconciles (compares and matches) the ledger against the provider's usage and cost reports. The gateway enforces budget alerts and hard stops before a call, not only in reports after it. Currency conversion, where needed, happens once at billing with a recorded rate. It never happens inside per-call arithmetic.

Step by step
  1. Read the usage block from each model response.
  2. Wrap each count as Tokens with its kind and model.
  3. Convert to Money using the price table for that model and kind.
  4. Add it to the session ledger and compare against the Money budget.
Code sketch
@dataclass(frozen=True)
class Money:
    pico: int; cur: str = 'USD'  # 1e-12
    def __add__(s, o):
        assert o.cur == s.cur
        return Money(s.pico + o.pico, s.cur)
def cost(u, model):
    p = PRICES[model]   # pico-USD per token
    n = (u.input_tokens * p.inp
       + u.cache_creation_input_tokens * p.wr
       + u.cache_read_input_tokens * p.rd
       + u.output_tokens * p.out)
    return Money(n)     # exact, round later

Be carefulFloating-point dollars (decimals that computers store inexactly) drift after millions of tiny per-call charges. Store whole-number units smaller than a millionth of a dollar, and round only when displaying or invoicing.

04
Chapter 4 · What the agent remembers

Context and Memory

The model can only see a limited amount of text at once (its context window), and that space is costly. Conversations, files and saved memories are large and need to last. These patterns decide what gets stored, when it is loaded, how copies are avoided, and how two people editing at once do not overwrite each other.

4.01

Repository

Give the agent's memory a simple list-like interface (add, search, remove), so loop code never talks to the database directly.

SWAPPABLE BACKENDSsearch(q)Memory[]queryselectreadAgent loopneeds contextMemoryRepoadd / searchVector storeembeddingsHistory DBpast turnsMemory filesnotes/*.md
Fig. 4.01The loop asks one collection, and the repository decides which store answers
Classic meaning

A repository sits between your main program and the storage code. It lets the program treat stored objects like an ordinary collection.

In the agent system

The agent loop (the code that runs the model turn after turn) asks a MemoryRepository for what matters to this turn: repo.search(query, k=5). Here k is the number of results. Behind the repository there might be a vector store (a database that finds text by meaning). It uses embeddings, which are lists of numbers that stand for meaning. It could also be a SQL table of past chats or a folder of markdown memory files. The repository speaks in the harness's own objects, such as Memory and Turn. It applies scope, such as the user id and how recent a memory is. It also hides the embedding call. You can swap the vector store, or use a simple in-memory repository in evals (automated test runs), without changing any loop code.

In a large company

In an enterprise platform, the memory repository is the one checkpoint where tenant and user scope get fixed. A tenant is one customer company sharing the platform. The scope comes from the logged-in identity, so neither loop code nor the model can widen a search. The repository is also where right-to-erasure requests land (a person's legal right to have their data deleted). remove() and a forget_user() call must delete the memory text, its embedding and any cached copies. Some vector stores offer separate namespaces or indexes per tenant. Postgres with pgvector (a vector search add-on) and row-level security (database rules that limit which rows each user can see) can also do this. These enforce scope below the repository as a second line of defence.

Step by step
  1. The loop builds a search query from the latest user message.
  2. It calls repo.search(query, k), limited to the current user.
  3. The repository embeds the query, searches its store and turns each hit into a Memory object.
  4. The loop puts those memories into the context window and saves new facts with repo.add().
Code sketch
class MemoryRepo(Protocol):
    def add(self, m: Memory): ...
    def search(self, q, k=5) -> list: ...
    def remove(self, mem_id): ...

class VectorMemoryRepo(MemoryRepo):
    def __init__(self, index, tenant, user):
        self.index = index
        self.ns = f'{tenant}/{user}'  # scope
    def search(self, q, k=5):
        hits = self.index.query(embed(q),
            top_k=k, namespace=self.ns)
        return [Memory.from_hit(h)
                for h in hits]

Be carefulDo not let store-specific query syntax, such as filter dicts or bits of SQL, leak out through the repository. Once callers depend on it, the abstraction is gone.

4.02

DAOData Access Object

Direct fit

Wrap one storage system's raw API in a small object with plain append, load and delete calls for one record type.

HIDDEN INSIDEappend()load()encoderunrowsSessionserviceMemory repoTranscriptDAOappend / load /deleteSQL + poolINSERT, SELECTJSON codecblocks <-> rowsTranscript DBturns table
Fig. 4.02Callers see four simple actions, and SQL, pooling and encoding stay inside the DAO
Classic meaning

A DAO is an object that gives you simple data actions for one database or storage system. It hides how the storage actually works.

In the agent system

A TranscriptDAO gives the harness a few calls: append(session_id, message), load(session_id), list_sessions(user_id) and delete(session_id). A transcript is the saved record of a conversation. Inside the DAO sit the SQL, the connection pool (a set of open database connections it reuses) and the JSON encoding of content blocks. It also handles paging through long transcripts. The session service and the memory repository call the DAO, not the database driver. So moving transcripts from a database file on a laptop to a managed SQL server in production changes only one class.

In a large company

Enterprise platforms usually put one DAO per record type in front of a managed database. The DAO is where encryption, tenant columns and retention rules (how long data is kept) live, so callers cannot skip them. A tenant is one customer company on the platform. A TranscriptDAO usually writes tenant_id on every row. On every read it relies on row-level security (database rules that limit which rows a user sees) or a tenant filter. It encrypts message bodies with a key per tenant from a KMS (key management service). Its delete() must truly wipe the data. That includes replicas (copies of the database) and backups, on a written schedule. A GDPR erasure request (a legal request to delete personal data) depends on that call in the end.

Step by step
  1. The session service calls dao.append(sid, message) after each turn.
  2. The DAO turns the content blocks into JSON and runs an INSERT using its pool.
  3. When a session resumes, dao.load(sid) reads the rows in order.
  4. The DAO turns each row back into a Message and returns the list.
Code sketch
class TranscriptDAO:
    def __init__(self, db): self.db = db
    def append(self, sid, msg):
        self.db.execute(
          'INSERT INTO turns VALUES (?,?,?)',
          (sid, msg.seq, msg.to_json()))
    def load(self, sid):
        rows = self.db.query(
          'SELECT body FROM turns WHERE sid=?'
          ' ORDER BY seq', (sid,))
        return [Message.from_json(r)
                for r in rows]

Be carefulA DAO that just copies SQL one-to-one adds little. Name its methods in harness terms, such as append a turn or load a session. Give each record type its own DAO.

4.03

DAO Factory

Direct fit

Choose a storage system once at startup and get a matching set of DAOs (transcripts, memories, artifacts) that all use it.

ONE FAMILYlocalcloudevalConfigmode = cloudmode?LocalFactoryfiles, embedded DBCloudFactorySQL + object storeMemoryFactoryeval fakesTranscriptDAOcloud SQLMemoryDAOcloud vectorsArtifactDAOcloud bucket
Fig. 4.03Choosing the factory chooses every DAO, so stores never mix
Classic meaning

A factory that hands out a whole family of DAOs for one data source. To switch storage, the program picks a different factory.

In the agent system

The harness reads its config at startup. Local mode wants an embedded database (one stored in a local file) and the local file system. Cloud mode wants a managed SQL server and object storage (a cloud service for storing files). Evals (automated test runs) want everything in memory. There is one factory per mode. It returns transcripts(), memories() and artifacts(), all connected to the same storage and credentials. A DAO is a small object that handles storage for one kind of record. The session service asks the factory for DAOs and never checks the mode itself. So a laptop run, a hosted run and an eval run can never mix stores.

In a large company

In an enterprise system spread across several regions, the factory choice depends on where the tenant lives as well as on the mode. A tenant is one customer company on the platform. An EU tenant gets a factory whose DAOs all point at EU databases, EU buckets and an EU KMS key. A KMS is a key management service for encryption keys. Building the whole family from one tenant placement record stops a transcript landing in one region while its artifacts land in another. Each family's credentials should come from workload identity (the platform proving which service is asking) or a secrets manager such as Vault. They should be fetched when the DAOs are created. They should never come from config files the agent can read.

Step by step
  1. At startup the harness reads the deployment mode from config.
  2. It creates the matching factory once.
  3. Services ask the factory for each DAO they need.
  4. Every DAO in the family shares the same connection, storage bucket and credentials.
Code sketch
class DAOFactory(Protocol):
    def transcripts(self) -> TranscriptDAO:...
    def artifacts(self) -> ArtifactDAO: ...

class LocalFactory(DAOFactory):
    def transcripts(self):
        return FileTranscriptDAO(self.root)
    def artifacts(self):
        return FileArtifactDAO(self.root)

FACTORIES = {'local': LocalFactory,
             'cloud': CloudFactory,
             'eval': MemoryFactory}
f = FACTORIES[cfg.mode](cfg)   # once

Be carefulIf you will only ever have one storage system, the factory is just extra ceremony. Add it when a second real one arrives. In-memory fakes for evals count.

4.04

Data Mapper

Keep objects like Memory and Turn unaware of storage by putting a separate mapper between them and the store.

DOMAINSTORAGEsavefrom_hitembed(text)to_recordupserthitsMemorytext, source, tsMemoryMapperto_record /from_hitEmbeddingmodelStore recordid, vector, meta{}Vector store
Fig. 4.04Neither side knows the other's shape, and only the mapper knows both
Classic meaning

A layer of mappers moves data between objects and a database. The objects and the database stay independent of each other, and of the mapper too.

In the agent system

In the harness, a Memory has text, source, created_at and importance. It has methods like is_stale(). The vector store (a database that finds text by meaning) wants something else. It wants an id, a list of numbers called a vector, and a flat set of text labels called metadata. A MemoryMapper converts between the two. One way is to_record: it embeds the text (turns it into a vector), flattens the fields and turns dates into text. The other way is from_hit: it reads the metadata and adds the similarity score (how close the match was). The Memory class imports nothing from the store's client library. So you can test it without a database. You can also move it to a different store by rewriting only the mapper.

In a large company

The mapper is where an enterprise platform decides what reaches the vector store as readable text. So it is the natural place for PII redaction (removing personal data such as names and emails). It is also where the stored text can be encrypted field by field. And it is where records get tagged with tenant, data classification and retention class. A tenant is one customer company on the platform. Many vector stores copy metadata into managed services outside the main database. So copying raw memory text into metadata spreads it to more places and regions. That makes a data breach bigger. Keeping the mapping in one class also makes a DPIA (data protection impact assessment) or a security review easier. One file shows exactly which fields leave your code.

Step by step
  1. Normal code creates and changes Memory objects without any save calls.
  2. On save, the mapper turns each Memory into a record with a vector and flat metadata.
  3. On load, the mapper reads search hits and rebuilds Memory objects with scores.
  4. If the store's layout changes, only the mapper needs to change.
Code sketch
class MemoryMapper:
    def to_record(self, m: Memory):
        return {'id': m.id,
            'vector': embed(m.text),
            'meta': {'tenant': m.tenant,
                'text': m.text, 'src': m.source,
                'ts': m.ts.isoformat()}}
    def from_hit(self, h) -> Memory:
        md = h.meta
        return Memory(id=h.id,
            tenant=md['tenant'],
            text=md['text'], source=md['src'],
            ts=parse_iso(md['ts']),
            score=h.score)

Be carefulA mapper roughly doubles the code compared with objects that save themselves. For a few flat records with no logic, that cost may not be worth it.

4.05

DTOData Transfer Object

Send flat, versioned data objects with no behaviour between separate processes, such as the tool runner and the chat client.

SANDBOXcopy fieldsJSON / HTTPreplytool_resultAgent loophas live objectsToolRequestv, call_id, name,inputTool runnerown processToolResultv, call_id,content
Fig. 4.05Only flat, versioned data crosses the process boundary
Classic meaning

A DTO carries data between processes (separate running programs). It bundles many values into one payload that can be turned into text or bytes, so a remote call needs fewer round trips. It has no business logic of its own.

In the agent system

Sometimes the agent loop talks to a tool runner in another process, inside a sandbox (a locked-down space for running tools). Sometimes the server streams events to the chat client. In both cases it sends DTOs: ToolRequest {call_id, name, input}, ToolResult {call_id, content, is_error} and StreamEvent {type, delta}. They are frozen dataclasses, which means they cannot change after they are made. They turn into JSON and carry a schema version number. Live objects such as a Session or a ToolCommand hold open files and methods. Those never cross the wire. At the boundary, the harness copies only the needed fields into a DTO.

In a large company

In an enterprise harness, DTOs sit exactly at trust boundaries (points where data moves between parts that trust each other differently). So that is where checks run before data reaches the tool sandbox, the model provider or the browser. These checks are schema validation, size limits and DLP or PII redaction. DLP is data loss prevention. PII redaction removes personal data such as names and emails. Define DTOs in a shared schema such as JSON Schema or Protobuf, with explicit versions. Check them for compatibility in CI (the automated build and test pipeline), because the runner and the loop are deployed separately. Credentials and access tokens must never be DTO fields that could be copied into the model's context. The runner should get them itself through workload identity (the platform proving which service is asking).

Step by step
  1. The loop copies the fields of a tool_use block (the model's request to run a tool) into a ToolRequest.
  2. The loop turns the request into JSON and sends it to the tool runner.
  3. The runner rebuilds the DTO, runs the tool and replies with a ToolResult.
  4. The loop turns the ToolResult into a tool_result content block for the model.
Code sketch
@dataclass(frozen=True)
class ToolRequest:
    v: int          # schema version
    call_id: str
    name: str
    input: dict

req = ToolRequest(1, b.id, b.name, b.input)
r = http.post(RUNNER, json=asdict(req))
res = ToolResult(**r.json())

Be carefulDo not let DTOs grow methods or hold live handles such as open files or clients. Give them version numbers, because the two sides of the wire are updated at different times.

4.06

Value Object

Treat content blocks, messages and token counts as values that never change and are compared by content, so sharing and caching them is safe.

yes: hit+ usage = new objTextBlocktext='hello'TextBlocktext='hello'samevalue?Prompt cachekey = hash(prefix)TokenCountin 1200, out 340TokenCountin 1500, out 410
Fig. 4.06Equal content means equal objects, and a change makes a new object
Classic meaning

A value object is a small object that never changes after it is made. Two value objects are equal when their values are equal, not when they are the same object.

In the agent system

TextBlock('hello'), a ToolUse block and TokenCount(input=1200, output=340) are value objects. They are frozen (they cannot change), and equality and hashing use their fields. A hash is a short fingerprint made from the content. The harness relies on this in several places. Removing duplicates compares blocks by value. Local caches use a hash of block values as the key. Usage totals add TokenCount values to make new ones, instead of changing a counter that two turns share. The provider has a prompt cache (saved work it can reuse when a request starts the same way). It matches the exact bytes of the start of the request. So the harness needs blocks that never change plus deterministic serialisation (turning data into text the same way every time). Together they keep a resent prefix byte-identical. A block in a prefix that was already sent must never change in place. If it does, the cached prefix silently stops matching.

In a large company

Enterprise harnesses use value objects for content blocks, usage records and cost amounts. Then shared counters that change cannot corrupt audit totals or chargeback (billing each team for its own use). The risk is treating equal values as a security boundary. Two tenants (customer companies on the platform) can produce identical blocks. So any cache in the harness keyed on block values must also put the tenant and policy context in the key. Deterministic serialisation of these values also matters. That means a stable key order and fixed number formats. It lets eval and replay tools compare transcripts reliably.

Step by step
  1. Blocks and counts are created frozen.
  2. To change one, you make a new copy with replace().
  3. Equal content gives equal hashes, and so the same cache key.
  4. Maths such as total + usage returns a new TokenCount.
Code sketch
@dataclass(frozen=True)
class TokenCount:
    input: int = 0
    output: int = 0
    def __add__(self, o):
        return TokenCount(
            self.input + o.input,
            self.output + o.output)

a, b = TextBlock('hello'), TextBlock('hello')
assert a == b and hash(a) == hash(b)
total = total + turn.usage   # new object

Be carefulA frozen dataclass that holds a list can still be changed through that list. Use tuples (lists that cannot change) inside value objects, and never give them ids.

4.07

Composite Entity

Load and save a whole session (messages, todos, tool state, files read) as one unit through a single root object.

ONE AGGREGATEget(sid)save(s)Agent loopSession storeSessionaggregate rootMessagesTodo listTool statecwd, env, grants
Fig. 4.07Everything that belongs to a session moves through one root
Classic meaning

A large object that looks after a group of smaller objects that depend on it. Programs load and save the whole group through that one object.

In the agent system

A Session object is the root of an aggregate (a group of objects that are always handled together). The group holds messages, a todo list and tool state, such as the shell's current folder. It also holds the set of files the agent has read and the permission grants for this session. The harness loads it with sessions.get(id) and saves it with sessions.save(s). Only the root has a public id. Code reaches the parts through the root, as in s.todos.add or s.messages.append. So resuming or forking a session moves one consistent snapshot, not five loosely linked rows. A subagent is a helper agent started by the main one. When a subagent gets a copy, the harness removes the grants and gives it a smaller set. Approvals belong to the person or agent who gave them.

In a large company

Enterprise platforms treat the session group as the unit of ownership, retention and legal hold. Retention is how long data is kept. A legal hold means data must be kept for a court case. The root carries the tenant (customer company), owner, data class and region, and every part inherits them. That makes controls per session practical. You can export one session for an audit, delete it after an erasure request, or hold it for a lawsuit. Permissions must not travel along with the data. A policy engine such as OPA or Cedar checks the grants again. It does this whenever a different person or agent resumes the session, or the session is handed to a subagent. OPA and Cedar are tools that decide who may do what, based on written rules.

Step by step
  1. A resume request arrives with a session id.
  2. The store loads the root and all its parts in one call.
  3. During the turn, the loop changes parts only through the root.
  4. At the end of the turn, the whole group is saved in one write.
Code sketch
class Session:              # aggregate root
    def __init__(self, sid):
        self.id = sid
        self.messages: list[Message] = []
        self.todos = TodoList()
        self.tool_state = {'cwd': '/repo'}
        self.read_files: set[str] = set()

s = sessions.get(sid)
s.todos.add('run tests')
sessions.save(s)    # one write, whole graph

Be carefulIf every save rewrites megabytes of transcript, the group is too big. Keep fast-growing parts like messages in their own log, and keep only references to them in the root.

4.08

Unit of Work

Collect a turn's memory, todo and message writes, then save them all together at the end of the turn, or cancel them all.

registerturn endcommiterrorsave_memoryupdate todosappend msgTurnUnitOfWorknew / dirty /deletedturn ok?Session storeone transactionRollbackstore unchanged
Fig. 4.08Writes wait in the unit of work until the end of the turn decides what happens to them
Classic meaning

A unit of work keeps a list of the objects changed during one task. At the end it writes all the changes, and settles any conflicts, as one step.

In the agent system

During one agent turn, the model might save two memories, update the todo list, write an artifact and add three messages. The harness does not write each change right away. It registers each one with a TurnUnitOfWork. If the turn ends cleanly, uow.commit() writes everything in one transaction (a group of database writes that all succeed or all fail). The turn might fail instead. The user might cancel, a token limit might be hit, or a tool might throw an error. Then uow.rollback() drops the waiting writes. The saved session never shows half a turn. A retried turn does not create duplicate memories.

In a large company

In an enterprise platform, the unit of work for a turn usually maps to one database transaction on the session store. Tool runs are recorded separately. They go into an append-only audit log (a record you can add to but never edit) or a durable workflow engine such as Temporal. Temporal is a system that runs long tasks and remembers each step even after crashes. The key rule is that rollback only affects the agent's own state. It never touches the audit trail of actions taken on outside systems. Each turn's commit should carry an idempotency key, a unique id that makes a repeated request safe. Then a retry after a lost reply does not duplicate memories or billing records.

Step by step
  1. Starting a turn starts a unit of work.
  2. Each write is registered as new, changed or deleted, not saved yet.
  3. If the turn finishes, commit() writes all changes in one transaction.
  4. If the turn is stopped or fails, rollback() drops them and the store stays untouched.
Code sketch
def run_turn(session, uow):
    reply = call_model(session)
    for m in reply.new_memories:
        uow.register_new(m)
    uow.register_dirty(session.todos)
    uow.register_new(reply.message)

with TurnUnitOfWork(db) as uow:
    run_turn(session, uow)
# __exit__: commit() on success,
# rollback() on any exception

Be carefulRollback cannot undo a sent email or a git push. It also must not erase the record of a tool that already ran. Log executed tool calls outside the unit of work. Put a separate check in front of tools that cannot be undone.

4.09

Identity Map

Adapted fit

Keep one in-memory object per document id while building context, so a file or memory found twice enters the window once.

get(key)miss: load1 copyVector searchdoc-17#3Grep tooldoc-17#3Memory recalldoc-17#3Identity mapkey -> objectDoc storeloaded onceContext windowdoc-17 cited [2]
Fig. 4.09Three retrievals of the same chunk become one object in context
Classic meaning

An identity map makes sure each object is loaded only once. It keeps every loaded object in a lookup table by its id, and returns the existing one on later requests.

In the agent system

In one turn, vector search (search by meaning) returns chunk doc-17#3, a piece of a document. A grep tool (a text search tool) finds the same part of the file. A memory lookup pulls it in again. Without care, the context window (the text the model can see at one time) holds three copies. That wastes tokens and suggests to the model that the fact is extra important. The harness keeps an identity map for each turn. Its key is the source and chunk id, or the path and a content hash (a fingerprint of the content). It returns the object that is already loaded. The context builder then cites it as [2] instead of pasting it again. If a tool edits a file, the file is also updated in only one place.

In a large company

Enterprise harnesses limit the identity map to one request or session and to one tenant (one customer company on the platform). A map shared across the whole process and keyed only by document id would hand one tenant's document to another. The key should also include the source system and the access decision. Two users may be allowed to see different versions or redactions of the same document. Removing duplicate chunks this way also cuts token spending per tenant. It also makes citations in audit logs point to one stable source.

Step by step
  1. Search tools return keys such as doc-17#3.
  2. The harness looks up each key in the identity map.
  3. If the key is missing, the document is loaded once and stored in the map.
  4. If the key is found, the existing object and its citation number are reused.
Code sketch
class IdentityMap:
    def __init__(self): self._m = {}
    def get(self, key, load):
        if key not in self._m:
            self._m[key] = load(key)
        return self._m[key]

idmap = IdentityMap()          # per turn
for hit in rag_hits + grep_hits:
    doc = idmap.get(hit.key, store.load)
    ctx.cite(doc)      # same doc, same [n]

Be carefulLimit the map to one turn or one session. A map that lives forever across the whole program keeps serving old file contents after the file changes on disk.

4.10

Lazy Loading

Put short placeholders for tool schemas and file contents into context, and load the full version only when the agent asks.

tools/list (once, at connect)200 definitions, kept locallyrequest: search tool, no schemastool_search('deploy')deploy schema, after cached prefixtool_use deploy(env='prod')tools/call deploy199 schemas never entered the contextModelHarnessMCP server
Fig. 4.10Only the schema the model asks for ever enters the context
Classic meaning

A lazy-loaded object does not hold all its data yet, but it knows how to get it. It waits to load the data until something first uses it.

In the agent system

Say a harness is connected to 200 tools through MCP (a standard way to connect tools to an AI model). It does not paste every tool's JSON schema (the description of a tool's inputs) into the request. It calls tools/list once per server, which returns every tool's full definition. It keeps those definitions in a local registry (a lookup list in the harness). It sends the model only a tool search tool plus short descriptions of what is available. MCP has no way to fetch one tool's schema on its own. So the lazy step is putting a stored schema into context, not fetching it from the server. When the model searches for a tool, the harness loads that one full schema into context. Then the model can call it with valid arguments. The harness adds discovered schemas after the cached prefix, as tool search on the provider side does. That keeps the prompt cache (saved work the provider can reuse when a request starts the same way) valid. Editing the front of the tools list would break it from that point on. Files work the same way. Context holds a FileRef with the path, size and a summary. The content is read only when the model calls read_file.

In a large company

Enterprise tool catalogs can hold hundreds of connectors, so lazy loading is standard. But the search index must be filtered for each user before the model sees it. This uses RBAC or ABAC. RBAC is role-based access control, which gives permissions by job role. ABAC is attribute-based access control, which uses facts about the user and the request. Without this filter, tool names and descriptions reveal systems that the user cannot access. The harness must also check permission again when a found tool is actually called. Finding a tool is not the same as being allowed to use it. Gateways (services that sit in front of MCP servers) often keep the catalog and the allow lists for each tenant. A tenant is one customer company on the platform. Gateways also keep the audit of which schemas each session was shown.

Step by step
  1. When it connects, the harness calls tools/list and keeps every schema in a local registry.
  2. The request carries only a search tool and short descriptions, not the schemas.
  3. The model calls the search tool and names what it needs.
  4. The harness loads that one schema from the registry into context, after the cached prefix.
  5. The model calls the real tool with valid arguments, and the other schemas never enter the context.
Code sketch
class LazyTool:
    def __init__(self, name, blurb, reg):
        self.name, self.blurb = name, blurb
        self._reg, self._schema = reg, None
    @property
    def schema(self):
        if self._schema is None:  # first use
            self._schema = self._reg.load(
                self.name)
        return self._schema

# reg was filled once from tools/list
stub = {'name': t.name, 'desc': t.blurb}

Be carefulEach lazy load costs a round trip. A loop over lazy references turns into N separate fetches. Load ahead of time the few tools or files that almost every turn uses.

4.11

Flyweight

Share one unchangeable copy of the system prompt and tool schemas across thousands of sessions, keeping only each session's own state separate.

build oncecache hitrefBundle factoryintern by hashPromptBundlesystem + 40 toolsPrompt cachesame prefixSession 1ana, 12 msgsSession 2raj, 3 msgsSession 3li, 40 msgsSession 10,000own state only
Fig. 4.11Thousands of sessions point at one shared prompt bundle
Classic meaning

Flyweight saves memory when you have huge numbers of small objects. It splits the shared part (intrinsic state) from the part that differs per object (extrinsic state), and shares the first.

In the agent system

A hosted harness may run 10,000 sessions with the same 8,000-token system prompt and 40 tool schemas. A flyweight factory stores that bundle once, keyed by a content hash (a fingerprint of its contents). Every session holds a reference to one frozen PromptBundle that cannot change. Each session stores only its own messages, user id and settings. Sharing one object in memory does not by itself give cache hits. But every session turns that bundle into the same bytes. So requests also share a prompt-cache prefix at the API. The prompt cache is saved work the provider can reuse when a request starts the same way. This works as long as the requests fall within the cache's lifetime and scope. Details for each session, such as the user's name or today's date, go after the shared prefix. They are the extrinsic state.

In a large company

In an enterprise platform the shared bundle is usually per tenant or per policy profile, not global. A tenant is one customer company on the platform. Tenants add their own instructions, and RBAC (role-based access control: permissions given by job role) filters the tool set. So the bundle's key must include the tenant and the tools it is allowed. The main control is a review step for anything that enters the shared bundle. Prompts have version numbers and go through evals (automated tests) before rollout. They must contain no user or tenant data. Provider prompt caches are kept separate per organisation or workspace. A bundle cache in the harness is not, so its keys and contents need the same care.

Step by step
  1. When a session starts, the harness hashes the prompt version and the tool set.
  2. The factory returns the existing bundle for that hash, or builds it once.
  3. The session stores a reference plus its own messages and settings.
  4. Each request sends the shared prefix, then the session's own content.
Code sketch
_bundles: dict[str, PromptBundle] = {}

def bundle_for(version, tools):
    key = hash_of(version, tools)
    if key not in _bundles:
        _bundles[key] = PromptBundle.build(
            version, tools)       # frozen
    return _bundles[key]

s = Session(user, bundle=bundle_for(v, t))

Be carefulNever put details about a user into the shared bundle. A name leaked into the flyweight shows up in every session and breaks the cache prefix.

4.12

Write-Ahead Log

Direct fit

Write each tool call to a lasting log before running it, so a crashed run can restart exactly where it stopped.

append tool_use t7, fsyncrun t7: run testsprocess crashes mid-toolrestart: read log tailt7 has no tool_resultrerun t7 (idempotent)append tool_result t7Agent looptranscript.jsonlTool
Fig. 4.12The plan reaches the disk before the tool runs, so a crash leaves a readable trail
Classic meaning

Write every change into a log that you only add to, and save it safely to disk before you make the change. After a crash, you can rebuild the state by reading the log.

In the agent system

Before the harness runs a tool call, it adds a tool_use record to the session's JSONL transcript. JSONL is a file with one JSON record per line. Then it calls fsync, which forces the data onto the disk. When the tool returns, it adds the tool_result. If the process dies in the middle of a turn, the harness reads the end of the log on restart. A tool_use with no matching tool_result marks an interrupted call. The harness reruns the call if the tool is idempotent (safe to run twice with the same result). Otherwise it tells the model the call was interrupted. It never silently loses the call or runs it twice.

In a large company

Enterprise harnesses rarely trust a local fsync on a container's temporary disk. So they write the log to lasting storage that is copied to several places. Or they hand it to a durable execution engine such as Temporal or AWS Step Functions. These are services that run long tasks and record each step before and after it runs. The log is also the audit trail. So it holds tool inputs and outputs that may contain PII (personal data such as names and emails) or secrets. Those must be redacted and encrypted at rest with each tenant's keys. A tenant is one customer company on the platform. The log must also follow a set retention policy (rules for how long data is kept). Recovery should pass idempotency keys (unique ids that make a repeated request safe) to outside APIs. Then a rerun after a crash is safe by design.

Step by step
  1. The model sends a tool_use block, its request to run a tool.
  2. The harness adds it to the log and flushes it to disk.
  3. Only then does the tool run, and its result is added and flushed.
  4. On restart, the harness deals with any tool_use at the end with no matching result before the loop goes on.
Code sketch
def run_tool(call):
    wal.append({'type': 'tool_use',
                'id': call.id,
                'in': call.input})
    wal.fsync()              # durable first
    out = tools[call.name](**call.input)
    wal.append({'type': 'tool_result',
                'id': call.id, 'out': out})
    wal.fsync()

def recover():
    return [e for e in wal.tail()
            if unmatched(e)]

Be carefulFlushing to disk on every streamed token is far too slow. Log at message and tool boundaries. And remember that the log does not make a tool that is unsafe to repeat safe to rerun.

4.13

Event Sourcing

Direct fit

Store a session as events that are only ever added, and rebuild any view (model context, chat UI, a fork) by replaying them.

append eventevents 1..nprojectupto=12Agent loopEvent logappend-onlyReplayfold eventsModel contextafter compactionChat UIrendered turnsFork at #12subagent start
Fig. 4.13The log is the truth, and every view is rebuilt from it
Classic meaning

Save an object's state as the list of events that changed it. Work out the current state by replaying those events in order.

In the agent system

The harness stores events such as user_message, assistant_message, tool_use, tool_result, compaction and rewind. Compaction means replacing old messages with a summary. Rewind means going back to an earlier point. The harness never stores a current context that it edits. The context sent to the model is a projection (a view built from the events). To build it, the harness replays the events, applies compaction summaries and drops branches that were rewound. The chat UI is another projection, and a cost report is a third. Rewinding to a checkpoint, or forking a subagent (a helper agent) from turn 12, means replaying events up to event 12 into a new stream. Nothing in the original history is overwritten.

In a large company

Enterprise agent platforms often store sessions as events, because the event log is also the audit trail that regulators and security teams ask for. It is often streamed to Kafka (a system for moving streams of events) or something similar, for analytics and a SIEM (a security monitoring system). The hard conflict is with the right to erasure (a person's legal right to have their data deleted). The history is only ever added to, so events cannot simply be deleted. The usual answer is crypto-shredding. Personal content is encrypted with a key per person or per tenant (customer company), and the key is destroyed on request. The event structure stays in place. Projections and snapshots built from the log must then be rebuilt or wiped. Otherwise the erased data survives in a read view.

Step by step
  1. Every change is added as a new event, and nothing is edited in place.
  2. To build the next request, the harness folds the events into a list of messages.
  3. Other readers fold the same events into their own views.
  4. Rewind and fork replay the first part of the events into a new stream.
Code sketch
events = store.read(session_id)

def project_context(events, upto=None):
    msgs = []
    for e in events[:upto]:
        if e.type == 'message':
            msgs.append(e.msg)
        elif e.type == 'compaction':
            msgs = [e.summary]
        elif e.type == 'rewind':
            msgs = msgs[:e.to]
    return msgs

Be carefulReplaying thousands of events every turn gets slow. Save snapshots of the built context from time to time, and replay only the events after the latest snapshot.

4.14

Dirty Flag

Direct fit

Mark a memory or summary as changed when its source changes, and re-embed or re-summarise only when that mark is set.

set flagcheckread flagyesnoclear flagNote editedfile watcherMemoryNotedirty = TrueNext turndirty?Embeddingmodelre-embedStored vectorreuse, 0 calls
Fig. 4.14Only changed notes pay for a new embedding
Classic meaning

Use a flag to track whether worked-out data is out of date. Set the flag when the source changes. Recompute only when the flag is set and the result is needed.

In the agent system

Re-embedding a memory file (turning its text into numbers for search again) costs model calls. So does making a new conversation summary. The harness keeps a dirty flag, or a saved content hash (a fingerprint of the content), for each derived item. A derived item is one built from other data. When someone edits a memory note, the file watcher sets embedding_dirty. When new messages pass the summary point, it sets summary_dirty. Before each turn, a refresh step recomputes only the items whose flag is set, then clears the flag. A quiet session with no new content costs nothing extra. The cached prompt prefix also stays byte-identical, so the provider can keep reusing it.

In a large company

Enterprise platforms use dirty flags or content hashes to control spending on embeddings and summaries. That spending shows up directly in cost dashboards for each tenant (customer company) and in chargeback (billing each team for its own use). The trap specific to enterprises is deletion. When a memory or document is deleted for retention or erasure reasons, the derived embedding, summaries and caches must be wiped. Marking them for recompute is not enough. A summary that already includes the deleted fact has to be rebuilt from the remaining sources. So it is worth recording lineage, meaning which sources each derived item came from.

Step by step
  1. A write to the source sets the flag.
  2. Before the derived value is needed, the harness checks the flag.
  3. If it is set, the harness recomputes the embedding or summary and clears the flag.
  4. If it is clear, the harness reuses the saved result with no model call.
Code sketch
class MemoryNote:
    def __init__(self, text):
        self.text, self.vec = text, None
        self.dirty = True
    def edit(self, text):
        self.text, self.dirty = text, True
    def vector(self):
        if self.dirty:        # changed?
            self.vec = embed(self.text)
            self.dirty = False
        return self.vec

Be carefulA missed flag is worse than a wasted recompute. If any write path forgets to set it, the agent works from an old summary. When writes come from many places, use content hashes instead.

4.15

Serialized Entity

Direct fit

Save a whole live object, such as an agent checkpoint, as one blob of data that can be stored, sent and restored elsewhere.

serialiseputgetdeserialiseWorker Apaused forapprovalCheckpointmsgs, todos, stateBlob storeckpt/s42.json.gzVersion checkmigrate v2 to v3Worker Bresumes loop
Fig. 4.15The whole run folds into one blob and unfolds on another worker
Classic meaning

Serialise (turn into text or bytes) a group of connected objects, for example as JSON, so it can be stored or sent. Later, deserialise it back into equal objects.

In the agent system

Sometimes a long-running agent pauses to wait for approval. Sometimes a scheduled task hands work to a fresh worker (another machine or process). In both cases the harness serialises a Checkpoint. It holds the messages, todo list, tool state, the waiting tool call, model settings and a schema version number. The blob (one block of stored data) goes to object storage (a cloud service for files) or into a queue message. Any worker can deserialise it and carry on the loop. So a run can wait hours for a human without keeping a live process. Moving a session between machines is just a file copy.

In a large company

Lasting checkpoints are how enterprise agents survive long approval waits, deploys and machine failures. They are often kept in object storage, encrypted by the storage service with the tenant's own KMS key, in the tenant's home region. A tenant is one customer company on the platform, and a KMS is a key management service. Checkpoints must never hold live credentials or OAuth tokens (login tokens for other services). Tool state should hold references instead. The worker that resumes swaps them for fresh credentials through workload identity (the platform proving which service is asking). Sometimes a checkpoint resumes a waiting tool call after a human approved it. Then the approval should be tied to a hash (fingerprint) of that exact call, and the blob should be signed. That way a changed checkpoint cannot run something nobody approved.

Step by step
  1. At a pause point the harness builds a Checkpoint from the live state.
  2. It serialises the Checkpoint with a version number and compresses it.
  3. It stores the blob under the session's key.
  4. A later worker loads it, upgrades old versions and resumes the loop.
Code sketch
@dataclass
class Checkpoint:
    v: int
    messages: list
    todos: list
    pending_call: dict | None

blob = gzip(json.dumps(asdict(ckpt)))
blobs.put(f'ckpt/{sid}', blob)
# later, on any worker:
d = json.loads(gunzip(blobs.get(key)))
ckpt = Checkpoint(**migrate(d))

Be carefulNever unpickle checkpoints from sources you do not trust. Unpickling (loading Python's pickle format) can run hidden code. Keep a version field and upgrade steps, because yesterday's blob must still load in today's code.

4.16

Serialized LOB

Store a transcript or artifact body as one packed blob column, in a row whose other columns stay small and searchable.

ONE ROW IN SESSIONSextractserialiseSELECT titledeserialiseSessionmessages + metaIndex columnsid, user, tokensbody LOBgzip(JSON)Session listreads labels onlyOpen sessionloads the body
Fig. 4.16Small columns are the label, and the transcript travels sealed in one field
Classic meaning

Save a group of objects by serialising (packing) them into one large object, a LOB, in a single database field. Ordinary columns sit next to it.

In the agent system

The sessions table has normal columns: id, user_id, title, model, updated_at and token_total. It also has a body column. That column holds the compressed JSON of every message and content block. Listing a user's recent sessions, or adding up tokens per day, reads only the small columns. The harness reads the full blob only when someone opens the session. It never needs a separate table for each kind of content block (text, tool_use, image, citation).

In a large company

Storing transcripts as compressed blobs is common in enterprise harnesses, but it makes data controls harder. DLP scanners (data loss prevention tools) and e-discovery tools (used to find records for court cases) cannot see inside a gzip column. Erasing one message means rewriting the whole blob. So teams usually keep the data class, retention class and legal-hold flags as real columns next to the blob. They encrypt the blob with a tenant key. A tenant is one customer company on the platform. They also remove sensitive data before packing it, not after. Large blobs are often moved to object storage (a cloud file service), with only a pointer in the row. That also lets lifecycle rules (automatic storage rules) enforce retention.

Step by step
  1. On save, the harness copies the fields used for lookups into their own columns.
  2. It turns the message list into JSON, compresses it and writes it to the body column.
  3. List views read only the small columns.
  4. Opening a session reads the body and unpacks it.
Code sketch
db.execute(
    'INSERT INTO sessions(id, user_id,'
    ' title, tokens, updated, body)'
    ' VALUES (?,?,?,?,?,?)',
    (s.id, s.user, s.title, s.tokens,
     now(), gzip(json.dumps(s.messages))))

recent = db.query(
    'SELECT id, title FROM sessions'
    ' WHERE user_id=?'
    ' ORDER BY updated DESC LIMIT 20',
    (uid,))

Be carefulYou cannot search inside the blob efficiently, for example for every session that called the deploy tool. Copy any field you filter on into its own column or a side index.

4.17

Metadata Mapping

Describe how memory records map to store fields in one table of settings, and let shared code do every load and save.

rulesobjectupsertinsertgenerateMEMORY_MAPfield -> storepathMemorytext, user, tsGeneric save()walks the mapStore schemageneratedVector storevector + metaSQL tablesame engine
Fig. 4.17One generic engine follows a declared map instead of code for each type
Classic meaning

Keep the details of how objects map to storage as data (metadata). Then shared, generic code reads that data to load and save the objects.

In the agent system

A harness may keep memories, artifacts and scheduled tasks in a vector store (a database that finds text by meaning) and in a SQL table. It does not write a separate mapper by hand for each type. Instead, each record class declares a mapping. For example, the text field goes to meta.body. user_id goes to meta.uid as a scope key (a field that limits whose data you can see). The vector is computed from the text by the embedder. One generic save() and one load() follow that map. Adding a field means adding a line. The same metadata can also generate the store's schema, and the optional filters that a memory search tool offers the model. Scope keys are different. The harness fills them from the caller's identity and never lets the model set them.

In a large company

Enterprise teams add data governance tags to the mapping metadata. Each field gets a PII class (what kind of personal data it is) and a retention period. It also gets a residency rule (where the data must be stored) and an encryption requirement. Then generic code can redact, encrypt and delete data in the same way for every record type. The same metadata can feed a data catalog and the records of processing that GDPR (the EU data protection law) expects. Scope fields like tenant_id and user_id must be enforced by the harness and by the store. A tenant is one customer company on the platform. For example, use Postgres row-level security (database rules that limit which rows each user sees). Never rely on whatever filters a caller or the model supplies.

Step by step
  1. Each record type declares its field mapping as data.
  2. Generic save() follows the mapping, converting and placing each field.
  3. Generic load() follows it backwards to rebuild the object.
  4. Schemas and search filters are generated from the same mapping.
Code sketch
MEMORY_MAP = [
    Field('id',   to='id'),
    Field('text', to='meta.body'),
    Field('user', to='meta.uid', scope=True),
    Field('text', to='vector', via=embed),
]

def save(obj, mapping, store):
    rec = {}
    for f in mapping:
        val = f.convert(getattr(obj, f.name))
        put(rec, f.to, val)
    store.upsert(rec)

Be carefulMetadata hides behaviour. When a field quietly stops being saved, the bug is in a table nobody reads as code. So check the mapping when the program starts.

4.18

Single Table Inheritance

Direct fit

Save every kind of transcript event in one table with a kind column, leaving empty the fields a kind does not use.

INSERTone SELECTUserMsgkind=user, textToolUsekind=tool_useCompactionkind=compactevents tablekind, text, tool_name,input, summaryLoaderclass from kind
Fig. 4.18Every event kind lands in one table, told apart by its kind column
Classic meaning

Store a family of related classes in one table. The table has columns for the fields of every class, plus a column (the discriminator) that names each row's class.

In the agent system

A transcript holds many kinds of entry: UserMessage, AssistantMessage, ToolUse, ToolResult, Compaction and PermissionDecision. With single table inheritance, they all live in one events table. It has session_id, seq (the position in order) and kind. It also has columns that may be empty (NULL): text, tool_name, tool_input, is_error and summary. Loading a session is one SELECT, ordered by (session_id, seq). The loader picks the class from the kind column. Queries across every kind need no joins (combining tables). An example is everything in session 42 after position 300.

In a large company

A single events table is a common enterprise choice. It gives one ordered audit stream per session that is only ever added to. It is easy to index by tenant_id and seq, and to protect with row-level security (database rules that limit which rows each user sees). A tenant is one customer company on the platform. The kind column also makes rules per kind easy. Permission decisions can be kept for years for audit, while message text follows a shorter retention schedule. The mostly empty columns that hold tool inputs and message text are the ones to encrypt or tokenise (replace with harmless stand-in values). They carry most of the PII (personal data such as names and emails).

Step by step
  1. Each event kind writes into the same table, setting kind and its own columns.
  2. Columns used by other kinds are left NULL (empty).
  3. Loading a session is a single ordered query.
  4. The loader maps each row's kind to the right class.
Code sketch
ROW_CLASS = {'user': UserMsg,
             'tool_use': ToolUse,
             'tool_result': ToolResult,
             'compaction': Compaction}

rows = db.query(
    'SELECT * FROM events WHERE sid=?'
    ' ORDER BY seq', (sid,))
events = [ROW_CLASS[r['kind']].from_row(r)
          for r in rows]

Be carefulEvery new kind adds mostly empty columns. The table cannot easily require tool_name only when kind is tool_use. Add CHECK constraints (database rules for each row), or switch to table inheritance when the kinds differ a lot.

4.19

Table Inheritance

Direct fit

Keep fields shared by all events in a base table, and each kind's own fields in a separate table linked by event id.

idididSUM(tokens)joinjoinTool analyticsevents JOINtool_callseventsid, sid, seq,tokensCost reportbase table onlytool_callsid, tool,exit_codeapprovalsid, decisioncompactionsid, summary
Fig. 4.19Shared fields live once, and each kind adds its own table on the same id
Classic meaning

Store a family of related classes with one table per class. Each subclass table holds only its own fields and shares the base table's primary key (the id that names each row).

In the agent system

All agent events share id, session_id, seq (position in order), created_at and tokens. These live in a base events table. Each kind of event gets its own table with the same id. tool_calls has (id, tool, input, duration_ms, exit_code). approvals has (id, decision, reviewer). compactions has (id, summary, dropped_tokens). Timeline and cost queries read only the base table. Tool analytics join (combine) events with tool_calls. That table can have NOT NULL constraints (rules that a field must be filled) and an index on tool for fast lookups.

In a large company

Enterprise teams often use this layout, also called class table inheritance, for the analytics and audit side of an agent platform. There, approvals, tool calls and compactions need their own rules, indexes and access controls. Separate tables let a security team give auditors read access to approvals without showing message text. Retention jobs can also delete content tables while keeping the base event skeleton. The cost is reads that need many joins. So the busy resume path usually stays on a denormalised log (one flat table with everything together). These tables are then filled from that log.

Step by step
  1. Writing an event adds a base row, plus a row with the same id in its kind's table.
  2. Queries across all kinds, such as cost per session, read only the base table.
  3. Queries about one kind join the base table to that kind's table.
  4. Each kind's table enforces its own rules.
Code sketch
class Event(Base):        # table: events
    id: int; sid: str; seq: int; tokens: int
class ToolCall(Event):    # table: tool_calls
    tool: str; input: dict; exit_code: int
class Approval(Event):    # table: approvals
    decision: str; reviewer: str

slow = db.query(
    'SELECT e.sid, t.tool FROM events e'
    ' JOIN tool_calls t USING (id)'
    ' WHERE t.duration_ms > 30000')

Be carefulLoading a full mixed transcript needs a join, or one query per kind. So this layout suits audit and analytics better than the busy path of resuming a session.

4.20

Optimistic Offline Lock

Direct fit

Let the agent and a person edit one document without locks, and check at save time whether someone changed it first.

read doc (v7)plan edits, 60 ssave(base=v7): ok, now v8save(base=v7)conflict: current is v8merge own edit onto v8save(base=v8): ok, now v9AgentDoc storeUser
Fig. 4.20No one waits, and the late writer finds out at save time and merges
Classic meaning

Avoid clashes between people working at the same time by checking for a conflict when saving. A stale write is rejected, instead of locking the data while people work.

In the agent system

An agent reads an artifact, such as a doc, a spec or a config file, at the start of a long turn. It spends a minute planning edits, while the user types in the same doc. When the agent saves, the harness sends the version number it read. The store compares it with the current version. If the version has moved on, the store rejects the write. The harness then fetches the newer content. It has the model redo its edit on top of it. Or it runs a three-way merge, which combines both sets of changes using the original as a guide. Then it saves again. Nothing was locked while the model was thinking, so the person was never blocked.

In a large company

In enterprise collaboration tools, an agent that edits shared documents, tickets or configuration must use that system's own conflict checks. It must not just overwrite. On HTTP APIs this means ETags (version tags on a resource) sent back with an If-Match header. A merge may combine an agent's edit with a human's. Then the result should be shown for review, or recorded with both authors in the audit trail. Silent agent merges over human edits are a risk for change management (the process for controlling changes). Repeated conflicts on the same item are a sign to send the work through a clear handoff or approval step.

Step by step
  1. The agent reads the document and remembers its version.
  2. It works on its edits without holding a lock.
  3. It saves with expected_version, and the store rejects the save if the version has moved.
  4. On a conflict, it fetches the latest version, merges its change and saves again.
Code sketch
doc = store.get(doc_id)       # text + version
new = agent.edit(doc.text)
try:
    store.save(doc_id, new,
               expected=doc.version)
except VersionConflict as c:
    merged = merge3(doc.text, new,
                    c.current.text)
    store.save(doc_id, merged,
               expected=c.current.version)

Be carefulThis optimistic approach suits conflicts that are rare. If the agent and a person clash on most saves, split the document or hand over editing on purpose. Do not keep merging again and again.

4.21

Version Number

Direct fit

Stamp every stored artifact, memory and session with a counter that goes up on each write, so stale reads and writes can be caught.

readreadCAS 4 -> 5CAS 4: 0 rowsstale: rebuildartifact rowversion = 4Agentholds v4Userholds v4artifact rowversion = 5Summarybuilt from v4
Fig. 4.21Both writers read v4, and the counter lets only the first one win
Classic meaning

Store a version number on each record and raise it with every update. Use it to spot old data and to do optimistic locking (checking for conflicts at save time instead of locking).

In the agent system

Every artifact, memory file and session row carries a whole-number version. Writes are compare-and-set, which means they only apply if the value is still what you expect. The SQL looks like this: UPDATE ... SET body = ?, version = version + 1 WHERE id = ? AND version = ?. If zero rows are updated, someone else wrote first. The number also tags derived data, meaning data built from the record. An embedding, a summary or a cached render records which version it was built from. So the harness knows when to rebuild it. The UI can also tell the user the doc changed since the agent last read it.

In a large company

Enterprise APIs show version numbers as ETags (version tags on a resource) or resource versions. Agents that write to them should send If-Match, or the same kind of compare-and-set. Then edits made by a human and an agent at the same time are caught. Record in the audit log which version the agent read and which it wrote. Then you can rebuild exactly what the agent saw when it acted. The same numbers let derived data such as embeddings and summaries be marked out of date exactly when the source changes or is deleted.

Step by step
  1. Every reader gets the version together with the content.
  2. Every writer sends the version it read along with its update.
  3. The database applies the update only if the version still matches, then raises it by one.
  4. Zero rows updated tells the writer its copy is out of date.
Code sketch
def save(art, body):
    n = db.execute(
        'UPDATE artifacts SET body=?,'
        ' version=version+1'
        ' WHERE id=? AND version=?',
        (body, art.id, art.version))
    if n == 0:
        raise StaleVersion(art.id)
    art.version += 1

Be carefulTimestamps are a poor substitute. Two writes in the same millisecond, or clocks that differ between workers, defeat them. Use a whole number that the database raises.

4.22

Sharding

Split session and memory stores by tenant, so each shard holds one slice of the data and each turn touches one shard.

EU REGIONUS REGIONroutelookupacmeTurn requesttenant = acmeTenantdirectoryacme: eu, no pinShard routerregion, then ringShard eu-1Shard eu-2acme, ...Shard us-1Shard us-2
Fig. 4.22The tenant's region limits the choice, and the hash only picks a shard inside it
Classic meaning

Split the rows of your data across several databases, using a shard key to decide where each row goes. Each database (a shard) stores and serves only its part of the rows.

In the agent system

A hosted harness with millions of users keeps transcripts, memories and vector indexes (search indexes by meaning) in many shards. A tenant is one customer company on the platform. First, the router looks up the tenant's home region in a tenant directory (a lookup table about each tenant). It also checks whether the tenant is pinned to a fixed shard. Then it picks a shard within that region using consistent hashing of tenant_id. Consistent hashing is a way of spreading keys so that adding a shard moves only a few of them. A turn reads and writes one shard's transcript table and one tenant's vector namespace (its own section of the index). That limits index size and stops one busy tenant from slowing everyone. Hashing alone cannot keep data in a region, which is why the region comes first. Tenants that share a shard are separated only logically, not physically. So tenant filters or row-level security (database rules that limit which rows each user sees) still run on every query. Only a dedicated shard gives physical separation.

In a large company

Enterprise agent platforms usually combine regional or cell-based deployment with sharding inside each region. A cell is a self-contained copy of the whole system. A tenant directory records each tenant's home region and any dedicated cell. A hash is used only inside that boundary. Regulated or very large customers often get a dedicated shard or cell with its own encryption keys. That is the only setup that truly gives physical separation. Shared shards still need tenant_id on every row. They also need enforcement such as row-level security or a vector namespace for each tenant. Admin queries across shards must be audited, because they get around the separation the design depends on.

Step by step
  1. A turn request arrives with the tenant id of the logged-in user.
  2. The router looks up the tenant's region, and any pinned shard, in the tenant directory.
  3. If there is no pinned shard, it hashes the tenant id onto the shard ring for that region only.
  4. Transcript, memory and vector reads and writes stay in that shard, filtered by tenant.
Code sketch
RINGS = {'eu': ring([db_eu1, db_eu2]),
         'us': ring([db_us1, db_us2])}

def shard_for(tenant):
    t = directory.get(tenant)  # region, pin
    if t.pinned:               # big tenants
        return t.pinned
    return RINGS[t.region].node(tenant)

db = shard_for(req.tenant_id)
mems = db.memories.search(
    q, tenant=req.tenant_id, user=req.user)

Be carefulPlain modulo hashing (hash number divided by shard count) moves almost every tenant when you add a shard. Use consistent hashing or a directory. Keep queries that reach out to every shard off the busy path.

4.23

Client Session

Direct fit

Keep the conversation on the client, which sends the full message history with every request, so the server stores nothing between calls.

POST messages[1..3]assistant message 4append to local historyPOST messages[1..5]assistant message 6replicas keep nothing between callsChat clientReplica AReplica B
Fig. 4.23The client carries the whole conversation, so any replica can answer
Classic meaning

Keep session state on the client and send it to the server with every request. The server keeps no data for each session.

In the agent system

This is how a plain messages API works. The client keeps the list of messages. On every turn it sends all of it, with the system prompt and tool definitions. Any server replica (any copy of the server) can answer any request. Nothing needs to expire. The client can freely edit, shorten or branch its history. There are costs too. First, the request gets bigger every turn. Prompt caching (the provider reusing saved work when a request starts the same way) cuts the token price, but not the bytes sent. Second, there is trust: a client can rewrite history, including tool results. Third, the history is lost if the client crashes before saving.

In a large company

Enterprises often prefer stateless calls to the model provider, where the provider keeps nothing between calls. Sometimes this is under zero data retention terms (the provider stores no data). They keep the official history in their own harness, not in the browser. History kept on a client falls outside server-side retention, erasure and DLP (data loss prevention) controls. So storing transcripts locally on company devices needs its own policy. Fowler's classic fix, signing or encrypting the state the client holds, still applies. But the safer enterprise rule is to always check approvals and tool results again against the server's records.

Step by step
  1. The client adds the user's message to its local history.
  2. It sends the whole history, the system prompt and the tools in one request.
  3. Whichever replica receives it calls the model and returns the reply.
  4. The client adds the reply, and the server forgets the exchange.
Code sketch
history = []        # lives in the client

def send(text):
    history.append({'role': 'user',
                    'content': text})
    r = client.messages.create(
        model=MODEL, system=SYSTEM,
        tools=TOOLS, messages=history)
    history.append({'role': 'assistant',
                    'content': r.content})
    return r

Be carefulNever base security decisions on history the client holds. A client can fake earlier tool_result blocks (tool outputs) or approvals. So check permissions again on the server.

4.24

Server Session

Direct fit

Keep the conversation, sandbox and files on the server under a session id, and the client sends only the id and new message.

sid + msgload / saveresumeturnChat clientholds sid onlyPhonesame sid, newdeviceSession APIowner checkSession storesid -> transcriptSandboxfiles, cwdModelfull context
Fig. 4.24The client holds a ticket, and the server holds the conversation
Classic meaning

Keep session state on the server. A session id names it, and the client shows that id with each request.

In the agent system

Hosted agent services keep the transcript, tool state, sandbox container and files on the server, under a session_id. A sandbox container is a locked-down space where tools run. The client sends {session_id, message} and streams back events. Requests stay small. The server is the authority on what really happened, so tool results and approvals cannot be faked. The same session can continue on another device. In return, the server must store, expire, shard (split across databases) and protect sessions. It must also send each request to wherever that session's state lives.

In a large company

Most enterprise agent platforms keep sessions on the server. Then retention, legal hold, erasure, audit export and data residency (where data is stored) are all enforced in one place the company controls. Each request is checked against the logged-in identity from SSO (single sign-on) or OIDC (a standard login protocol). The check covers the tenant (customer company) and the RBAC role, as well as the owner. RBAC is role-based access control: permissions given by job role. Admins, shared sessions and support access all need clear rules that are audited. Session stores should be encrypted with tenant keys and kept in the tenant's region. They should expire on a written schedule, not be kept forever.

Step by step
  1. The client sends a new message with its session id.
  2. The server checks that the caller owns the session, then loads it.
  3. It runs the turn with the full context and sandbox held on the server.
  4. It saves the session and streams the reply back to the client.
Code sketch
@app.post('/sessions/{sid}/messages')
def post_message(sid, body, user):
    s = sessions.load(sid)
    if s.owner != user.id:
        raise Forbidden()
    s.messages.append(user_msg(body.text))
    reply = run_turn(s)     # server-side
    sessions.save(s)
    return stream(reply)

Be carefulServer sessions need expiry, size limits and an owner check on every request. If a session id can be guessed, a whole transcript can leak.

05
Chapter 5 · Many agents, one goal

Multi-Agent Orchestration

One agent eventually runs out of room or time. Several agents can split the work and run side by side, but then they must coordinate, pass messages and be tracked. These patterns come from large distributed systems, where those problems were solved first.

5.01

Actor Model

Run each subagent as a sealed-off actor that keeps its own context and only reacts to messages in its mailbox.

ACTOR AACTOR Bsend(task)send(task)dequeuedequeueownsownsreplyreplynext msgOrchestratoralso an actorOrc mailboxrepliesMailbox AResearcherone msg at a timePrivatecontextMailbox BCoderone msg at a timePrivatecontext
Fig. 5.01Subagents share nothing. Work and results move only as messages between mailboxes.
Classic meaning

Actors are units of work that run at the same time. Each one keeps its own private state, talks to others only by sending messages without waiting, and handles one message at a time from its mailbox.

In the agent system

Each subagent (a helper agent started by the main agent) has its own context window (the text the model can see at one time). It also has its own system prompt and its own set of tools. The orchestrator (the agent that hands out and tracks the work) cannot reach into that context. Work arrives as a message, a task brief, placed in the subagent's inbox. The subagent's loop takes one message at a time. So its transcript never mixes two tasks together. Results travel back the same way, as a message to the orchestrator's own inbox. Nothing is shared, so a supervisor can restart a crashed subagent without damaging anyone else's state. But the message it was working on survives only if the mailbox is durable (saved so it survives a crash). The mailbox must also remove the message only after the reply has been sent.

In a large company

Large companies run long-lived agents as actors on a durable runtime, so mailboxes survive restarts. Examples are Temporal workflows (a system that saves each step so work can resume after a crash), Akka or Orleans. Another option is to give each agent its own Kafka or SQS queue (message services that store messages until they are read). Each actor carries its own tenant id (which customer company it works for). It also carries scoped credentials from workload identity (login details issued to the program itself, limited to what it needs) and its own token budget. This makes isolation and chargeback (billing each team for its own use) easy. The control to add is supervision. A parent watches its child actors and restarts, escalates or stops the ones that fail. It records each decision in the audit trail.

Step by step
  1. The orchestrator sends a task message to the subagent's mailbox.
  2. The subagent takes one message and runs its loop inside its private context.
  3. It may send messages to other actors, including subagents it starts.
  4. It posts a result message to the orchestrator's mailbox and takes the next message.
Code sketch
class SubagentActor:
    def __init__(self, prompt):
        self.inbox = asyncio.Queue()
        self.ctx = [system(prompt)]  # private
    async def run(self):
        while True:
            m = await self.inbox.get()
            out = await turn(self.ctx, m.body)
            await m.reply_to.put(out)

coder.inbox.put_nowait(Msg(task, orc.inbox))

Be carefulActors that stop and wait for each other's replies can deadlock (get stuck forever, each waiting on the other). So give every request a timeout, and avoid requests that go round in a circle between subagents.

5.02

Mediator

Direct fit

Send all subagent coordination through one orchestrator, so specialists never need to know about or message each other.

no direct chatfindingsbriefrun testsreview diffupdateResearcherCoderOrchestratordecides who's nextTesterReviewerShared plansteps + status
Fig. 5.02Every subagent talks to the orchestrator, and none of them talk to each other.
Classic meaning

A mediator is one object that holds the rules for how a group of objects work together. Each object talks to the mediator instead of talking to the others directly.

In the agent system

In an orchestrator and subagent design, there is a researcher, a coder, a tester and a reviewer. Each is a subagent (a helper agent with its own job). Each one reports only to the orchestrator (the agent in charge of the plan). The orchestrator holds the plan and decides who runs next. It turns one subagent's output into the next one's task brief. It trims the brief down to what that subagent needs in its context (the text the model can see). If subagents chatted directly, the number of message paths would grow fast. Context would repeat in every exchange, and the run would be hard to follow. To add a new specialist, you teach the orchestrator one more route. The existing subagents stay the same.

In a large company

In a company platform the orchestrator is the natural mediator. It is also the natural place to enforce rules. It checks each hand-off with the policy engine, such as OPA or Cedar (tools that answer 'is this allowed?'). It removes data that the next subagent is not cleared to see. It writes every routing decision to the audit trail. Subagents get only scoped, short-lived credentials (login details limited to their own tools that expire soon). The risk is that the mediator holds a lot of power and is a single point of failure. So give it its own SLO (a reliability target) and keep its state in a durable store (one that survives crashes). Treat changes to its routing rules as reviewed releases that must pass evals (automated quality tests) first.

Step by step
  1. A subagent finishes and returns its result to the orchestrator.
  2. The orchestrator records the result in the shared plan.
  3. It decides which specialist acts next and writes a focused brief for it.
  4. That subagent runs with only the brief and reports back the same way.
Code sketch
class Orchestrator:
    def __init__(self, agents, plan):
        self.agents, self.plan = agents, plan
    def notify(self, sender, result):
        self.plan.record(sender, result)
        nxt = self.plan.next_step()
        if nxt:
            brief = self.brief_for(nxt, result)
            self.agents[nxt.role].send(brief)

Be carefulA mediator that keeps every subagent's full output in its own context becomes a token bottleneck (it fills up and slows everything down). Store the raw output in files and keep only short summaries in the plan.

5.03

Master-Worker

A master agent splits a big job into separate tasks, gives them to worker subagents, and collects or reassigns the results.

assignassignassignresultresultrequeue CMaster agenttask tableWorker 1batch AWorker 2batch BWorker 3batch C: timed outResult tablemaster merges
Fig. 5.03The master owns the task table, so a lost task simply goes back to pending.
Classic meaning

A master process splits work into tasks and hands them to a pool of workers. It gathers their results and gives a failed worker's task to another worker.

In the agent system

Picture a migration agent that must update 300 files. It acts as master. It lists the files and groups them into batches. It starts one worker subagent (a helper agent) per batch, each with a brief and a token budget (a limit on how much text it may use). The master tracks each task's state (pending, running or done) in a task table. Workers send back a short result message, not their full transcripts. This keeps the master's context (the text its model can see) small. Sometimes a worker runs out of time, or its result fails a check. Then the master marks the batch pending again and gives it to the next free worker.

In a large company

Company teams usually run the master as a durable workflow, using Temporal or AWS Step Functions (systems that save each step so work survives a crash). Workers run as activities (single steps of that workflow). So the task table survives a crash. Retries, timeouts and heartbeats (regular 'I am still alive' signals) are set in configuration instead of written by hand. Limits on how many workers run at once are set per tenant (per customer company). The LLM gateway (one shared front door to all AI models) enforces them again. So one large job cannot use up shared rate limits. Each worker run is tagged with the job id and cost centre for chargeback (billing each team for its use). Batches that still fail after their retry limit go to a review queue instead of looping forever.

Step by step
  1. The master splits the job into separate tasks and records them as pending.
  2. It starts workers up to a limit on how many run at once, one task each.
  3. Each worker runs in its own context and returns a short result.
  4. The master marks tasks done, puts failed or timed-out ones back, and stops when the table is complete.
Code sketch
state = {b.id: 'pending' for b in batches}
tries = Counter()
while not finished_all(state):
    free = MAX_WORKERS - running(state)
    for b in pending(state)[:free]:
        state[b.id] = 'running'
        spawn('migrator', b, timeout=600)
    for r in await next_results():  # incl.
        tries[r.id] += 1           # timeouts
        if r.ok and verify(r):
            state[r.id] = 'done'
        elif tries[r.id] >= 3:
            state[r.id] = 'failed'
        else: state[r.id] = 'pending'

Be carefulMaster-worker suits tasks that are truly independent and safe to run again. So run each worker in its own git branch or worktree (a separate copy of the files). Then a batch that is handed out again starts clean, and two workers never edit the same files.

5.04

Fork/Join

Direct fit

Split a task into parts for parallel subagents, maybe splitting again, and continue only when every child has finished.

fork / joinfork / joinwaits for bothRepo auditorwhole monorepopkg/apiforks againpkg/webforks againapi/routesleaf: runsapi/dbleaf: runsweb/uileaf: runsweb/buildleaf: runs
Fig. 5.04Each parent forks its children and continues only after joining all of them.
Classic meaning

You split a task into smaller parts that run at the same time (fork). Each part may split again. Then you wait for all of them and combine their results (join).

In the agent system

Say an agent is asked to audit a monorepo (one large code repository holding many packages). It forks one subagent (helper agent) per top-level package. A subagent facing a large package may fork again, one per directory. Each parent then waits at the join. It waits for all its children before summarising their findings and returning to its own parent. The harness sets a maximum depth and a total budget of subagents. This matters because forking again and again multiplies token spend quickly. The join is a barrier (a point that everyone must reach before anyone moves on). So the slowest child sets the pace for its parent.

In a large company

In production, platform policy limits the forking. It sets a maximum depth, a maximum number of children per parent, and a token budget per run. The harness and the LLM gateway (one shared front door to all AI models) enforce these limits. When a limit is passed, the run stops cleanly. Durable execution engines such as Temporal (systems that save each step so work survives a crash) treat the join as child workflows. So a crashed parent can resume without forking finished children again. In OpenTelemetry (a standard for recording what software does), each child span (a timed record of one piece of work) is linked to its parent. This shows the cost and time of every branch. That supports chargeback (billing each team for its use) and SLO reports (reports on reliability targets).

Step by step
  1. The parent agent checks whether the task is small enough to do directly.
  2. If not, it splits the task and forks a child subagent for each part.
  3. Children run in parallel and may fork further, within the depth limit.
  4. Each parent joins all its children, merges their results and returns upward.
Code sketch
async def audit(path, depth=0):
    parts = split(path)
    if depth >= MAX_DEPTH or len(parts) < 2:
        return await run_agent('auditor', path)
    kids = [spawn(audit, p, depth + 1)
            for p in parts]  # fork
    res = await asyncio.gather(*kids)  # join
    return merge(res)

Be carefulOne stuck child holds up its whole branch at the join. So give every forked subagent a timeout. Decide in advance whether partial results are acceptable.

5.05

Fan-Out/Fan-In

Send the same question to several subagents at once, then gather their answers in one place, usually by a deadline.

same querysame querysame queryreplyreplylate: missingcutoffOrchestratorone questionWeb subagentCode subagentDocs subagentvia MCPDeadline60 sCollectorall, or deadline
Fig. 5.05One question goes out to many. One bundle comes back when all reply or the clock runs out.
Classic meaning

A sender sends one request to many workers at the same time (fan-out). A collector then combines their answers into one result (fan-in).

In the agent system

Take a research question. The orchestrator (the agent in charge) sends the same query to three subagents (helper agents) in one turn. One searches the web, one searches code, and one reads docs through an MCP server (MCP is a standard way to connect tools to an AI model). Fan-in happens at a collector. It waits for every reply or for a deadline, whichever comes first. Late or failed replies are marked as missing. So the orchestrator knows its answer rests on partial evidence. The collected replies then enter the orchestrator's context as one tool_result (the block that carries a tool's output back to the model). This is better than three separate interruptions.

In a large company

This is the Scatter-Gather pattern from the book Enterprise Integration Patterns. Companies use it for research and search across sources that are governed separately. Examples are a document store, a ticketing system and the code host. Each branch must run with the requesting user's own permissions. These pass along through OIDC token exchange (a standard way to swap a user's login token for one that a downstream service accepts). So the gathered bundle never holds documents that the user could not open. Deadlines are tied to the SLO (reliability target) that the user sees. Token and time measurements for each branch feed cost dashboards. Then low-value branches can be dropped.

Step by step
  1. The orchestrator sends the same request to several subagents at once.
  2. Each subagent works on its own with its own tools.
  3. A collector gathers replies until all arrive or the deadline passes.
  4. The orchestrator receives one combined bundle, with missing replies marked.
Code sketch
jobs = {a: create_task(spawn(a, task=q))
        for a in ('web', 'code', 'docs')}
done, late = await asyncio.wait(
    jobs.values(), timeout=60)
for j in late:
    j.cancel()
ok = {a: j.result() for a, j in jobs.items()
      if j in done and not j.exception()}
bundle = {'answers': ok, 'missing':
          sorted(set(jobs) - set(ok))}

Be carefulFanning out multiplies the cost of every question. So fan out only when the sources are different enough to add new evidence.

5.06

MapReduce

Adapted fit

Turn each document into short notes with its own subagent, group the notes by theme, then combine each group into one summary.

1 doc eachnotesby themesection40 documentstoo big for onectxMapsummarise doc 1Mapsummarise doc 2Mapsummarise doc 40Group by themeshuffleReducetheme: pricingReducetheme: risksReportsections merged
Fig. 5.06No call ever sees all the documents. Maps shrink them, the shuffle groups, and reducers combine.
Classic meaning

To process a large set of data, you run a map function on each record at the same time. You group the results by a key. Then a reduce function combines each group.

In the agent system

Forty PDFs will not fit in one context window (the text the model can see at one time). So the harness runs a map step. It starts one subagent (helper agent) per document or chunk. All use the same summary prompt. Each one returns structured notes tagged with themes. The harness then groups the notes by theme. This is the shuffle step. Next, one reduce subagent per theme writes a summary from that theme's notes alone. A final call merges the theme sections into the report. Every model call sees a limited amount of text. So the job grows with the number of documents, not with the size of the context window.

In a large company

Companies run map steps through a provider's batch API (a cheaper way to send many requests to be done later). Or they use a queue of workers behind the LLM gateway (one shared front door to all AI models). This gives lower cost, rate limits per tenant (per customer company) and chargeback (billing) per job. PII redaction (removing personal data such as names and emails) and DLP (data loss prevention checks) run before the map step. Source document ids travel with every note, so the final report can cite and audit its evidence. Data residency matters here (keeping data inside a set country or region). Map workers must run in the region where the documents live. The notes made along the way are stored and deleted under the same retention policy (rules on how long data is kept) as the sources.

Step by step
  1. Split the documents into pieces that each fit in one context.
  2. Map: one subagent per piece runs the same prompt and returns notes tagged by theme.
  3. Shuffle: the harness groups all notes by theme.
  4. Reduce: one call per theme combines its notes, and a final call merges the sections.
Code sketch
notes = await gather(*[
    run('summarise', d) for d in docs])
by_theme = defaultdict(list)
for n in chain(*notes):
    by_theme[n.theme].append(n)
parts = await gather(*[
    run('synthesise', t, ns)
    for t, ns in by_theme.items()])
report = await run('merge', parts)

Be carefulMap summaries lose detail. When the answer needs exact quotes or figures, carry source ids through the map step. Then the reducers can look up the originals.

5.07

Leader-Followers

Worker agents take turns. One leader watches the task queue, claims the next task, then makes a follower the new leader.

next taskclaimwritedone: rejoinpromote nextTask queueticketsLeaderonly one watchingProcessingruns the taskFollowersidle, in lineResults
Fig. 5.07Leadership rotates. The leader claims a task, promotes a follower, and rejoins the line when done.
Classic meaning

A pool of threads (units of work inside one program) shares a set of event sources. One leader waits for an event. When one comes, it makes a follower the new leader, then handles the event itself.

In the agent system

A group of identical worker agents serves a shared queue of tasks, such as new tickets or scheduled code reviews. Only the current leader checks the queue. So idle workers do not all hit it at once, and two workers never claim the same task. When the leader claims a task, it first hands leadership to the next idle worker. Only then does it start its long model and tool calls. So someone is always watching the queue. When the worker finishes, it writes the result and rejoins the followers.

In a large company

Inside one harness process, a leader-followers pool stops a thundering herd (many idle workers rushing the queue at once). But across machines, companies almost always use competing consumers. They use SQS, Pub/Sub or Kafka consumer groups (message services where workers share one queue). Visibility timeouts or leases take the place of the leader token. Either way, a claimed task needs a lease and a dead-letter path (a place where failing tasks go to be looked at). Worker counts are capped per tenant (per customer company), so one customer cannot take over the whole pool. Alert on queue length and on how long tasks wait to be claimed. These are the SLO measures (measures for the reliability target).

Step by step
  1. One idle worker holds the leader token and waits on the task queue.
  2. A task arrives and the leader claims it.
  3. The leader passes the token to the next follower, who starts waiting on the queue.
  4. The former leader does the task, writes the result and rejoins the followers.
Code sketch
async def worker(me, pool, queue):
    while True:
        await pool.wait_until_leader(me)
        task = await queue.claim()
        pool.promote_next()   # hand off lead
        out = await run_agent('review', task)
        results.write(task.id, out)
        pool.rejoin(me)

Be carefulLeader-followers works inside one process (one running program). If the leader crashes after claiming a task but before passing the token, nobody watches the queue. So give the token a lease (a hold that expires unless renewed). For workers on several machines, use competing consumers instead. That means all workers read from one queue that hides a claimed message for a set time (a visibility timeout).

5.08

Leader Election

Let a group of identical agents agree on exactly one coordinator, and pick a new one automatically when it disappears.

acquire(lease, ttl=30s)granted: term 1acquire(lease)held by Arenew(lease) every 10sA crashes; renewals stop and the lease expiresacquire(lease)granted: term 2leader: B, term 2Agent AAgent BAgent CLease store
Fig. 5.08An atomic lease picks one leader. When it expires, the next one takes over with a higher term.
Classic meaning

A group of machines (nodes) runs a shared method to choose one node to coordinate them. When that node fails, they choose a replacement.

In the agent system

Several copies of the harness may share a task queue or run the same scheduled task. Exactly one of them should coordinate. It splits the work, sends reminders and writes the daily summary. Each copy tries to get a lease in a shared store. A lease is a database row or key-value lock with a TTL (a time limit after which it expires). The winner becomes leader and renews the lease while it is healthy. A term number goes up with each new leader. Workers reject commands from an older term. This stops a former leader that was paused from acting on old authority. If the leader's process crashes, its lease expires and another copy wins the next try.

In a large company

Company harnesses get leases from a store with linearizable compare-and-set. That means a write happens only if the value is still what you expect, and every reader sees the same order of changes. Examples are etcd, ZooKeeper, Consul or a Kubernetes Lease object. Teams use these instead of writing their own consensus (agreement) system. The systems the leader writes to must check the fencing term (the term number used as a guard). One way is a conditional write in the database. Without that check, a paused old leader can still cause duplicate side effects. Log and alert on changes of leader. Frequent changes, called flapping, usually mean an unhealthy node or a network partition (part of the network cut off from the rest).

Step by step
  1. Each agent copy tries to get the coordinator lease with a TTL.
  2. One succeeds, gets a new term number and starts coordinating.
  3. The leader renews the lease while healthy. The others keep retrying quietly.
  4. If renewals stop, the lease expires and the next copy to get it leads with a higher term.
Code sketch
while running:
    lease = store.try_acquire('coord', me,
                              ttl=30)
    while lease and lease.valid():
        # work in slices shorter than the TTL
        coordinate_step(fence=lease.term)
        lease = store.renew(lease)  # or None
    await sleep(10)

Be carefulNever choose a leader by comparing clocks or by asking the model to pick one. Use an atomic lease (one that is taken in a single step that cannot be split) in a store that gives every copy the same answer.

5.09

Observer

Direct fit

Let the UI, the orchestrator and loggers watch a shared task list and react when a subagent changes an item.

set_status()attach()update()Subagentmarks task 3 doneTask listkeeps observerlistChecklistpanelredrawsOrchestratorplans next stepProgress logappends a line
Fig. 5.09The task list itself calls every attached observer when an item changes.
Classic meaning

A subject (the thing being watched) keeps a list of observers. When its state changes, it tells each of them automatically.

In the agent system

The task list behind a todo tool is the subject. Three observers attach when the run starts: the chat UI's checklist panel, the orchestrator (the agent in charge) and the progress logger. Say a subagent (helper agent) calls the tool to mark task 3 done. The task list updates itself. Then it calls update on every attached observer. The panel redraws, the orchestrator thinks about what comes next, and the logger adds a line. Observers detach when the run ends. So finished sessions stop getting updates.

In a large company

In a company harness, the observers on a task list are usually the UI, the audit writer and a metrics exporter. The audit observer must never be skipped. So teams make audit writes synchronous (finished before the call returns). Or they back them with a durable outbox (a saved list of messages that will be sent even after a crash). UI and metrics observers run in the background, so they cannot slow the agent. Some observers sit in another process or belong to another tenant (customer company). These should use a proper event stream with access control instead of direct calls.

Step by step
  1. The UI panel, orchestrator and logger call attach() on the task list.
  2. A subagent changes a task through a tool call.
  3. The task list saves the change and calls update() on each observer in its list.
  4. Each observer reads the new state and reacts in its own way.
Code sketch
class TaskList:
    def __init__(self):
        self.items, self.observers = {}, []
    def attach(self, obs):
        self.observers.append(obs)
    def set_status(self, tid, status):
        self.items[tid] = status
        for o in self.observers:
            o.update(self, tid, status)

tasks.attach(checklist_panel)

Be carefulObservers run inside the subject's call. So one slow observer, such as a remote logger, delays the subagent's tool result. Hand slow work off to run in the background.

5.10

Publish-Subscribe

Direct fit

Agents publish events such as file.changed or task.done to named topics, and every agent subscribed to a topic gets them.

BROKERpublishpublishdeliverdeliverdeliverCoder agentpublishesTest agentpublishesfile.changedtopictask.donetopicLint agentIndexrefresherOrchestrator
Fig. 5.10Publishers name a topic, never a receiver. The broker sends each event on to all its subscribers.
Classic meaning

Publishers send messages to topics without knowing who will receive them. A broker (a go-between service) delivers each message to every subscriber of that topic.

In the agent system

In a coding run with several agents, the coder agent publishes file.changed with the file path and a diff hash (a short fingerprint of the change). The test agent publishes task.done with its verdict. A broker keeps a list of subscribers for each topic. The broker can live inside the harness or be an outside message service. The lint agent (a code style checker) and the index refresher both subscribe to file.changed. The orchestrator (the agent in charge) subscribes to task.done. Publishers never address subscribers. So a new watcher, such as a docs-updater agent, joins just by subscribing to an existing topic.

In a large company

At company scale, the broker is Kafka, Google Pub/Sub, Amazon SNS with SQS, or EventBridge. Teams own topics. Message formats (schemas) are registered and versioned. Tenant isolation (keeping each customer company's data apart) uses separate topics per tenant, or ACLs (access lists) on who may subscribe. This matters because a subscriber added to a shared topic quietly receives every tenant's events. Keep events small. They carry ids rather than raw prompts or PII (personal data such as names and emails). Set the broker's retention (how long it keeps messages) to match the data retention policy.

Step by step
  1. Subscribers tell the broker which topics they want.
  2. An agent publishes an event to a topic and carries on.
  3. The broker looks up the topic's subscribers.
  4. It delivers a copy to each one, and each handles it on its own.
Code sketch
bus.subscribe('file.changed', lint_agent)
bus.subscribe('file.changed', reindex)
bus.subscribe('task.done', orchestrator)

# inside the coder agent's write tool
bus.publish('file.changed',
            {'path': p, 'sha': sha})

Be carefulEvents that wake agents can cause more events. So guard against loops. For example, a formatter agent's own edits can publish file.changed again and wake itself.

5.11

Event Aggregator

Direct fit

Collect events from every harness part in one hub, so listeners sign up once instead of connecting to each source.

emitemitemitcallcallcallTool runnerSubagent poolFile watcherEvent hubtype -> handlersStatus barTranscriptCost meter6 links, not 9
Fig. 5.11Sources and listeners each know one object, the hub, instead of each other.
Classic meaning

One object receives events from many sources. Subscribers sign up with that one object instead of with each source.

In the agent system

A harness has many event sources. They include the tool runner, the subagent pool, the file watcher, the permission gate and the model client's stream. Without a hub, the status bar, the transcript writer and the cost meter would each connect to every source. An event aggregator is the one object they all share. Sources call hub.emit to send an event. Listeners call hub.on with an event type to sign up. The hub usually lives inside the same program. It runs handlers right away, one after another. This is unlike a pub-sub broker (a separate message service) on a network. It also gives one place to add filtering, ordering or recording.

In a large company

Company harnesses use an in-process event hub as the one place to attach three things. These are audit logging, OpenTelemetry span events (records in a standard format for tracing what software does) and cost metering. So every tool call and approval is recorded without changing each part. A forwarder on the hub sends chosen events to the platform's central stream, such as Kafka or EventBridge. From there they feed the SIEM (the security team's log monitoring system) and analytics. Keep handlers isolated, so an error in one listener cannot stop the emit or block the audit handler. Redact event data (remove secrets and personal data) before it leaves the process.

Step by step
  1. At startup, listeners sign up handlers with the hub by event type.
  2. Parts of the harness send events to the hub as things happen.
  3. The hub looks up the handlers for that event type.
  4. It calls each handler in the order they signed up.
Code sketch
class EventHub:
    def __init__(self):
        self.handlers = defaultdict(list)
    def on(self, kind, fn):
        self.handlers[kind].append(fn)
    def emit(self, kind, **data):
        for fn in self.handlers[kind]:
            fn(**data)

hub.on('tool.done', cost_meter.add)
tool_runner.hub = subagents.hub = hub

Be carefulWhen everything talks only to the hub, it gets hard to see who reacts to what. So keep an automatically generated list of event types and their handlers.

5.12

Data Bus

Direct fit

Connect every agent to one shared bus that carries all messages, and let each member decide which messages to act on.

post / hearhearPlannerkeeps plan.*Coderkeeps task.*Shared data busevery msg to allTesterkeeps file.savedTranscriptrecords allUI panelkeeps progress.*
Fig. 5.12Every message reaches every member, and each member does its own filtering.
Classic meaning

Parts of a system attach to a shared bus (a common channel) and post messages onto it. Every attached member sees every message and filters for itself.

In the agent system

A small team of agents can share one bus inside the program. The planner, coder, tester, a UI panel and the transcript writer all attach to it. Every message (plan.updated, file.saved, tests.failed) goes onto the bus and reaches all members. Each member checks the message type and ignores what it does not handle. There is no routing table. That keeps things simple while the team is small and there are few messages. The bus is also one single place to record every message into the transcript for replay.

In a large company

The main company objection is that a broadcast bus breaks least privilege (giving each part only the access it needs). Every member sees every message and any tenant data in it, including UI panels and third-party plugins. A tenant is one customer company sharing the platform. So platforms limit a data bus to one session in one process. Traffic between sessions or between tenants moves to topic-based brokers with ACLs (access lists). When a bus is used, recording every message to the transcript gives a full audit and replay log. But that log must follow the same retention rules (how long data is kept) and redaction rules (removing secrets and personal data).

Step by step
  1. Each agent attaches to the bus when it starts.
  2. An agent posts a typed message onto the bus.
  3. The bus hands the message to every attached member.
  4. Each member filters by message type and acts only on the ones it handles.
Code sketch
class DataBus:
    def __init__(self):
        self.members = []
    def attach(self, m):
        self.members.append(m)
    def post(self, msg):
        for m in self.members:
            m.receive(msg)

class Tester(Member):
    def receive(self, msg):
        if msg.type == 'file.saved':
            self.queue_tests(msg.path)

Be carefulEvery member receives every message, so the bus copes badly with growth. Once members ignore most of the traffic, move to topics or an event aggregator.

5.13

Polling Pub/Sub

Publishers add events to a shared store, and each subscriber agent checks it on its own schedule using a saved cursor.

append #41 file.changedwake (every 30s)read(after=40)[#41]handle; cursor = 41append #42 task.doneappend #43 file.changedread(after=41)[#42, #43]SubscriberEvent storePublisher
Fig. 5.13Events wait in the store. The subscriber pulls whatever is newer than its cursor.
Classic meaning

Publishers write messages to a durable topic (one that is saved and survives crashes). Subscribers check from time to time and pull the messages that arrived after the last one they read.

In the agent system

Some agents run as scheduled tasks or short sessions. They cannot keep a connection open for pushed messages. So the harness writes events to an append-only table or file (one where new items are only added at the end), with ids that keep going up. Each subscriber keeps a cursor, which is the id of the last event it handled. Each time it wakes up, it asks for events after that cursor. A subagent may have been asleep, crashed, or paused while waiting for user approval. It catches up on its next check. The cost is delay, up to the time between checks. There are also wasted calls when nothing has changed.

In a large company

This is how many company agents read change feeds. A scheduled agent may read a transactional outbox table (a table of messages written in the same database step as the change). Or it may read a Kafka topic by consumer offset (its saved position). Or it may read a SaaS API's changes endpoint with a saved cursor. Cursors are stored per subscriber and per tenant (customer company) in a durable store. Handlers are idempotent, because at-least-once replay is normal. That means the same event may arrive more than once. How often to check is a cost decision. So platforms set it per tenant. They alert on consumer lag (how far behind a reader is) rather than on single failures.

Step by step
  1. A publisher adds an event with the next id in the sequence.
  2. A subscriber wakes on its schedule and asks for events after its cursor.
  3. It handles the returned events in order.
  4. It saves the new cursor, so a restart picks up from the right place.
Code sketch
cursor = state.get('cursor', 0)
while True:
    events = store.read(topic='repo',
                        after=cursor)
    for ev in events:
        handle(ev)
        cursor = ev.id
    state.save('cursor', cursor)
    await sleep(POLL_SECONDS)

Be carefulSave the cursor only after the events are handled successfully. Make handlers idempotent (safe to run twice with the same result), because a crash replays events. Do not trust raw database sequence ids as cursors. Transactions running at the same time can commit them out of order, so a reader may skip one.

5.14

Microservices Messaging

Direct fit

Agent services talk through message channels, so a sender posts work and moves on without waiting for the receiver.

send(cid)deliveracksave3 nacksinspectResearch svcsends, moves onResults queuedurableWriter svcacks after saveDead lettersparkedRepair agentor a personDraftskeyed by cid
Fig. 5.14The sender only needs the channel to be up. Acknowledgements and the dead-letter queue stop messages from vanishing.
Classic meaning

Services talk by sending messages over channels such as queues or streams, without waiting for a reply. So neither side needs the other to be running at the same moment.

In the agent system

A research service and a writing service each run as their own agent deployment. A durable queue (one that saves messages so they survive a crash) called research.results connects them. The research agent sends a message with its findings and a correlation id (a tag that links related messages). Then it picks up its next job without waiting. The writer reads messages at its own pace. It acknowledges each one (confirms it is done) only after it has saved its draft. Messages that are not acknowledged are delivered again. A message that keeps failing moves to a dead-letter queue (a holding place for problem messages). A person or a repair agent can look at it there.

In a large company

In company agent platforms, the channels are Kafka, RabbitMQ, SQS or Pub/Sub. The producer uses a transactional outbox (it writes the message in the same database step as its own change). So a message is sent only if its database write succeeds. Messages carry a correlation id, a tenant id (which customer company) and W3C trace context in headers. W3C trace context is a standard tag that links all the work in one request. Consumers drop duplicates using an idempotency key (a unique id that marks a repeat). Dead-letter queues have alerts and an owner. Messages are encrypted with keys managed by KMS (a key management service). They are kept no longer than the retention policy allows.

Step by step
  1. The producer agent sends a message with a correlation id to the channel.
  2. The channel saves it durably, and the producer moves on.
  3. The consumer receives the message, does the work and acknowledges it.
  4. Unacknowledged messages are delivered again, and repeated failures go to a dead-letter queue.
Code sketch
# research service
chan.send('research.results',
    {'cid': job.id, 'findings': notes})

# writer service
for msg in chan.consume('research.results'):
    try:
        draft = write(msg.body['findings'])
        drafts.save(msg.body['cid'], draft)
        msg.ack()
    except Exception:
        msg.nack()   # redeliver, then DLQ

Be carefulChannels deliver at least once, so a message may arrive twice. Consumers must cope with duplicates. For example, save each draft under its correlation id.

5.15

Microservices Aggregator

One aggregator calls several specialist subagents, then merges their partial outputs into a single answer for the user.

requestaskask / partaskmergeone answerChat UIAggregatorasks, then mergesTests agentpass/fail countsChangelogagentmerged PRsSecurity agentopen findingsOne reportmissing partsnamed
Fig. 5.15The user asks once and gets one answer. The aggregator hides the specialists and names any gaps.
Classic meaning

A service receives a request, calls several backend services and combines their replies into one answer. This is called API Composition, and it all happens inside one request. It is different from the message Aggregator in Enterprise Integration Patterns. That one collects related messages that arrive over time and sends one combined message when the set is complete.

In the agent system

A user asks for a release readiness report. The aggregator agent sends focused requests to three subagents (helper agents). One reports test results, one writes the changelog and one runs a security scan. Each returns structured output that follows a schema (a fixed format) the aggregator set. The aggregator checks the parts against each other and removes duplicates. It flags conflicts, such as two subagents disagreeing about a version number. Then it writes one reply or artifact. The user and the chat UI talk to one endpoint. They never need to know how many subagents produced the answer.

In a large company

In a company platform, this is the API Composition layer behind an API gateway (the single front door for requests). One endpoint signs the user in with SSO (single sign-on). It calls specialist agents or services using that user's delegated identity (acting on the user's behalf with the user's permissions). Then it returns one response. Each branch has its own timeout and circuit breaker (a switch that stops calling a failing service for a while). The reply marks missing sections clearly. So a report that matters for compliance is never quietly incomplete. The aggregator is also where the cost of each request is added up and charged to the tenant (customer company).

Step by step
  1. The user's request reaches the aggregator.
  2. It sends a focused request to each specialist subagent.
  3. Each subagent returns its part as structured output.
  4. The aggregator merges and checks the parts and returns one answer, naming any missing part.
Code sketch
async def readiness(release):
    asks = {'tests': ask('tests', release),
            'log': ask('changelog', release),
            'scan': ask('security', release)}
    timed = [wait_for(a, 120)
             for a in asks.values()]
    res = await gather(*timed,
                       return_exceptions=True)
    parts = dict(zip(asks, res))
    missing = [k for k, v in parts.items()
               if isinstance(v, Exception)]
    ok = {k: v for k, v in parts.items()
          if k not in missing}
    return report(merge(ok), missing)

Be carefulThe aggregator is the only place where a partial failure is visible. So it must say which section is missing instead of hiding the gap.

5.16

Saga

Run a multi-step job across agents as a chain of steps, each paired with an action that undoes it.

FORWARDCOMPENSATIONSokokfailscompensatethen undoCreate branchcoder agentMigrate DBdb agentDeploy stagingdeploy agentDelete branchcompensates 1Migrate downcompensates 2Saga log1 ok, 2 ok, 3failed
Fig. 5.16Forward steps commit one by one. A failure runs the recorded compensations backwards.
Classic meaning

A saga handles a business task that spans several services. It runs as a series of local steps, and each step commits (is made final) on its own. If one step fails, compensating steps undo the steps already done. There are two styles. In choreography, each service reacts to the event from the step before. In orchestration, a central orchestrator tells each service what to do.

In the agent system

A release job uses three agents. A coder agent creates a branch. A database agent runs a migration (a change to the database structure) on staging. A deploy agent ships to staging. No single transaction can cover git, a database and a deploy tool at once. So each step commits on its own and registers a compensation, an action that undoes it. Here that means delete the branch, or run the down-migration. This is an orchestrated saga. One coordinator tells each agent what to do. It writes every finished step to a durable saga log (a record saved so it survives a crash). In a choreographed saga, by contrast, each agent reacts to the previous agent's event, and there is no central coordinator. When the deploy fails, the coordinator reads the log. A restarted copy of the coordinator can do this too. It runs each compensation in reverse order and retries each one until it succeeds. Then it reports the state the system was left in. Between steps, other users can see the half-finished state, because a saga gives no isolation.

In a large company

Company teams build agent sagas on a durable orchestrator such as Temporal or AWS Step Functions (systems that save each step so work survives a crash). Or they use choreography over Kafka or EventBridge events, when separate teams own the participants. Every step and compensation carries an idempotency key (a unique id that makes a repeat safe). The saga log is the audit record of what changed and what was undone. Some compensations cannot run automatically. These go to a human approval queue. Change-management policy often requires one more rule. Any agent step that touches production must have a tested compensation before it is allowed into the job.

Step by step
  1. The coordinator runs step 1 and records it, with its compensation, in the saga log.
  2. It continues step by step, recording each success.
  3. A step fails, and the coordinator stops moving forward.
  4. It runs the compensations of the finished steps in reverse order and logs the outcome.
Code sketch
steps = [(create_branch, delete_branch),
         (migrate_db, migrate_down),
         (deploy, None)]  # last: no undo
for i, (act, undo) in enumerate(steps):
    try:
        act(ctx, key=f'{sid}:{i}')
        log.mark(sid, i, 'done')  # durable
    except StepFailed:
        for j in reversed(log.done(sid)):
            comp = steps[j][1]
            if comp:  # idempotent, retried
                retry_until_ok(comp, ctx)
        raise

Be carefulA compensation is a new forward action. It cannot unsend an email. So plan a correction step for any effect that cannot be reversed.

5.17

Commander

A commander agent pushes a multi-service job through to the end, retrying failed steps from a saved log until all succeed.

begin txn 17: 3 stepspush tag v2.1okpush_tag: donepublish package503 unavailablepublish: retry +30spublish package (try 2)okCommanderTxn logRepo serviceRegistry
Fig. 5.17The commander keeps retrying and logging until every step is done, without rolling back.
Classic meaning

A coordinator manages a job that spans several services. It tracks the state of each step and retries failures, waiting longer each time (backoff). It records progress so it can pick up again after a crash.

In the agent system

Publishing a release takes three steps. Push a tag to the repository service, upload a package to a registry, and post release notes through a messaging connector. Unlike a saga, the commander aims to finish rather than undo. Say the registry returns a 503 (a 'service unavailable' error). The commander schedules that step to try again later with backoff, and it keeps the tag. It writes each step's state to a durable transaction log (a record saved so it survives a crash). So if the harness restarts, it picks up the half-finished job. A step that uses up its retries goes to a person through the approval UI.

In a large company

In company platforms, the commander is usually a durable workflow engine such as Temporal or Step Functions. Its retry rules, timeouts and saved history act as the transaction log directly. Every call carries an idempotency key. So retries after a 429 (too many requests), a 503 or a timeout cannot publish twice. Errors that retrying cannot fix go straight to a human approval or incident queue. Runbooks (written procedures for operators) say who owns a job that has been sent to a person. The saved log also serves as audit evidence for change management.

Step by step
  1. The commander records a new job and its steps as pending.
  2. It calls each service in turn and records every success.
  3. A failed step is tried again later with backoff.
  4. Retries go on until every step succeeds, or a step goes to a person after its retry limit.
Code sketch
txn = log.resume_or_begin(tid, STEPS)
for step in txn.pending():
    key = f'{tid}:{step}'  # idempotency
    for n in range(MAX_TRIES):
        r = call(step, txn.data, key=key)
        if r.ok:
            log.mark(txn, step, 'done')
            break
        if not r.retryable:  # e.g. 400
            break
        await sleep(r.retry_after
                    or backoff(n))
    if not txn.is_done(step):
        return escalate(txn, step)

Be carefulRetrying until success is safe only for idempotent steps (safe to run twice with the same result). So give every call an idempotency key (a unique id that marks a repeat). Otherwise a retry may publish the package twice.

5.18

Distributed Tracing

Carry one trace id through the orchestrator, subagents, tool calls and model calls, so a whole run reads as one tree.

trace=ab12trace=ab12trace=ab12trace=ab12spans 1-3span 4span 5Orchestratorroot span 1Researcherspan 2, parent 1Coderspan 3, parent 1Model callspan 4, parent 2Tool callspan 5, parent 3Trace storeone tree per id
Fig. 5.18The same trace id rides along every hop, so every span lands in one tree.
Classic meaning

Every request carries a trace context (a shared id and a parent link) from service to service. Each piece of work records a span, a timed record that names its parent. The spans join up into one end-to-end trace.

In the agent system

When a user message starts a run, the harness creates a trace id. It also opens a root span (the top record in the tree) for the orchestrator's turn. The orchestrator is the agent in charge. When it starts a subagent (helper agent), it passes the trace id and parent span id in the task envelope. Inside the subagent, every model call and tool call opens a child span. Each records timing, token counts and status. MCP requests carry the same context in their metadata (MCP is a standard way to connect tools to an AI model). So remote servers can add their spans too. A collector joins the spans into a tree. The tree shows which subagent spent the tokens, or which tool call held up the run.

In a large company

Company platforms measure agents with OpenTelemetry (a standard for recording what software does) and its GenAI semantic conventions (agreed names for AI-related data). They pass the W3C traceparent header (the standard trace tag) through task envelopes, HTTP headers and MCP _meta fields. Spans go through an OpenTelemetry Collector to the tracing backend. Spans carry tenant (customer company), user and cost details. So traces support chargeback (billing each team for its use) and SLOs (reliability targets) per tenant. Prompt and reply content stays off by default. When it is turned on, it is redacted (secrets and personal data removed) and kept for a shorter time. Sampling keeps every error and slow trace but only some routine runs, to control storage cost.

Step by step
  1. The harness creates a trace id and a root span when the run starts.
  2. Every subagent start, model call and tool call opens a child span with the trace id and its parent's span id.
  3. Each span records timing, tokens and status when it closes.
  4. A collector joins all spans with the same trace id into one tree.
Code sketch
with tracer.span('invoke_agent orch') as root:
    sub = spawn('researcher', task,
                trace=root.context)

# inside the subagent's harness
with tracer.span('invoke_agent researcher',
                 parent=task.trace) as s:
    with tracer.span('chat', parent=s):
        r = client.messages.create(**req)
    with tracer.span('execute_tool search',
                     parent=s):
        run_tool(block)

Be carefulSpans that store full prompts and tool outputs copy secrets into the monitoring store. So by default, record only sizes and hashes (short fingerprints). Record full content only when someone turns it on.

5.19

Log Aggregation

Direct fit

Send logs from every agent run, subagent and MCP server to one central store, indexed by run id, for search and alerts.

json linesshipqueryqueryOrchestratorsper sessionSubagentsx12 per runMCP serversstderr + jsonLog shipperadds run_id, hostCentral logindexed by run_idSearch viewone run, all logsAlert ruledenials > 5/min
Fig. 5.19Scattered logs on each machine become one searchable record keyed by run id.
Classic meaning

Services send their logs to a central logging service. It stores and indexes them, so operators can search all of them in one place.

In the agent system

A busy harness runs hundreds of agent sessions across many worker machines. Each session's subagents, sandboxes (sealed-off places to run code) and MCP servers write their own logs. MCP is a standard way to connect tools to an AI model. A shipper on each machine forwards structured JSON log lines to a central log store. It adds the run id, agent name, machine name and time. Operators search one place for every error from a given tool. Evals (automated quality tests) and alert rules query the same data. For example, they can watch for a rising rate of permission denials. Without aggregation, debugging a failed run starts with working out which machine each subagent happened to run on.

In a large company

Companies send structured agent logs through OpenTelemetry Collector, Fluent Bit or Vector (tools that collect and forward logs). The logs go into Elasticsearch or OpenSearch, Splunk, Loki or a cloud logging service. Every line carries a trace_id and span_id, so logs can be joined to traces (records of one request's full path). Indexes or access rules are split by tenant (customer company). Security events such as approvals, permission denials and tool calls also go to the SIEM (the security team's log monitoring system). There they are kept longer, in a tamper-evident way (any change can be detected). Secrets and PII (personal data such as names and emails) are removed at the shipper, before data leaves the machine or region.

Step by step
  1. Every part writes structured log lines tagged with the run id.
  2. A shipper on each machine forwards the lines to the central store.
  3. The store indexes them by run id, part, level and time.
  4. People and alert rules search across all runs from one place.
Code sketch
log = logger.bind(run_id=run.id,
                  agent='researcher')
log.info('tool.start', tool='web_search')
log.warn('permission.denied', path=p)

# central query across all runs
store.query('level:warn AND'
            ' event:permission.denied'
            ' | count by agent')

Be carefulAgent logs grow quickly and often hold user data. So set limits on how long logs are kept, and remove secrets before the lines leave the machine.

5.20

Self-Registration

Each agent service or MCP server tells a registry its name, address and tools at startup, and keeps renewing that entry.

boot, load toolsregister(issues, url, tools, ttl=30)okheartbeat (every 10s)find(tool='search_issues')issues @ :8040server crashes; heartbeats stopevict after TTLMCP serverRegistryOrchestrator
Fig. 5.20The service announces itself and keeps its entry alive. Silence gets it removed.
Classic meaning

A running copy of a service signs itself up with the service registry (a directory of live services) when it starts. It renews its entry with heartbeats (regular 'still alive' signals) and removes itself when it shuts down.

In the agent system

An MCP server (MCP is a standard way to connect tools to an AI model) or a specialist agent service starts up. It calls the harness registry with its name, endpoint (address) and tool list, such as search_issues and create_issue. It also gives a TTL (a time limit after which the entry expires). It sends a heartbeat before the TTL runs out. On a clean shutdown it removes its entry. Orchestrators (the agents in charge) ask the registry which tools or agents to offer the model on the next turn. The registry removes entries whose heartbeats stop. So a crashed server drops out of the tool list instead of causing failed tool calls.

In a large company

Self-registration suits internal agent services that hold a workload identity (login details issued to the program itself). Examples are a SPIFFE ID or a cloud service account. The registry checks that identity on every registration and heartbeat. Many companies prefer third-party registration instead. In that setup, the platform registers the running copies. This can be Kubernetes with its service endpoints, or a registrar that watches deployments. Then a hacked or badly set up service cannot advertise itself. Either way, the tool catalog shown to models is filtered by an allowlist and by RBAC (role-based access control: permissions given by job role) for each tenant (customer company). Every change to a registration is audited.

Step by step
  1. The service starts and registers its name, endpoint and tool list with a TTL.
  2. It sends heartbeats to renew the entry.
  3. Orchestrators ask the registry what is live and available.
  4. If heartbeats stop, the registry removes the entry.
Code sketch
async def main():
    srv = McpServer('issues', tools)
    await registry.register(
        name='issues', url=srv.url,
        tools=[t.name for t in tools], ttl=30)
    try:
        async with every(10, registry.renew,
                         'issues'):
            await srv.serve()
    finally:
        await registry.deregister('issues')

Be carefulA registry entry is only a claim. So the registry must authenticate (check the identity of) every service that registers. Accept only servers on an allowlist. Then check that the server's live tool schemas (the formats of its tools) match what it registered, before showing them to the model.

5.21

Service Locator

Direct fit

Code asks a central locator for a connector or agent by name while running, instead of having each one handed in.

get('issues')clientlookupconnectSkill codeneeds 'issues'Servicelocatorcaches clientsName tablename -> factorySearch MCPIssues MCPReviewer agent
Fig. 5.21Callers know only a name. The locator decides which live connector answers to it.
Classic meaning

A service locator is a central registry object. Code calls it to get a service by name. It hides how the service is made or found.

In the agent system

Skills and subagent code often need a connector they did not create. Examples are the web search MCP client, the issue tracker or a reviewer subagent. MCP is a standard way to connect tools to an AI model. The code calls locator.get('issues'). The locator returns a live client from its cache. If there is none, it builds one using the factory (a function that makes the client) registered under that name. So a skill written once can run in harnesses with different connectors installed, as long as the names match. The cost is hidden dependencies. You cannot tell which connectors a piece of code needs until it runs.

In a large company

In a harness shared by many tenants (customer companies), the locator must find clients per tenant and per user. It looks them up by name plus identity. It fetches credentials from Vault or a cloud secrets manager (safe stores for passwords and keys) when it builds the client. Otherwise a cached client built with one tenant's token could serve another tenant. The name table usually comes from a connector catalog that an admin has approved. So a skill can only get connectors that policy allows for that tenant. Lookups are logged. This gives auditors a record of which connectors each skill actually used.

Step by step
  1. At startup the harness registers a factory for each name in the locator.
  2. Code calls get(name) when it needs a connector or agent.
  3. The locator returns a cached client or builds one from the factory.
  4. An unknown name raises a clear error that the harness reports back to the model.
Code sketch
class Locator:
    def __init__(self, table):
        self.table, self.live = table, {}
    def get(self, name):
        if name not in self.live:
            make = self.table[name]
            self.live[name] = make()
        return self.live[name]

issues = locator.get('issues')

Be carefulA locator hides what code depends on. So in the harness core, prefer explicit injection (passing each dependency in directly). Keep the locator for plugins and skills that load while the harness runs.

06
Chapter 6 · Work that has to wait

Async, Concurrency and Scheduling

Agents spend a lot of time waiting: for the model, for tools, for a person to approve, or for a scheduled time. These patterns let the system run many jobs at once, hand work to background workers, pause for approval, and shut down cleanly without two jobs tripping over each other.

6.01

Promise

Give back a placeholder for a result that will arrive later, so the caller keeps working instead of waiting.

spawn(task)starthandle (pending)keeps workingreportresolve(report)OrchestratorHarnessSubagent
Fig. 6.01The caller gets a handle at once and the result later.
Classic meaning

A promise is an object that stands for the future result of work running in the background (asynchronous work). The caller can wait for it or attach next steps to it.

In the agent system

The main agent can launch a subagent (a helper agent that does one task) to run in the background. When it does, the harness at once returns a handle, which is an agent id, not the finished report. The orchestrator (the main agent that hands out work) keeps planning or answering the user. When the subagent finishes, the harness resolves the handle, which means it fills the handle with the result. It then adds a notice into the conversation. If the subagent fails, the harness rejects the handle with an error. The orchestrator can then deal with that error.

In a large company

In an enterprise harness, long subagents and batch jobs return a durable job id. This id survives process restarts. It is stored in a database or in a durable execution engine such as Temporal (a system that keeps long tasks running through crashes). It is not kept as an in-memory future, which a restart would lose. The handle carries the tenant (one customer company sharing the platform) and the user's identity. So only the owner can check on it or cancel it. Every handle needs a deadline and a cost limit. Then abandoned work does not keep spending tokens (the units that model use is measured and billed in). Pass the OpenTelemetry trace context into the subagent. OpenTelemetry is a standard for tracing requests across systems. This links the final result back to the request that started it.

Step by step
  1. The orchestrator calls spawn_agent and at once gets a pending handle.
  2. It keeps doing other work while the subagent runs.
  3. The subagent finishes, and the harness resolves the handle.
  4. A completion notice arrives, and the orchestrator reads the result.
Code sketch
# returns at once with a pending handle
h = harness.spawn('researcher', task=q)
plan_next_steps()

# resolves when the subagent finishes
try:
    report = await h
except SubagentError as e:
    log.warn(e)
    report = None

Be carefulNever invent or guess the value of a promise that has not resolved yet. If the user asks early, say the work is still running.

6.02

Async Method Invocation

Start a slow job, like a background subagent, get a future (a claim ticket for the result) at once, and collect the result later.

run_in_background(task)submit on worker threadtask_id = t7other tool callsstatus(t7)runningdone(report)callback injects reportModelInvokerBackground job
Fig. 6.02The invoker returns a task id now and delivers the result later, by polling or by callback.
Classic meaning

A method runs on another thread (a separate line of work). The caller gets a future or a callback hook right away while the work goes on.

In the agent system

A subagent is a helper agent that the main agent starts for a task. Say the model calls a tool like run_in_background, or starts a background subagent. The harness hands the job to an executor (the part that runs jobs). It puts a task id in the tool_result straight away. A tool_result is the message that carries a tool's answer back to the model. The model reads that result on its next step. It then goes on with other tool calls while the job runs. Later it has three ways to get the result. It can poll (ask again and again) a status tool with the id. It can await the future, which means wait for it to finish. Or a completion callback can add the result to the transcript as a new message. The transcript is the saved record of the conversation. The invoker (the part that started the job) owns the job and records it against the session. So it can cancel the job and enforce a timeout. It can also refuse status requests for task ids that belong to another session.

In a large company

Enterprise platforms run background agent jobs on a durable engine, one that survives crashes and restarts. Examples are Temporal or a fleet of workers fed by a queue. They do this because an in-process future dies with the pod (the small server unit that runs the code) during a deploy. The task id acts as a capability, a token that grants access by itself. So status and cancel calls must check that the caller's tenant (customer company) and session own it. Otherwise one user can read another user's results. Every job carries a deadline and a token budget charged to the tenant. It also carries trace context, so the background span (one timed step in a trace) links to the turn that started it.

Step by step
  1. The model calls run_in_background with a task description.
  2. The invoker hands the job to an executor and returns a task id at once.
  3. The model makes other tool calls while the job runs.
  4. It polls the status tool, or a done-callback adds the result to the transcript.
Code sketch
fut = executor.submit(run_subagent, task)
jobs[(session.id, fut.id)] = fut
return {'task_id': fut.id}

# later: look up by id within this session
fut = jobs[(session.id, task_id)]
if fut.done():
    report = fut.result()
else:
    fut.add_done_callback(inject_result)

Be carefulA background job that nobody waits for or cancels leaks, which means it keeps using resources. Track every future per session. When the session ends, cancel or report any jobs still running.

6.03

Event-Based Asynchronous

Show a long job as events (start, progress, completed, failed, cancel), so the UI and agent loop react as things happen.

startraiseAgent loopTask runnerrun_async(t42)progressstep 3 of 7completedresultfailederrorTask cardprogress barNotificationinto transcript
Fig. 6.03One running job raises separate events that different subscribers act on.
Classic meaning

A MethodAsync call returns at once. It then raises ProgressChanged and Completed events on the caller's own thread or loop. It can also support cancelling.

In the agent system

Some jobs run a long time, like a background subagent, a long build or a scheduled task. Each one publishes lifecycle events: started, progress, completed, failed, cancelled. An event is a short message that says something happened. The chat UI subscribes to (signs up for) progress events to update a task card. The agent loop is the cycle that calls the model and runs its tools. It subscribes to completed and failed events. Then it can add a task-notification message to the conversation. Cancelling goes the other way, as a cancel request on the same task id. The runner raises events back on the session's own event loop. That is the single loop that handles the session's work. So handlers never touch session state from a worker thread.

In a large company

In production, the lifecycle events go onto a durable stream such as Kafka or EventBridge. A durable stream is a stored log of messages that survives crashes. Each event is keyed by task id. So a client that reconnects can replay the latest state instead of missing it. Failed events often carry stack traces or raw tool output. So remove secrets and PII (personal data such as names and emails) before an error message enters the transcript or a notification. Cancelling is cooperative, which means the job must notice the request and stop itself. Record each cancel in the audit log. Side effects already in flight must either finish idempotently (safe to run twice with the same result) or be compensated (undone by a reversing action).

Step by step
  1. The caller registers its handlers, then starts the job with run_async and gets a task id.
  2. The worker raises progress events as it goes.
  3. When it finishes, it raises completed with the result, or failed with the error.
  4. Handlers on the session loop update the task card and tell the model.
Code sketch
task = runner.create(job, task_id='t42')
task.on('progress', ui.update_card)
task.on('completed', lambda r:
        transcript.notify(f'done: {r}'))
task.on('failed', lambda e:
        transcript.notify(f'failed: {e}'))
task.run_async()     # subscribe first

# user presses Stop on the task card
task.cancel_async()

Be carefulEach event fires only once. A handler registered after completed has fired misses the result. So subscribe before you start the job, or let late subscribers read the last state.

6.04

Producer-Consumer

Many sources put agent jobs into a bounded (size-limited) buffer, and a separate set of workers takes them off at its own pace.

PRODUCERSCONSUMERSput(job)get()WebhookSchedulercron firesUser chatBounded queuemaxsize=100, put waitsif fullWorker 1Worker 2Worker 3
Fig. 6.04Producers and consumers never meet, and the bounded buffer sets the pace for both.
Classic meaning

Producers and consumers share a bounded buffer instead of talking directly. Producers wait when it is full, and consumers wait when it is empty.

In the agent system

Agent jobs come from many places. Webhook handlers produce them. A webhook handler is code that runs when another service sends an event. Scheduled tasks fired by cron (a timer) produce them. So do user messages from many sessions. These producers do not call the agent loop directly. Instead they put jobs on a bounded asyncio.Queue or a broker list. A bounded queue has a fixed maximum size. A broker is a separate service that holds messages. A fixed set of consumer workers take jobs off. Each one runs the model and tool calls, and stores the results. When the queue is full, producers wait or answer 429 (the HTTP code for 'too many requests'). This protects model rate limits and token budgets from sudden bursts.

In a large company

Enterprise harnesses replace the in-memory queue with a durable broker such as Kafka, SQS or Pub/Sub. A durable broker keeps messages safe even if a server crashes. The worker acknowledges a job only after the turn is recorded. So if a worker crashes, the broker delivers its job again. A single shared queue lets one tenant's (customer company's) burst starve everyone else. So platforms use per-tenant queues or weighted fair scheduling. Each tenant also gets a concurrency cap, a limit on jobs running at once. The cap is tied to the tenant's model quota. Two numbers drive autoscaling and SLO alerts (alerts on reliability targets). They are the queue depth and the age of the oldest job.

Step by step
  1. Producers wrap incoming work as job messages.
  2. They put jobs on a bounded queue, and wait if it is full.
  3. Consumers take one job at a time, and wait if it is empty.
  4. Each consumer runs the agent turn and records the result.
Code sketch
jobs = asyncio.Queue(maxsize=100)

async def on_webhook(evt):     # producer
    try:
        jobs.put_nowait(Job.from_event(evt))
    except asyncio.QueueFull:
        return 429             # shed load

async def worker():            # consumer
    while True:
        job = await jobs.get()
        try: await run_agent(job)
        finally: jobs.task_done()

Be carefulAn unbounded queue (one with no size limit) hides overload until memory runs out. Always set a size. Then decide whether producers wait, drop the job or reject it.

6.05

Event Queue

Direct fit

Hold events that arrive mid-turn in a queue, so the agent loop handles them in order at a safe point.

postdrainstream, toolsmessagesUser follow-upSubagent doneFile watcherSession inboxFIFOAgent loopmid-turnContextnext model call
Fig. 6.05Events wait in the inbox until the loop reaches a turn boundary.
Classic meaning

Sending an event and handling it happen at different times. The sender puts it in a queue, and the receiver empties the queue when it is ready.

In the agent system

While the model streams a reply or a tool runs, other things keep happening. The user types a follow-up. A background subagent finishes. A file watcher fires. A scheduled trigger arrives. Handling each one at once would push it into the middle of the current turn. A turn is one round of model reply plus tool calls. That would corrupt the transcript (the saved record of the conversation). So the harness adds each event to a per-session event queue and returns. At the turn boundary, the agent loop drains (empties) the queue in arrival order. It turns the events into messages before the next model call.

In a large company

Sometimes sessions can move between pods (the small server units that run the code). Then the per-session inbox lives in a durable store, or in a stream partition keyed by session id. A partition is one ordered slice of a stream. So events survive failover (a switch to a backup server) and stay in order. Events from webhooks, file watchers or other agents are untrusted input. So the loop should add them as clearly labelled notifications that say where they came from. It must never add them as if the user had typed them. Cap and merge events per session. Log dropped or merged events for audit.

Step by step
  1. A sender posts an event to the session queue and returns at once.
  2. The current model call or tool call goes on undisturbed.
  3. At the turn boundary, the loop drains queued events in arrival order.
  4. Each event becomes a message in the context for the next model call.
Code sketch
inbox = collections.deque()

def post(event):        # any hook or thread
    inbox.append(event)

def at_turn_boundary(ctx):
    while inbox:
        ev = inbox.popleft()
        ctx.messages.append(ev.to_message())

Be carefulEvents may arrive faster than turns finish. Then merge or cap them, or the next prompt fills up with old notices.

6.06

Active Object

Direct fit

Give a scheduled task its own thread and request queue, so callers add requests and get futures back.

OWN THREADenqueuenextexecuteresolvereturnsCron triggerRun nowProxyrun(prompt)ActivationqueueSchedulerone request at atimeTask sessionservantFutureper request
Fig. 6.06Callers only add requests, and the task's own thread does all the work.
Classic meaning

Calling a method and running it are kept apart. A proxy (a stand-in object) turns each call into a request on an activation queue. A scheduler runs each request on the object's own thread and fills in its future (a placeholder for the result).

In the agent system

Take a scheduled task such as a daily inbox triage (sorting the inbox). It runs as an active object. It owns a session, a transcript and a single worker thread or asyncio task. Callers never touch that session directly. A caller may be the cron trigger (a timer), a Run now button or another agent. Each one calls a proxy method, run(prompt). The proxy wraps the call as a request and puts it on the task's activation queue. Then it returns a future, a placeholder for the result that fills in later. The task's scheduler takes requests in order. It runs each turn against the task's own session. Then it resolves (fills in) the matching future.

In a large company

At platform scale, the active object becomes a durable single writer per session. Durable means it survives crashes. Single writer means only one owner changes the session. One example is a Temporal workflow per scheduled task. It receives Run now requests as signals (messages to a running workflow). It handles them one at a time. Each session is pinned to one owner by a lease (a time-limited lock) or a partition key. This stops two pods (server units) from running turns against the same transcript. The scheduled task runs under a service identity, an account for software rather than a person. That identity is limited to what its owner approved. The harness records who sent each request, for audit.

Step by step
  1. A caller calls proxy.run(prompt) and gets a future.
  2. The proxy puts a method request on the activation queue.
  3. The scheduler on the task's thread takes the next request.
  4. The servant (the object that does the real work) runs the turn in its own session and resolves the future.
Code sketch
class ScheduledTask:          # proxy side
    def run(self, prompt):
        fut = Future()
        self.q.put((prompt, fut))
        return fut

    def _loop(self):          # own thread
        while True:
            prompt, fut = self.q.get()
            out = self.session.turn(prompt)
            fut.set_result(out)

Be carefulCode running on the task's own thread must never call the task's proxy and wait on the future. It deadlocks (gets stuck forever), because the request waits in line behind itself.

6.07

Reactor

Direct fit

Run one event loop that waits on many streaming connections at once and sends each ready event to its handler.

HANDLERSreadablenext tickdispatchModel SSEChat WebSocketMCP stdioEvent loopselect() +dispatchon_tokenon_user_msgon_rpc_reply
Fig. 6.07One wait covers every connection, and ready ones go to their handlers.
Classic meaning

A single wait watches many handles (open connections or files) at once. Each event that arrives goes to a registered handler that must not block.

In the agent system

A harness server holds thousands of open connections. Some are SSE streams from the model provider. SSE (Server-Sent Events) is a way to stream text over HTTP. Some are WebSocket links (two-way live connections) to chat clients. Some are stdio pipes (standard input and output) to local MCP servers. MCP is a standard way to connect tools to an AI model. A single asyncio event loop registers them all with the OS selector, such as epoll or kqueue. The selector is the operating system feature that reports which sockets have data. When a socket has bytes, the loop calls that connection's handler. The handler adds a token delta (a small piece of new text). Or it forwards text to the right client, or reads a JSON-RPC reply. JSON-RPC is the message format MCP uses. Handlers must return quickly. So CPU-heavy work, such as tokenising a large file, goes to a thread pool.

In a large company

Enterprise harness gateways run several event-loop processes per host behind a load balancer. They use sticky routing for WebSocket and SSE sessions, which keeps each session on the same process. During deploys they drain connections gracefully, letting them finish before shutdown. Event-loop lag is a key health metric. It measures how late the loop is in handling events. It is exported through OpenTelemetry, a standard for traces and metrics. It matters because one blocking handler slows every tenant (customer company) on that loop. Per-tenant connection limits and idle timeouts protect shared hosts. They stop one customer's clients from using up all the file descriptors (the limited slots for open connections).

Step by step
  1. Each connection registers a handler with the event loop.
  2. The loop blocks once in select or epoll across all handles.
  3. For each ready handle, it calls the registered handler.
  4. Handlers do one small non-blocking step and return to the loop.
Code sketch
sel = selectors.DefaultSelector()
sel.register(model_sse, READ, on_token)
sel.register(client_ws, READ, on_user_msg)
sel.register(mcp_pipe, READ, on_rpc_reply)

while running:
    for key, _ in sel.select():
        key.data(key.fileobj)   # dispatch

Be carefulOne blocking call inside a handler freezes every stream on the loop. A synchronous HTTP request, one that waits for its answer, is an example.

6.08

Thread-Pool Executor

Run tool calls or whole sessions on a fixed set of reusable workers, so extra load waits in a queue instead of creating endless threads.

POOL: 3 WORKERSsubmittakeresultModel6 tool_use blocksExecutor queuet4 t5 t6 waitingWorker 1bashWorker 2web_fetchWorker 3read_filetool_resultordered by id
Fig. 6.08Work waits in a queue for a fixed set of reused workers instead of creating more threads.
Classic meaning

A pool of worker threads takes tasks from a shared queue. The threads are reused, and the pool caps how many tasks run at once.

In the agent system

Sometimes the model returns several tool_use blocks in one turn. A tool_use block is the model's request to run one tool. The harness sends the independent, read-only ones to a ThreadPoolExecutor, with say max_workers=8. It runs calls that change shared state one at a time. Then it returns every result in the next user message as tool_result blocks. Each result is matched to its call by tool_use_id. A tool that fails becomes a tool_result with is_error set. is_error is a flag that marks the result as an error. So one bad call does not lose the results of the other calls. A server hosting many sessions uses the same idea at a larger scale. It runs N worker processes, each running one agent session at a time. Model request concurrency and sandbox count have their own separate limits. A sandbox is an isolated space where tools run.

In a large company

Enterprise platforms size worker pools per tenant tier and per tool class. A tenant tier is a level of customer plan. They enforce caps again at the LLM gateway, the shared entry point for model calls. There the caps cover model concurrency and tokens per minute. Parallel tool runs are limited to independent read-only calls. Anything with side effects runs one at a time, behind the approval and audit path. Pool saturation (how full the pool is) and queue wait time are exported as metrics. They drive autoscaling. One example is a Kubernetes HorizontalPodAutoscaler on a custom metric. That is the Kubernetes feature that adds or removes servers based on load.

Step by step
  1. The harness creates a pool with a fixed number of workers.
  2. Each tool_use block goes into the pool's queue as a task.
  3. Free workers take tasks, and the rest wait in the queue.
  4. The harness collects results by call id and builds the tool_result message.
Code sketch
pool = ThreadPoolExecutor(max_workers=8)

safe = [b for b in blocks if b.read_only]
futs = {pool.submit(run_tool, b): b.id
        for b in safe}
results = {}
for f in as_completed(futs):
    try: results[futs[f]] = f.result()
    except Exception as e:
        results[futs[f]] = tool_error(e)
for b in blocks:          # writes: one by one
    if not b.read_only:
        results[b.id] = run_tool(b)
reply = [results[b.id] for b in blocks]

Be carefulTasks that wait on each other inside one small pool can deadlock it, so all of them get stuck. Keep dependent steps out of the pool, or size the pool with care.

6.09

Thread-Specific Storage

Give each session its own slot for user id, budget and trace id, which shared code can read without passing it everywhere.

CONTEXTVARcallin Ain Bin CSession task ASession task BSession task CShared toolcodesession.get()Slot Aana, 50k tokensSlot Braj, 8k tokensSlot Cli, 20k tokens
Fig. 6.09The same shared code reads a different slot, depending on which task runs it.
Classic meaning

Code reads what looks like one global variable. But its value is private to the current thread or task.

In the agent system

A harness server runs many sessions on one asyncio loop and a shared thread pool. Deep inside, a tool wrapper needs to know which session it serves. It needs the user id for permission checks. It also needs the token budget, the trace id and the sandbox path. A trace id is a label that links all the logs from one request. Passing all of these through every function would be messy. So the harness sets a contextvars.ContextVar at the start of each session task. A ContextVar is a Python variable whose value belongs to the current task. Any code running in that task can call session.get() and see its own values. This includes logging and the MCP client. It works even while another session's task takes turns on the same thread.

In a large company

Enterprise harnesses keep tenant id, principal, roles and trace context in context variables. A tenant is one customer company. A principal is the user or service the action is for. OpenTelemetry's Python SDK also uses contextvars this way. OpenTelemetry is a standard for traces and metrics. So spans (timed steps) from shared code land under the right trace. Security decisions should fail closed when no context is set. Fail closed means deny when unsure. They must never fall back to a default tenant. Some context crosses a thread pool, process or queue boundary. That context must be passed explicitly. For example, put signed claims and W3C trace context on the job message. W3C trace context is a standard format for passing trace ids.

Step by step
  1. The server declares a ContextVar for session state.
  2. Each session task sets it at the start with its own values.
  3. Shared tool and logging code calls get() to read the current session.
  4. The value goes away with the task, so nothing leaks to the next session.
Code sketch
session = ContextVar('session')

async def handle(req):
    session.set(Session(req.user, budget=5e4))
    await agent_loop(req)

def write_file(path, text):   # shared code
    s = session.get()
    s.sandbox.check(path)

Be carefulThe context follows asyncio tasks and asyncio.to_thread. It does not follow loop.run_in_executor or raw threads. Wrap those in copy_context().run. Otherwise the worker sees no session, or an old one.

6.10

Guarded Suspension

Pause an action until its condition is true, such as waiting for the user's approval before running a risky tool.

tool_use: send_email(to, body)approval cardwait_for(decision set)user reads the draftAllowre-check guard, run tooltool_resultModelApproval gateUser
Fig. 6.10The tool call sleeps at the gate until the guard turns true.
Classic meaning

A method call is blocked until a guard condition becomes true. It waits on a condition variable (a signal that threads can wait for). It checks the condition again after each wake-up.

In the agent system

Some tools need permission, such as sending an email or deleting files. When the model calls one, the harness records a pending approval. It then suspends (pauses) that tool call on a condition. The chat UI shows an approval card. The user clicks Allow or Deny. The UI handler sets the decision and notifies the waiting coroutine. A coroutine is a function that can pause and resume. It checks the guard again. Then it either runs the tool or returns a denial as the tool_result (the answer sent back to the model). A timeout turns a long silence into a denial. So the call never hangs forever.

In a large company

In an enterprise platform, an approval can wait for hours. So the harness saves the pending call and frees the worker and the sandbox (the isolated space where tools run). It resumes when the decision comes in, through a durable mechanism that survives restarts. Examples are a Temporal signal or a Step Functions task token. The approval is tied to a hash (a fingerprint) of the exact tool name and arguments. So the agent cannot change the email after it was approved. The approver's identity is checked against RBAC (role-based access control: permissions given by job role). Separation of duties applies to high-risk actions, so the person who asks is not the one who approves. Every decision goes to the audit log, including timeouts.

Step by step
  1. The tool call reaches the gate while its decision is still unset.
  2. The harness posts an approval card and waits on a condition.
  3. The user's click sets the decision and wakes the waiter.
  4. The call wakes, checks the guard again, and runs or returns a denial.
Code sketch
async def guarded_run(call, ttl=300):
    try:
        async with asyncio.timeout(ttl), cond:
            await cond.wait_for(
                lambda: call.decision)
    except TimeoutError:
        call.decision = 'deny'  # default deny
    if call.decision != 'allow':
        return denied(call)
    return await call.tool.run(call.args)
async def on_click(call, choice):   # UI
    async with cond:
        call.decision = choice
        cond.notify_all()

Be carefulAlways pair the wait with a timeout that denies by default. Otherwise a forgotten approval card holds a worker and a sandbox forever.

6.11

Balking

Direct fit

If an object is not in the right state, return at once instead of waiting, such as skipping a run already in progress.

fireclickyesnodone: clearCron triggerevery 15 minSend pressedx2Running?Return'skipped'logged, no workStart runflag = running
Fig. 6.11A request that arrives mid-run is refused on the spot, not queued.
Classic meaning

An action runs only if the object is in a suitable state. Otherwise the call returns at once and does nothing.

In the agent system

A scheduled task fires every 15 minutes, but one run sometimes takes 20. Without care, the scheduler starts a second run on the same session while the first is still going. That doubles model spend. It also races on the transcript, with two runs writing at once. With balking, the trigger handler checks a running flag under a lock. If a run is in progress, it logs 'skipped: already running' and returns. The same check stops a double-click on Send from starting two turns. It also stops a second compaction while one is underway. Compaction is shrinking the conversation history by summarising it.

In a large company

A harness may run as several replicas (copies on different hosts). Then an in-process flag only stops overlaps on one host. So scheduled runs take a distributed lease before starting. A lease is a lock that expires after a set time. Examples are a Postgres advisory lock or a database row with an expiry. Each skipped run is logged and counted as a metric. A job that balks every time is stuck, not busy. For user actions such as a double-clicked Send, use an idempotency key on the request. An idempotency key is a label that makes a repeat safe. It gives the same protection across retries and reconnects.

Step by step
  1. The trigger fires and calls start_run.
  2. Under a lock, the handler checks whether a run is already active.
  3. If one is, it logs a skip and returns at once.
  4. Otherwise it marks the run active, starts it, and clears the flag when done.
Code sketch
def start_run(task):
    with task.lock:
        if task.running:
            log.info('balk: run in flight')
            return 'skipped'
        task.running = True
    try:
        execute(task)
    finally:
        task.running = False

Be carefulBalking drops requests silently. So use it only when the skipped request is truly not needed. If every request matters, queue it instead.

6.12

Poison Pill

Direct fit

Shut down queue workers cleanly by sending a special stop message that each worker recognises and exits on.

enqueuejoin tailget()exitexitSupervisordeploying v2STOP pillx2, one per workerJob queuej7, j8, STOP, STOPWorker 1Worker 2Join + cleanupflush, close MCP
Fig. 6.12The stop signal travels through the queue behind the real work.
Classic meaning

A sentinel (a special marker message) goes into a queue. Each consumer that reaches it finishes its current work and stops.

In the agent system

Sometimes a harness host deploys a new version, or a batch eval (a large automated test run) finishes. It must then stop its agent workers without leaving turns half-done. The supervisor is the process that manages the workers. It stops taking new jobs. It then puts one STOP sentinel per worker on the job queue. Each worker finishes the job it holds. It also finishes the jobs ahead of the pill, in FIFO order (first in, first out). Then it leaves its loop when it takes STOP. The supervisor joins the workers, which means it waits for each to finish. Then it saves transcripts and closes MCP connections (links to tool servers).

In a large company

Pills only work on an in-process queue. On a shared broker such as Kafka or SQS, another replica's worker would take the pill. A broker is a separate service that holds messages. So hosts do something else instead. They stop polling for jobs when they get SIGTERM, the signal that tells a process to shut down. They finish or checkpoint (save progress on) the current turn. Then the broker delivers anything not yet acknowledged again. Agent turns can last longer than a Kubernetes termination grace period, the time a pod gets to shut down. So long work should checkpoint into durable execution and resume on another host. Durable execution keeps work going through crashes and restarts. Do not confuse a poison pill with a poison message. A poison message is a malformed job that crashes consumers again and again. It belongs in a dead-letter queue, a holding place for messages that cannot be handled.

Step by step
  1. The supervisor stops taking new jobs.
  2. It adds one STOP sentinel per worker behind the remaining jobs.
  3. Workers handle the jobs ahead of the pill as usual.
  4. Each worker that takes STOP exits, and the supervisor joins them and cleans up.
Code sketch
STOP = object()

def shutdown(q, workers):
    for _ in workers:
        q.put(STOP)       # one pill each
    for w in workers:
        w.join()

def worker(q):
    while (job := q.get()) is not STOP:
        run_agent_turn(job)

Be carefulSend exactly one pill per consumer, and never let a worker put it back on the queue. A missing pill leaves a worker stuck on get() forever.

6.13

Half-Sync/Half-Async

Let clients use simple blocking calls while the backend runs model streams and tool calls asynchronously, joined by a queue.

SYNC HALFASYNC HALFcallput / replytake / resolveawaitgatherScript or CLIagent.run()blocks on resultBridge queuerequest + replyEvent loopmany sessionsModel streamTool callsfan out
Fig. 6.13Blocking code above, non-blocking code below, and a queue in between.
Classic meaning

Concurrent work is split into three layers. A synchronous layer keeps code simple. An asynchronous layer handles I/O (input and output) efficiently. A queue sits between them.

In the agent system

An SDK or CLI offers a blocking call: result = agent.run(prompt). A blocking call waits until the answer is ready. It is easy to script and test. Underneath, the harness runs an asyncio backend. It is asynchronous, meaning it handles many things at once without waiting on each one. It streams from the model, starts many tool calls at once and juggles many sessions. A thread-safe bridge sits between the two sides. The sync side schedules a request on the event loop. Then it waits on a reply future (a placeholder for the answer). The async side picks up the request, does the concurrent work and fills in the future. Neither side has to adopt the other's style of programming.

In a large company

Enterprise deployments usually put an async gateway in front. It accepts WebSocket, webhook and SSE traffic on an event loop. These are kinds of live or pushed web connections. It hands jobs through a queue to synchronous worker processes. These run tools and sandboxes with plain blocking code. The queue in the middle is where the platform enforces backpressure, per-tenant quotas and deadlines. Backpressure means slowing down senders when the system is full. A tenant is one customer company sharing the platform. Timeouts on the sync side must cancel the async work. Otherwise abandoned requests keep spending the tenant's token budget.

Step by step
  1. The sync layer gets a call and places a request on the bridge.
  2. It blocks on the reply future for that request.
  3. The event loop takes the request and runs model streams and tool calls at the same time.
  4. It fills in the future, and the sync caller wakes with a plain return value.
Code sketch
# sync half: simple and blocking
def run(prompt):
    fut = asyncio.run_coroutine_threadsafe(
        backend_run(prompt), loop)
    try: return fut.result(timeout=600)
    finally: fut.cancel()   # no-op if done

# async half: one loop, many sessions
async def backend_run(prompt):
    async with asyncio.TaskGroup() as tg:
        async for ev in model.stream(prompt):
            if ev.tool_use:
                tg.create_task(run_tool(ev))

Be carefulNever call the blocking API from inside the async layer. It would block the event loop while waiting for work that only that same loop can do.

6.14

Monitor

Direct fit

Wrap shared session state in an object whose methods run one at a time, with wait and notify for conditions.

MONITORcallacquiremutatewait()notifyStream readerTool workerCompactorEntry queuewaiting for lockSessionStateone caller insideMessagesmsgs, tokensWait setwait_idle()
Fig. 6.14One caller is inside at a time, and waiters park in the wait set until notified.
Classic meaning

An object lets only one thread run its methods at a time, which is called mutual exclusion. It also has condition variables, so threads can wait inside it for the state to change.

In the agent system

A session has shared state: the message list, the token count and pending tool calls. Several parts touch it, such as the stream reader, tool workers and the UI handler. Making it a monitor means every method takes the same lock. The methods include append, add_tokens and end_turn. So no caller ever sees half an update. Some methods need a condition to be true first. One is wait_idle, used before compaction (shrinking the history by summarising it). It waits on a condition variable inside the monitor. A notify call wakes it when the turn ends.

In a large company

A monitor guards state inside one process. But several replicas (copies on different hosts) may touch a session. Then the same rule moves to the database as optimistic concurrency. With optimistic concurrency, each write checks a version column or ETag. The write fails if someone changed the record first. Another option is a single-writer owner per session. It is worth exporting lock wait and hold times as metrics. When callers fight over a session object's lock, users see a slow UI. Never make network calls, remote policy checks or model calls while holding the monitor.

Step by step
  1. Callers call methods on the SessionState monitor.
  2. Each method enters by taking the monitor's single lock.
  3. A method whose condition is false waits and lets go of the lock.
  4. A state change calls notify, and a waiter enters again and re-checks.
Code sketch
class SessionState:            # a monitor
    def append(self, m):
        with self.cv:
            self.msgs.append(m)
    def end_turn(self):
        with self.cv:
            self.busy = False
            self.cv.notify_all()
    def wait_idle(self):
        with self.cv:
            self.cv.wait_for(self.idle)

Be carefulKeep monitor methods short. Never call the model or a tool while holding the lock. Otherwise every other caller stalls behind the network.

6.15

Lockable Object

Let a caller lock a session object across a change of several steps, so other writers wait or back off until it is released.

lock(owner='compactor', ttl=60)grantedtry_lock('ui')busy: held by compactorsnapshot, summarise, replaceunlock('compactor')try_lock('ui'): ok, appendCompactorSessionUI handler
Fig. 6.15The holder keeps the lock across several steps while others are turned away.
Classic meaning

An object has explicit lock and unlock operations and records who holds it. A caller can hold it across several operations.

In the agent system

Compaction shrinks a session's transcript by summarising it. It rewrites the transcript in several steps. First it takes a snapshot. Then it summarises with the model, replaces old turns and updates token counts. Locking each method on its own is not enough. Messages that arrive between steps would be lost. So the session is a lockable object. The compactor calls lock(owner='compactor') and holds it across all steps. Meanwhile the UI's append calls try_lock. It sees who holds the lock and saves the message for later. The lock records its owner and an expiry time. So a crashed holder cannot keep it forever.

In a large company

Across replicas (copies on different hosts), the lock is a lease in a shared store. Examples are a Postgres row with an expiry or an etcd lease. Every write the lock protects carries a fencing token. The token is a number that only ever goes up, and the store checks it. The harness records the holder's identity, lease time and renewals. So operators can see who holds a session. They can break a stale hold through an audited admin action. Holders should be service identities (accounts for software), not browser connections. Then a client that disconnects cannot pin a tenant's session, which would keep a customer's session locked.

Step by step
  1. A caller locks the session with its owner id and becomes the holder.
  2. It does several operations while holding the lock.
  3. Other callers' try_lock reports the holder, so they wait or put off their work.
  4. The holder unlocks, or the lease runs out, and the next caller goes ahead.
Code sketch
lease = session.try_lock('compactor', ttl=60)
if lease:
    try:
        snap = session.snapshot()
        summary = model.summarise(snap)
        # refused if the lease has expired
        session.replace_old_turns(
            summary, fence=lease.token)
    finally:
        session.unlock(lease)
else:
    log.info(f'held by {session.holder}')

Be carefulA lock held across a model call can outlive its lease (its time limit). So renew the lease while working. Also have the store reject writes that carry a stale fencing token. A fencing token is a number that goes up with each new lease. Without these, an expired holder can overwrite newer messages.

6.16

Double-Checked Locking

Create a shared client only when first needed, checking once without the lock and again under it, so only one thread builds it.

noyespublishyes: fast pathno: reuseSessionget_client()Exists?Init lockStillnone?Build clientopen poolShared client
Fig. 6.16The second check, made under the lock, stops a racing thread from building twice.
Classic meaning

Lazy initialisation builds something only on first use. To cut locking cost, code checks the condition before taking the lock and checks again after.

In the agent system

Some things are costly to build: model provider clients, MCP connections and tokenizer tables. A client is the object that talks to a service. MCP is a standard way to connect tools to an AI model. Building these opens connection pools, fetches tool lists and loads vocabularies. A harness creates them on first use. But many sessions may reach first use at the same moment. The getter first checks for the client without locking. Every later call takes this fast path. If the client is missing, the getter takes a lock and checks again before building. So a thread that lost the race reuses the new client. It does not open a second pool. Some things carry credentials, such as an MCP connection authorised as one user. These are cached per tenant and principal, never as one global instance. A tenant is one customer company. A principal is the user or service the access belongs to.

In a large company

In an enterprise harness, lazily built clients are usually per tenant (customer company) or per principal (user or service). That is because they carry API keys, OAuth tokens or a region-specific endpoint. The regional endpoint is for data residency, which means keeping data in a certain region. So the cache key must include whichever of those vary. A singleton is one shared instance for the whole process. A global singleton holding one user's MCP authorisation is a cross-tenant data leak. Cached clients also need a way to refresh when credentials rotate (get replaced). Credentials may rotate in Vault or in a KMS-backed secret store, which is protected by a key management service. Clients should not live for the life of the process.

Step by step
  1. A caller checks whether the shared client exists, and returns it if so.
  2. If not, it takes the init lock.
  3. It checks again, since another caller may have built it meanwhile.
  4. Only if it is still missing does it build and publish the client.
Code sketch
_clients, _lock = {}, threading.Lock()

def get_client(tid):         # per tenant
    c = _clients.get(tid)          # check 1
    if c is None:
        with _lock:
            c = _clients.get(tid)  # check 2
            if c is None:
                c = ModelClient(creds(tid))
                _clients[tid] = c
    return c

Be carefulPublishing the built object must be one atomic step (it happens all at once) after construction. A CPython name binding gives that. Java needs a volatile field and C++ needs an atomic. Otherwise a reader can see a half-built client.

6.17

Double Buffer

Adapted fit

Keep two buffers: the UI shows the finished one while the stream fills the other, then they swap in one step.

appendtriggerrenderModel streamtoken deltasBack bufferbeing filledswap()exchange refsFront buffercomplete blocksFrame ticksafe boundary?Chat view
Fig. 6.17Writers fill the back buffer, readers see only the front, and a swap exchanges them.
Classic meaning

Readers see a current buffer while writers fill a next buffer. The two swap atomically, in one step, so readers never see a half-done state.

In the agent system

The model streams its reply as token deltas (small pieces of new text). Pushing each one straight into the chat DOM causes flicker and broken markdown. The DOM is the page structure the browser draws. You might see half a code fence, or a table missing its last row. So a harness frontend keeps two buffers. The back buffer collects deltas and re-reads the markdown. The front buffer is the one the renderer paints. On each animation frame, the code checks the back buffer. If it sits at a safe boundary, such as the end of a block, the two references swap. The same idea works on the server for artifacts. Write the new version to a temp file. Then atomically rename it over the live one.

In a large company

Enterprise chat clients can use the safe-boundary buffer as a checkpoint. There, streamed output passes DLP and secret scanning before it is painted. DLP (data loss prevention) is tooling that stops sensitive data from leaking. This matters because text already on screen cannot be taken back. The same swap-a-reference idea appears on the server when new versions are published. This covers policy bundles, prompt configs and artifact versions. Build and check the new version completely. Switch the active pointer in one step. Keep the old one for rollback. Record which version each session used, so an audit can rebuild what a user saw.

Step by step
  1. Token deltas are added to the back buffer.
  2. The renderer paints only the front buffer.
  3. At a frame tick, if the back buffer is at a safe boundary, the references swap.
  4. The new back buffer is brought up to date and keeps filling for the next round.
Code sketch
front, back = Doc(), Doc()

def on_delta(text):
    back.append(text)          # writer side

def on_frame():
    global front, back
    if back.at_safe_boundary():
        front, back = back, front
        back.sync_from(front)
    render(front)              # reader side

Be carefulSwapping only at safe boundaries can fall behind a fast stream. Cap the delay, so a long code block still shows progress.

07
Chapter 7 · Keeping agents within safe limits

Safety, Permissions and Resilience

An agent acts on someone's behalf using real logins and real data. These patterns put checks between what the agent wants to do and what actually happens: filters, permission rules, walls between customers, actions that never run twice by accident, and cleanup that always happens.

7.01

Intercepting Filter

Pass every prompt and response through a chain of guardrail filters, each able to inspect, rewrite or block it.

INPUT CHAINOUTPUT CHAINblockreplysafe replyUser or toolincoming textInjection scanPII redactSize capModelRefusalblocked: reasonModerationSecret scanChat or toolreceives output
Fig. 7.01Traffic in both directions passes a chain of separate filters, and any of them can stop it.
Classic meaning

Filters run in a chain before and after a request handler. They can be configured, and the handler does not need to know they exist.

In the agent system

Some inputs reach the model, such as a user message or a fetched web page. First they pass through input filters. One is a prompt-injection scan on tool results. Prompt injection is hidden text that tries to give the model new orders. Others are a PII redactor and a size cap. A PII redactor removes personal data such as names and emails. After the model replies, output filters run. These include moderation, secret scanning, and a check that no tool call targets a blocked domain. That output check is only an early warning. The real block on domains happens where the tool runs, or in the egress proxy (the gate for outgoing network traffic). A filter manager builds the chains from config. So a team can add a new detector without touching the agent loop. Any filter can stop the chain early with a refusal. Or it can pass on a redacted payload.

In a large company

Enterprise platforms usually build this chain into an LLM gateway or harness middleware. An LLM gateway is a shared entry point for all model calls. The chain applies PII redaction and DLP (data loss prevention), injection classifiers, moderation and secret scanning. Each tenant (customer company) gets its own settings from versioned configuration. Filters fail closed. They log each decision with a redacted payload for audit. Cheap rules run before model-based classifiers, to protect latency SLOs (targets for response speed). Streaming responses need the output chain applied to buffered chunks. The controls that truly decide, such as egress allow-lists, still sit in the tool executor and the network layer.

Step by step
  1. The filter manager builds the input and output chains from config.
  2. An incoming message runs through each input filter in order.
  3. If nothing blocks it, the agent loop calls the model with the cleaned input.
  4. The reply runs through the output filters before it reaches the user or a tool.
Code sketch
pre = FilterChain([
    InjectionScan(), RedactPII(),
    TokenCap(50_000),
])
post = FilterChain([Moderation(),
                    SecretScan()])

def handle(msg):
    msg = pre.run(msg)   # may raise Block
    reply = agent.turn(msg)
    return post.run(reply)

Be carefulClassifier filters are probabilistic, which means they guess and can be wrong. So treat the chain as defence in depth, one layer among several. It is not a security boundary. Fail closed when a filter errors, which means block when unsure. Keep hard limits, such as tool permissions and egress allow-lists, in the executor. An egress allow-list is the list of outside addresses the tools may reach.

7.02

Specification

Write permission rules as small predicates (yes-or-no tests) combined with and, or and not, then test each action against them.

is_satisfied_byProposedactionsubmit_form(url)Policy: ORANDApproveduser allowed itIsLocalhosthost == localTestCredstest accountUserRequestedasked in chat
Fig. 7.02Small reusable rules combine into a tree that judges each proposed action.
Classic meaning

A business rule becomes an object with an is_satisfied_by(candidate) method. Rules can be combined with boolean operators: and, or, not.

In the agent system

Here is a policy for browser actions. Allow a form submission if the URL is localhost AND the credentials are test credentials AND the user asked for it. OR allow it if the user approved this exact action. Each clause is a Specification object: IsLocalhost, TestCreds, UserRequested, Approved. Each has is_satisfied_by(action). Each leaf (a single basic rule) reads facts the harness itself holds. These are the parsed URL, the credential's source, the recorded user turns and the approval record. A leaf never trusts claims the model puts in the tool arguments. The policy is built with &, | and ~ (and, or, not). It is unit-tested clause by clause. It can also explain itself. So the harness can tell the model which clause failed, in the denial tool_result.

In a large company

Enterprise platforms usually move these rules into a policy engine. Examples are OPA with its Rego language, or Cedar. There, policies are versioned, code-reviewed and tested in CI (automatic checks on every change). Each check writes a decision log for audit. Cedar has a rule that a matching forbid overrides any permit. This gives explicit deny, which plain OR combining lacks. Every attribute fed to the policy must come from the identity provider and harness records, never from model output. Attributes include the tenant (customer company), the principal (the acting user), the resource and the approval state.

Step by step
  1. Write each rule as a small spec with is_satisfied_by(action).
  2. Combine specs with and, or and not into a policy.
  3. At the permission gate, check the policy against the proposed tool call.
  4. If it fails, report which clause failed so the model can adjust.
Code sketch
class Spec:
    def __and__(s, o): return And(s, o)
    def __or__(s, o):  return Or(s, o)
    def __invert__(s): return Not(s)

policy = (IsLocalhost() & TestCreds()
          & UserRequested()) | Approved()

if not policy.is_satisfied_by(action):
    return deny(policy.explain(action))

Be carefulDeeply nested specs get hard to read. Name the middle-level policies, such as safe_local_submit, and test each leaf on its own.

7.03

Filterer

Direct fit

Give a tool or content collection a filtered() method that returns a smaller collection of the same type, chained per session.

EACH STEP IS A TOOLREGISTRYfiltered()filtered()filtered()schemas()sendAll toolsbuilt-in + MCP:120By role34 toolsBy project18 toolsNot deferred11 toolsModel requesttools=[...]Modelsees 11 tools
Fig. 7.03Each filtered() call returns the same type, so the narrowing can go on.
Classic meaning

A container returns filtered copies of itself through filtered(predicate). A predicate is a yes-or-no test. Filters can chain without showing the container's insides.

In the agent system

The harness loads a full ToolRegistry, the list of all tools it knows. It holds every built-in tool plus every tool from each connected MCP server. MCP is a standard way to connect tools to an AI model. A given session should only see some of them. So the harness calls registry.filtered(by_role).filtered(by_project).filtered(not_deferred). Each step returns a new ToolRegistry. So the result still supports lookup, schema export and more filtering. The final registry is serialised (turned into text) into the tools parameter of the model request. The same shape works for memory entries, or for search results filtered by the user's access.

In a large company

In enterprise harnesses, the first filters come from entitlements, the access the customer has bought and allowed. First comes the tenant admin's allow-list of MCP servers and tools. Then come RBAC or ABAC rules mapped from identity provider groups through SSO (single sign-on). RBAC gives permissions by job role. ABAC gives permissions based on attributes such as team or region. The same shape applies to retrieval, which means looking up documents for the model. There, document ACLs (access control lists) should be applied as a filter inside the vector store query. A vector store is a database that finds similar text. Trimming a top-k list afterwards can return too few results, and it is easy to forget. Visibility filters are a usability layer. So the executor checks authorisation again and logs every call.

Step by step
  1. Load the full registry of built-in and MCP tools.
  2. Apply filtered() with the session's role test.
  3. Chain more filtered() calls for project scope and loaded state.
  4. Send the final registry's schemas as the request's tools list.
Code sketch
class ToolRegistry:
    def filtered(self, pred):
        return ToolRegistry(
            t for t in self.tools if pred(t))

visible = (registry
    .filtered(lambda t: t.risk <= role.cap)
    .filtered(lambda t: t.server in project)
    .filtered(lambda t: not t.deferred))
request.tools = visible.schemas()

Be carefulA filtered list hides tools from the model, but it does not stop a crafted call. Hiding a tool is not the same as blocking it. Enforce the same rule again when the tool runs.

7.04

Role Object

Attach role objects such as editor or operator to a session while it runs, each carrying the permissions it grants.

checkroles?hashasremovedallows(call)tool_usebash('make test')PermissiongateSession coreuser=anaEditorwrite /repoOperatorshell, no netViewerread filesReviewerrevoked
Fig. 7.04The session stays the same object while roles come and go around it.
Classic meaning

A core object gets different views for different contexts. Each view is a separate role object that can be added or removed while the program runs.

In the agent system

A Session core holds identity, transcript and settings. Roles attach as separate objects. Viewer can read files. Editor can write inside the workspace. Operator can run shell commands and send messages. Reviewer can approve other agents' tool calls. The permission gate asks the session for its roles. It lets each role decide with allows(call). Access may be added mid-session, from the user's entitlements or an audited admin grant. Entitlements are the access the user really holds. Then the harness attaches a role. When access is revoked, it removes one. It does not change a permissions blob or create a subclass of Session. A session never gains a role its user does not hold.

In a large company

In an enterprise platform, a session's roles come from the user's identity provider groups and the tenant's policy. A tenant is one customer company. An agent never holds a role its user lacks. This avoids the confused-deputy problem, where a trusted helper is tricked into using power its requester should not have. Granting a role mid-session is an audited event. It has an approver, a scope and an expiry. This follows the style of just-in-time access, where access is given only when needed and for a short time. 'Any role allows' logic cannot express explicit deny. So high-risk restrictions belong in a policy engine such as Cedar or OPA. The gate asks that engine as well as the roles.

Step by step
  1. Create the session core with identity and transcript.
  2. Attach role objects based on the user's grants or the workspace policy.
  3. At each tool call, the gate asks the session's roles whether any allows it.
  4. Add or remove roles mid-session as access is granted or revoked.
Code sketch
s = Session(user='ana')
s.add_role(Editor(paths=['/repo']))
s.add_role(Operator(shell=True, net=False))
s.add_role(Reviewer())

def allowed(s, c):     # any role may allow
    return any(r.allows(c) for r in s.roles)

s.remove_role(Reviewer)    # access revoked

Be carefulRole checks spread across the codebase drift apart over time. Send every permission question through one gate that asks the roles.

7.05

Bulkhead

Split sandboxes, worker pools and quotas per session or tenant (customer), so one runaway agent cannot sink the others.

CHATEVALS: FULLCONNECTORchatevalmcpRequest routerby kindChat pool64 slotsSandbox A2 CPU, 4 GBEvals pool8/8 busySandbox BrejectingMCP crm pool4 slotsCRM connectorslow, contained
Fig. 7.05A flooded compartment stays flooded alone, and its neighbours keep serving.
Classic meaning

Parts of a system are kept in separate pools. If one fails or is overloaded, the others keep working.

In the agent system

Each session runs tools in its own container. A container is an isolated box for running code. It has limits on CPU, memory, disk and processes. So a fork bomb or a 50 GB download fills only that box. A fork bomb is a program that copies itself until the machine chokes. Above that, the harness gives each tenant (customer company) its own worker pool and model concurrency quota. It also gives each slow MCP server (a tool server) its own connection pool. So a hung connector uses up its own slots, not the shared ones. Interactive chat and batch evals (large automated test runs) run in separate pools. So a large eval sweep never starves a user who is waiting for a reply.

In a large company

Enterprise platforms build bulkheads along tenant lines. They set per-tenant token and concurrency quotas at the LLM gateway, the shared entry point for model calls. They use Kubernetes namespaces with ResourceQuota, which caps what each group can use. They run untrusted code on separate node pools (separate groups of machines). Some use cell-based deployments, so a bad tenant or release affects only one cell. A cell is a full, separate copy of the system. Sandboxes for different tenants should not share a kernel (the core of the operating system) without a stronger isolation layer. Examples are gVisor or Firecracker microVMs. Saturation metrics for each compartment feed both alerting and chargeback, which means billing each tenant for what it used.

Step by step
  1. Group workloads by tenant, session or dependency.
  2. Give each group its own pool, container and quota.
  3. Send each request only to its own group's compartment.
  4. When one compartment fails or fills, reject its requests while the others run normally.
Code sketch
pools = {
    'chat':    Semaphore(64),
    'evals':   Semaphore(8),
    'mcp:crm': Semaphore(4),
}

async def run(kind, job):
    if pools[kind].locked():   # full: reject
        raise Rejected(kind)
    async with pools[kind]:
        async with Container(cpus=2, mem='4g',
                             pids=256) as box:
            return await box.exec(job)

Be carefulMany tiny compartments leave capacity unused. Size pools from real load. Let interactive traffic borrow spare slots, but only within set limits.

7.06

Idempotent Consumer

Record each trigger firing's key before acting, so a message delivered twice is recognised and the email is never sent twice.

fire #81 x2statusyesnomark doneclaimTrigger queueat-least-onceConsumerderives keyKey done?Ack, returnstored resultSend emailidem_key=keyProcessed keyskey, status
Fig. 7.06A redelivery that finds its key done stops, and one that finds it pending retries with the same key.
Classic meaning

Some message systems deliver at least once, so a message may arrive twice. The consumer stays safe by tracking processed message ids and ignoring duplicates.

In the agent system

Scheduled triggers, webhooks and queue messages arrive at least once. A worker can crash after sending an email but before it acks, which means telling the broker the message is handled. Then the broker delivers the message again. An idempotent consumer is safe to run twice with the same result. It builds a key from the business event, such as trigger id plus scheduled fire time. It claims that key in a processed table. The table has a unique constraint, so the database refuses duplicate keys. It also has a status column. If the row is already done, the consumer acks. It returns the stored result instead of running the agent again. If the row is still pending, an earlier attempt died part-way. So the consumer tries the send again with the same key. It passes that key to the email API as the idempotency key. The provider then drops any duplicate. The consumer acks only after it stores the result as done.

In a large company

Enterprise agents that send email, file tickets or move money need effectively-once side effects. That means each action happens once in effect, even if messages repeat. It comes from three parts together. The first is at-least-once delivery. The second is a durable table of processed keys. The third is an idempotency key that the downstream system honours. An example is the Idempotency-Key header that payment APIs like Stripe accept. Provider-side keys expire after a retention window, a set storage period. So the local table must last longer than any possible redelivery. Some downstream systems have no idempotency support. Then the consumer checks for the effect before retrying, or sends the case to a human. Every attempt goes to the audit log.

Step by step
  1. A message arrives carrying a trigger id and fire time.
  2. The consumer claims the key in the processed table, or reads the row that already holds it.
  3. If the row is done, it acks and returns the stored result.
  4. Otherwise it does the action, passing the key as the provider's idempotency key.
  5. It marks the row done with the result, then acks.
Code sketch
def on_fire(msg):
    key = f'{msg.trigger_id}:{msg.fire_at}'
    row = db.claim('done', key)  # unique key
    if row.status == 'done':
        msg.ack()
        return row.result         # duplicate
    # new, or an earlier try crashed mid-send
    res = mail.send(draft, idem_key=key)
    db.finish('done', key, res)
    msg.ack()

Be carefulBuild the key from the business event, never from the delivery attempt. A fresh uuid (random id) for each retry makes every duplicate look new.

7.07

Transactional Outbox

Adapted fit

Write the planned side effect to an outbox in the same transaction as the transcript turn, then let a relay carry it out.

ONE TRANSACTIONappendinsertpollsend(key)mark sentAgent loopdecides to emailTranscriptassistant turnOutboxpending rowRelay workerpolls pendingEmail APIidempotent
Fig. 7.07The decision and the pending action commit together, and the relay acts afterwards.
Classic meaning

Outgoing messages are stored in a database outbox, inside the same business transaction. A separate relay publishes them. This gives at-least-once delivery without two-phase commit, a slow protocol that makes two systems agree.

In the agent system

Say an agent decides to post a chat message or create a ticket. The harness does not call the outside API right then. It opens one database transaction, a group of writes that all succeed or all fail together. In it, the harness adds the assistant turn to the transcript. It also inserts an outbox row. The row holds the action, its arguments and an idempotency key (a label that makes a repeat safe). A relay worker polls the outbox. It carries out each pending action and marks it sent. Say the process dies after the commit. The relay finds the row on restart, so nothing is lost. Say it dies after sending but before marking. Then the idempotency key on the receiving side stops a duplicate.

In a large company

Enterprise systems relay the outbox in one of two ways. One is polling with SELECT ... FOR UPDATE SKIP LOCKED, so several relays can share the work without taking the same row. The other is change data capture, which reads the database's change log. It uses Debezium's outbox event router to send rows into Kafka. The tool_result the model sees should say the action was queued, not done. Then the agent does not tell the user an email went out before it did. Failed rows retry with backoff, waiting longer each time. After that they move to a dead-letter state with an alert. The outbox also works as an audit record of every side effect the agent asked for.

Step by step
  1. In one transaction, save the transcript turn and an outbox row for the action.
  2. Commit, and note that nothing has touched the outside world yet.
  3. A relay reads pending outbox rows in order.
  4. It does each action with its idempotency key and marks the row sent.
Code sketch
with db.transaction():
    db.insert('turns', turn)
    db.insert('outbox', {'id': key,
        'action': 'send_email', 'args': args,
        'status': 'pending'})

def relay():                 # separate worker
    for row in db.pending('outbox'):
        run_action(row, idem=row.id)
        db.mark_sent(row.id)

Be carefulThe outbox gives at-least-once delivery, not exactly-once. Pair it with an idempotent receiver, one that is safe to hit twice. Otherwise the relay's retries can repeat an action.

7.08

Health Check

Probe connectors, browsers and remote devices on a schedule, and send work only to the ones that report healthy.

DEPENDENCIEStickproberecordreadProbe timerevery 30 sHealth checkerMCP serverping: okBrowserno reply: downRemote deviceheartbeat okStatus boardstate + last seenTool routerhides down tools
Fig. 7.08Regular probes keep a status board that the router trusts before each call.
Classic meaning

A service offers a probe, a small test call that reports whether it and its dependencies work. Monitors and routers act on the answer.

In the agent system

A harness depends on parts that fail quietly. An MCP server's process may die. MCP is a standard way to connect tools to an AI model. A headless browser (one with no screen) may hang. A remote device may go to sleep. An OAuth token (a login pass) may expire. A health checker probes each one on a timer. It uses a cheap real call: an MCP ping or tools/list, a blank-page navigation, or a device heartbeat. tools/list is the MCP request that lists a server's tools. The checker records each status with a timestamp. The tool router checks the board before each call. If a connector is down, it returns a clear 'connector unavailable' tool_result instead of a 60-second timeout. It hides a connector's tools only after a long outage. That is because every change to the tools list invalidates the prompt cache. The prompt cache is saved work the provider can reuse when a request starts the same way. A readiness endpoint says whether a host can take traffic. It reflects only the host's own dependencies. Then the load balancer can drain (stop sending traffic to) a broken host. A liveness check says whether the process is alive. It stays a plain process check. So a shared outage does not restart every pod.

In a large company

Enterprise harnesses expose Kubernetes liveness and readiness probes. They keep liveness to a cheap in-process check. Then an outage at a shared dependency, such as the model provider, does not restart every pod at once. A pod is the small server unit Kubernetes runs. Connector health is tracked per tenant (customer company) where credentials are per tenant. One customer's expired OAuth token is not an outage for everyone. Health state feeds SLO dashboards (views of reliability targets) and status pages. Probes use their own low-privilege credentials, so they never touch real user data.

Step by step
  1. A timer starts probes for each registered dependency.
  2. Each probe makes a cheap real call with a short timeout.
  3. Results update a status board with state and last-seen time.
  4. The tool router and UI check the board before using a dependency.
Code sketch
async def probe_all():
    for dep in deps:
        try:
            await wait_for(dep.ping(), 3)
            board[dep.name] = ('up', now())
        except Exception as e:
            board[dep.name] = ('down', e)

def usable_tools():
    return [t for t in tools
            if board[t.server][0] == 'up']

Be carefulA check that only proves the process is alive can pass while every real call fails. Probe the actual capability with a cheap real request.

7.09

Immutable

Make transcript entries, configs and policies read-only once created, so any change makes a new version and history stays intact.

prevprevedit: refusedappendprev_hashEntry 1hash a91Entry 2prev a91, hash 7f3Entry 3prev 7f3, hash c40Agent or toolwants a changeEntry 4prev c40,correction
Fig. 7.09History can only grow, so an edit attempt is refused and a new entry is added instead.
Classic meaning

An immutable object cannot change after it is built. Any change returns a new object instead.

In the agent system

Once a transcript entry is added, it is a frozen record. The transcript is the saved record of the conversation. Each entry's hash chains to the previous entry. A hash is a short fingerprint of the content. So a compromised tool or a confused agent cannot quietly rewrite what the user approved. Permission policies and system-prompt configs load as frozen objects. A change creates a new version with a new id. Each session records which version it ran under. Compaction does not edit old turns either. Compaction shrinks the history by summarising it. It adds a summary entry that points to the turns it replaces. Frozen objects can also be shared across threads and sessions with no locks.

In a large company

Enterprise audit trails for agent actions are stored append-only. One example is WORM object storage (write once, read many) such as S3 Object Lock. The head of the chain is anchored now and then in a separate system, so tampering can be detected. Immutability clashes with GDPR erasure and retention rules. GDPR is the EU data protection law, which can require deleting personal data. So platforms keep personal data encrypted under per-subject or per-tenant keys. To erase the data, they delete the key. This is called crypto-shredding. Versioned, immutable policy and prompt configs also make change management and incident review repeatable.

Step by step
  1. Build the record or config once, with all fields set.
  2. Freeze it and compute a hash over its content and the entry before it.
  3. Any change creates a new object that points to the old one.
  4. Readers share it without locks and can check the hash chain.
Code sketch
@dataclass(frozen=True)
class Entry:
    role: str
    content: str
    prev_hash: str

    @property
    def hash(self):       # covers every field
        return h(self.prev_hash, self.role,
                 self.content)

e2 = Entry('user', 'hi', e1.hash)
# e2.content = 'x'  -> FrozenInstanceError

Be carefulFreezing objects in memory protects nothing if the store allows edits. Back it with append-only storage, where records can be added but never changed. Check the hash chain on load.

7.10

Private Class Data

Seal credentials in an inner object that tool code can use through one method but never read, print or pass to the model.

Secret valueAPI tokenCredential holdersign() onlyTool codebuilds requestsModel + transcriptsees scrubbed resultsVaultloads the holder onceNo getterrepr shows ***only sign() crosses
Fig. 7.10Each outer ring can use the secret only through the narrow door of the ring inside it.
Classic meaning

A class's sensitive fields move into a separate data class. It has no getters or setters (methods that read or change fields), which limits who can read or change them.

In the agent system

An HTTP tool needs an API token. But the model, the transcript and the tool's own plugin code should never see it. So the harness puts the secret in a private credential holder, built from the vault. A vault is a secure store for secrets. The holder has no getter and no setter. Its __repr__, the text Python prints for an object, hides the secret. The holder offers one operation, sign(request). It adds the auth header just before the request leaves. The token is short-lived and scoped to the session's user. It is not a long-lived key loaded once at startup. Every tool result is scrubbed (cleaned) for the secret's exact value. It is also scrubbed for anything that looks like a credential. This covers headers and body alike. An endpoint that echoes its request can put the token anywhere in the reply.

In a large company

Enterprise platforms keep secrets out of the harness process wherever they can. An egress proxy (a gate for outgoing traffic) or a credential broker sits outside the sandbox. It adds short-lived tokens from Vault or a workload identity, an identity given to the running software. So neither the model nor tool code ever holds them. Tokens are scoped to the user and tenant (customer company) the agent acts for. This is often done through OAuth token exchange, which swaps one token for a narrower one. Every use is logged against that principal, the user or service acting. Scanning for known secret values and credential patterns runs on every tool_result and log line. It is a backstop, with an alert on any hit.

Step by step
  1. Fetch a short-lived, scoped token from the vault into a private holder.
  2. Offer only an operation such as sign(request), never the raw value.
  3. Tool code builds requests and asks the holder to sign them.
  4. Results sent to the model are scrubbed for the secret's value and credential patterns anywhere in the reply.
Code sketch
class _Creds:                   # private data
    __slots__ = ('_tok',)
    def __init__(self, tok): self._tok = tok
    def __repr__(self): return '<creds ***>'
    def sign(self, req):
        req.headers['Authorization'] = \
            f'Bearer {self._tok}'
        return req

tool = HttpTool(signer=_Creds(vault.get()))

Be carefulPython cannot truly hide attributes, so treat this as defence in depth, one layer among several. The hard boundary is a proxy outside the sandbox that adds the secret.

7.11

RAIIResource Acquisition Is Initialization

Direct fit

Tie a sandbox, browser or temp workspace to a scope, so it is released on every exit path, including errors and cancellation.

SCOPEboxraisereleaseasync withSandbox()acquire on enterAgent looptool callsTurn doneTool errorUser Stopcancelled__aexit__destroy container
Fig. 7.11Every way out of the scope passes through the same release step.
Classic meaning

A resource is taken when an object is set up. It is released when the object's scope (the block of code where it exists) ends. So the resource lives exactly as long as the scope.

In the agent system

Each session that uses tools takes costly resources. These include a container (an isolated box for running code), a headless browser, a temp directory, a port forward and a lock on the workspace. The harness wraps each one in a context manager, which is Python's with or async with block. Or it uses an ExitStack, a helper that manages several context managers at once. So release runs whenever the scope ends. The turn may finish, or a tool may raise an error. The user may press Stop, which cancels the task. Or the token budget may run out. Without this, a cancelled session leaves orphaned containers running and billing in the background.

In a large company

Scope-based cleanup does not survive a process crash, an OOM kill or a node failure. An OOM kill is when the system stops a process for running out of memory. So enterprise platforms pair it with a reaper. The reaper deletes sandboxes, browsers and volumes past a TTL (a time-to-live limit). It finds orphans through labels or Kubernetes owner references. Resources are tagged with tenant and session ids. A tenant is one customer company. So leaked capacity shows up in per-tenant cost reports and chargeback, which bills each tenant for its use. Teardown also wipes scratch data. This matters when a workspace held customer files.

Step by step
  1. Enter a scope that takes the resource and returns a handle.
  2. Use the handle for the session's tool calls.
  3. Leave the scope by any route: return, error or cancellation.
  4. The scope's exit releases every resource in reverse order of taking them.
Code sketch
async def run_session(task):
    async with AsyncExitStack() as stack:
        box = await stack.enter_async_context(
            Sandbox(image='py3.12'))
        pg = await stack.enter_async_context(
            Browser(headless=True))
        return await agent_loop(task, box, pg)
    # browser closed, then sandbox destroyed,
    # even on error or cancellation

Be carefulCleanup that waits on a slow network call can itself be cut short by a second cancellation. Shield critical teardown from cancellation. Run a reaper (a cleanup job) for anything that still leaks.

7.12

Notification

Direct fit

Collect every guardrail and validation error in one notification object, then report them together instead of failing on the first.

checkadd()reportProposed callwrite_file(...)Schema check2 errorsPathallow-list1 errorBudget check1 warningNotification3 errors, 1warningOnetool_resultis_error, listsall
Fig. 7.12Every validator writes to one shared report, which goes back in a single reply.
Classic meaning

Errors and other details are gathered in an object that goes back to the caller. This replaces throwing an exception for each problem.

In the agent system

The model may propose a tool call or a plan. Then several validators (checking steps) run. One checks the arguments against the schema, the expected shape of the input. Others check paths against an allow-list and check the budget. Another does a dry run of the shell command, testing it without real effects. Each validator adds to a Notification instead of raising an error on the first failure. If the notification holds errors, the harness returns one tool_result with is_error set. is_error is a flag on the tool_result block that marks it as an error. The tool_result lists all the errors. So the model fixes everything in a single retry. It does not find problems one round trip at a time. The same object feeds the approval card, which shows the user every warning together.

In a large company

Enterprise harnesses use the collected validation report in two places: the model's single retry and the human approval card. They write the same structured findings to the audit log. Error text sent to the model should not reveal internal policy details, secrets or other tenants' paths. A tenant is one customer company. This matters because the transcript may be stored, exported or read by later tools. Validation categories are worth tracking as metrics. A spike in one denial type often signals a broken tool schema or a prompt-injection campaign.

Step by step
  1. Create an empty notification for the proposed call.
  2. Run each validator, which adds errors or warnings instead of raising.
  3. If the notification holds errors, return them all in one tool_result.
  4. Otherwise run the call and show any warnings in the UI.
Code sketch
note = Notification()
for check in (schema_ok, path_allowed,
              within_budget, dry_run):
    check(call, note)   # adds, never raises

if note.has_errors():
    return tool_error(call.id, note.errors)
result = execute(call)
ui.show_warnings(note.warnings)

Be carefulSome findings must stop validation at once, such as a detected prompt injection (hidden text that tries to give the model orders). Let those validators stop instead of collecting more from hostile input.

08
Chapter 8 · The overall shape

Harness Architecture

Zoom out and the whole system has a shape: a core that knows how agents work, with clear connection points for models, tools, storage and the screens people use. These patterns decide that shape and how it can grow without becoming a tangle.

8.01

Hexagonal Architecture

Direct fit

Put the agent core in the middle, and let every model, tool, store and screen plug in through a named port.

DRIVINGDRIVENChatPortModelPortToolPortStorePortCLIterminal inputWeb socketbrowser sessionsEval runnerscripted casesAgent coreloop + policy, no SDKimportsSDK modelor FakeModelMCP clientstdio / HTTPPostgres storeor in-memory
Fig. 8.01The core talks only to ports, so adapters on either side can be swapped without touching it.
Classic meaning

This pattern is also called Ports and Adapters. The application names a port (a fixed plug shape) for everything it talks to, and an adapter connects each port to one real technology.

In the agent system

The agent core declares its ports as interfaces (lists of functions with no code behind them). ChatPort is for whoever drives a session, such as a person typing. ModelPort, ToolPort and StorePort are for things the core itself drives. Driving adapters turn their input into calls on ChatPort. Examples are a CLI (command-line tool), a web socket handler (code that keeps a live browser connection open) and an eval runner (code that runs test cases). Driven adapters fill in the outbound ports. They use the Anthropic SDK, an MCP client (MCP is a standard way to connect tools to an AI model) and a Postgres or file store. The core only speaks through ports. So a test suite can run the real loop against a scripted fake model and an in-memory store.

In a large company

In a large company, the ModelPort adapter usually talks to the internal LLM gateway (one shared front door to all AI models), not straight to a provider SDK. This keeps routing, key custody (who holds the secret API keys) and per-tenant cost tracking outside the core. A tenant is one customer company sharing the platform. Driving adapters multiply: web, IDE (code editor), chat apps, a public API and batch jobs. They all enter through the same inbound port. That port is where the checked tenant and user details should be attached, once. Policy decisions can sit behind a driven PolicyPort. OPA or Cedar (policy engines that answer "is this allowed?") can sit behind it. This keeps permission checks testable with a fake. The key control: every driven adapter for tenant data gets the tenant scope from the port call. It must never take it from shared global state.

Step by step
  1. The core defines inbound and outbound ports as plain interfaces.
  2. Driving adapters (CLI, web, eval runner) turn their input into calls on the inbound port.
  3. The core runs the turn and calls ModelPort, ToolPort and StorePort when needed.
  4. Driven adapters turn those calls into SDK requests, MCP calls and database writes.
Code sketch
class ModelPort(Protocol):
    def complete(self, msgs, tools): ...
class ToolPort(Protocol):
    def run(self, call): ...

class AgentCore:
    def __init__(self, model, tools, store):
        self.model, self.tools = model, tools
        self.store = store

core = AgentCore(SdkModel(), McpTools(),
                 FileStore('runs/'))

Be carefulDo not shape a port after one vendor's API. For example, a port that takes one provider's own message format lets that adapter leak into the core. Design each port around what the agent needs.

8.02

Onion Architecture

Direct fit

Put the agent's core logic at the centre, and make every outer layer depend inward, never outward.

Agent coreTurn, Message,ToolCallDomain servicescompaction, policyApplicationthe agent loopInfrastructureSDK, MCP, DB, UIdepends inward
Fig. 8.02Dependencies point inward, and the parts that change often live on the outside.
Classic meaning

The code is arranged in rings, like an onion, with the core business ideas (the domain model) at the centre. Dependencies always point toward the centre, and infrastructure such as databases lives on the outside.

In the agent system

The innermost ring holds pure ideas: Turn, Message, ToolCall and Plan. Around it sit domain services such as context compaction (shrinking old conversation to save space) and permission rules. The application ring runs the agent loop (the cycle of calling the model and running its tools). The outer ring holds the parts that change often. These are the Anthropic SDK client, MCP servers, the database and the web UI. MCP is a standard way to connect tools to an AI model. Swapping a model provider or a vector store (a database for search by meaning) only touches the outer ring.

In a large company

Enterprise platforms use the onion to keep the agent core free of any one model vendor. The outer ring holds the LLM gateway client (for a central service that routes all model traffic). It also holds MCP connectors, the identity provider and the audit sink (where audit records are sent). All of these sit behind ports, which are interfaces declared further in. A regulated customer can then swap a provider for data residency (keeping data in a set country or region) or for buying reasons. It can also run the same core against a private deployment. Neither change touches the policy logic. Enforce the dependency rule in CI (the automatic checks that run on every code change) with an import linter. Keep policy decisions in the inner rings, so unit tests and evals (automated tests of the agent) check them directly.

Step by step
  1. Define core types (Turn, Message, ToolCall) with no imports from outer layers.
  2. Write the loop and policy logic against interfaces that inner rings declare.
  3. Build those interfaces in the outer ring with real SDKs and stores.
  4. Connect them together at startup.
Code sketch
# core/turn.py: imports nothing outward
class ModelPort(Protocol):
    def complete(self, msgs): ...

# infra/anthropic.py: depends inward
class AnthropicModel(ModelPort):
    def complete(self, msgs):
        return sdk.messages.create(
            model=MODEL, messages=msgs)

Be carefulA single import of an SDK type into the core quietly breaks the onion. Enforce the rule with an import linter (a tool that checks which code may import which).

8.03

Clean Architecture

Direct fit

Arrange the harness in four rings (entities, use cases, adapters, frameworks), with every code dependency pointing inward.

EntitiesTurn, ToolCallrulesUse casesRunTurn, ApproveToolInterface adapterscontrollers, presentersFrameworksSDK, MCP, web, DBController -> use caseplain RunTurnInputUse case -> presenterthrough an output portGateway interfacesowned by the use caseFrameworksdetails kept at the edgedepends inward
Fig. 8.03Use cases own the ports. Controllers carry data in and presenters carry it out.
Classic meaning

The system is built as rings inside rings: entities, use cases, interface adapters and frameworks. Code in an outer ring may depend on an inner ring, never the other way. Data crosses each ring as simple structures.

In the agent system

Entities (the core objects) hold the stable agent rules. For example, a Turn ends with an assistant message. A ToolCall needs a result before the next model call. Use cases such as RunTurn, ApproveToolCall and CompactContext direct those entities. Each use case owns its own input and output ports (fixed plug shapes for data). Interface adapters come in two kinds. Controllers turn an HTTP request into a RunTurnInput. Presenters turn a TurnOutput into SSE events (a live stream of updates to a browser) or a terminal line. The outer ring holds the web server, the Anthropic SDK, MCP clients and the database. MCP is a standard way to connect tools to an AI model. Compared with Onion Architecture, Clean makes the use case the main unit of design. It also splits controllers from presenters.

In a large company

Large companies use the use case ring as the natural place for permission, audit and approval rules. Each use case, such as ApproveToolCall, matches one business action that can be audited. One presenter per surface lets the same RunTurn output feed many places. It can feed SSE for the web app, message edits for a chat integration and JSON for the public API. Frameworks stay in the outer ring. Examples are the LLM gateway client (one shared front door to AI models), the Kafka producer (code that sends events to Kafka, a message stream) and the Postgres driver. So changing a provider or region for data residency (keeping data in a certain country) means swapping adapters. The risk is ceremony. Platform teams should use the full rings for the shared core and let small internal tools stay simpler.

Step by step
  1. A controller in the adapter ring turns an incoming request into a plain input model.
  2. The use case loads entities through a gateway interface it owns and applies the rules.
  3. It hands a plain output model to its output port.
  4. A presenter formats that output for the screen, such as SSE events or terminal lines.
Code sketch
class RunTurn:  # use case ring
    def __init__(self, gateway, presenter):
        self.gw, self.out = gateway, presenter
    def execute(self, inp: RunTurnInput):
        conv = self.gw.load(inp.session_id)
        turn = conv.start_turn(inp.text)
        self.gw.save(conv)
        self.out.present(TurnOutput(turn.id))

class SsePresenter:  # adapter ring
    def present(self, o): sse.send(o)

Be carefulFour rings, plus input and output models for every use case, is a lot of ceremony. It is too heavy for a small tool with one screen. Merge rings until each boundary is worth its cost.

8.04

Layered Architecture

Direct fit

Stack the harness as UI, API, agent runtime and providers, where each layer calls only the layer just below it.

PROVIDER LAYERPOST /turnsrun_turn()complete()call toolnever directChat UIUI layer: render streamHTTP APIAPI layer: auth, quotasAgent runtimeloop, context, compactionProvider clientsmodel SDK, retriesMCPconnectionstool servers
Fig. 8.04Each layer calls only the one below, so the UI can never reach a provider directly.
Classic meaning

Split a system into horizontal layers. Each layer offers services to the layer above and uses only the layer below.

In the agent system

The top layer is the chat UI. It shows the model's words as they stream in, plus cards for each tool. Below it, the HTTP API checks who the caller is, checks the request and links it to a session. The agent runtime owns the loop. It builds the context (the text the model will see), calls the model, sends out tool calls and compacts (shortens) old history. The bottom layer holds provider clients and MCP connections. MCP is a standard way to connect tools to an AI model. The UI never calls the model directly. So rate limits, retries and token counting live in one place. A token is a small chunk of text that models count and bill by.

In a large company

Large agent platforms usually have three tiers. The edge tier holds the API gateway, a WAF (web application firewall) and SSO (single sign-on) through OIDC, a standard login protocol. The application tier holds the session and the agent runtime. The provider tier reaches models only through an LLM gateway (one shared front door to AI models). Strict layers let you prove to auditors that no surface reaches a model or tool without passing login, policy checks and audit logging. SOC 2 and ISO 27001 are the security audit standards in question. Some concerns cut across every layer, such as tenant details and OpenTelemetry trace data. (A tenant is one customer company. OpenTelemetry is a standard for tracing requests across services.) These belong in shared middleware, not in pass-through methods. The common failure: a team adds a shortcut from a UI service straight to the model for a demo. That shortcut skips usage limits and logging.

Step by step
  1. The UI sends a user message to the API layer.
  2. The API checks login and usage limits, then calls the runtime's run_turn.
  3. The runtime builds the context and asks the provider layer for model replies and tool results.
  4. Results flow back up through each layer as a stream of events.
Code sketch
# ui -> api -> runtime -> providers
@app.post('/sessions/<sid>/turns')
def post_turn(sid, body):
    auth.require(request.user, sid)
    return runtime.run_turn(sid, body.text)

class Runtime:
    def run_turn(self, sid, text):
        ctx = self.context.build(sid, text)
        return self.provider.complete(ctx)

Be carefulStrict layers tend to grow pass-through methods that only forward calls. When a layer adds nothing, merge it. Do not add another one.

8.05

Monolithic Architecture

Run the UI server, agent loop, tool runner, scheduler and storage in one process, shipped and scaled as one unit.

ONE PROCESSHTTPcallcallwritetriggerHTTPSBrowserHTTP routesAgent loopTool runnersubprocessSQLite filetranscriptsSchedulerthreadModel APIremote
Fig. 8.05Everything shares one process. The only network trip is the call to the model.
Classic meaning

One application that you ship as a single piece. All its modules share one process, one codebase and usually one database.

In the agent system

Most harnesses start as one process (one running program). It holds an HTTP or command-line front end, the agent loop and a tool runner that runs shell commands. It also holds a scheduler thread for timed tasks and a SQLite file (a small database in one file) for transcripts. The parts call each other with plain function calls. So a tool result reaches the loop without being converted to text first. You can debug a whole session from one stack trace (the list of function calls at a moment). The only network trip is the call to the model API. To scale, you run more copies of the same process behind a load balancer (a router that spreads requests). That forces two changes. The SQLite file must become a shared database. The scheduler needs a lease or leader election (a way to pick one copy in charge). Then each timed task runs once, not once per copy.

In a large company

Many large agent platforms run well as a modular monolith for a long time. They ship as one container image, with several replicas (copies) on Kubernetes behind a load balancer. Kubernetes is a system that runs and manages containers. The company controls are about isolation inside that one unit. Tool runs move to separate sandboxes, such as gVisor or Firecracker microVMs (small, tightly sealed virtual machines). Secrets come from Vault (a secret store) or workload identity, not from files. Tenant scoping is enforced in the data layer, so each tenant (customer company) sees only its own data. HA (high availability: staying up when parts fail) needs at least two replicas in different zones. It also needs a managed database and a distributed lock for schedulers. The risk is one noisy tenant using up shared CPU or database connections. So you need per-tenant limits on parallel work, even in a monolith.

Step by step
  1. One program starts the web server, the scheduler thread and the tool runner.
  2. A request enters the handler, which calls the agent loop directly.
  3. The loop calls the model over HTTPS and runs tools in the same process.
  4. The transcript is written to a local database file as the reply streams back.
Code sketch
def main():
    db = sqlite3.connect('harness.db')
    tools = ToolRunner(workdir='/srv/work')
    loop = AgentLoop(SdkModel(), tools, db)
    start_thread(Scheduler(loop).run)
    serve(WebApp(loop), port=8080)

if __name__ == '__main__':
    main()

Be carefulA tool that runs untrusted code in the same process as the session store can read every user's transcript. So run tools in a sandbox (a sealed-off space), even inside a monolith.

8.06

Event-Driven Architecture

Every harness part publishes typed events and reacts to them, so the loop never calls the UI, store or meter directly.

publishpublishAgent loopTool runnerSession streamordered, typedChat UItext_delta,tool_useTranscriptevery eventUsage meterturn_completePush notifierrun_finished
Fig. 8.06Senders publish once. Each listener picks the events it needs.
Classic meaning

Parts talk by sending and receiving events through a broker or stream. A sender does not know who will react.

In the agent system

The agent loop sends events onto a stream for each session. Examples are message_start, text_delta, tool_use, tool_result and turn_complete. The chat UI subscribes over SSE (a live stream of updates to a browser) to show text and tool cards. The transcript writer saves every event. The usage meter adds up tokens (small chunks of text that models bill by) for billing. A notifier sends a phone alert when a long background run finishes. You can add a new listener, such as a test logger, without changing the loop. The stream keeps events in order for each session. So a client that reconnects can replay everything after its last event id.

In a large company

Large platforms usually run the session stream on Kafka, Google Pub/Sub or Amazon EventBridge. These are services that carry events between systems. They split the stream by session id. That keeps order within each session while the listeners scale out. Listeners include the audit archive with a retention policy (how long to keep data) and the chargeback meter (billing each team). They also include DLP scanners (data loss prevention: tools that catch leaks of sensitive data) and test sampling. Each one is in its own consumer group, so each reads the stream on its own. Events should carry the tenant id (which customer company) and trace context (W3C traceparent, a standard tracking header). They should also carry a schema version that a schema registry checks. The key risk: raw events contain prompts and tool outputs with PII (personal data such as names and emails). So topics need encryption and access lists for each listener. Data also needs redaction (removing sensitive parts) before it reaches analytics.

Step by step
  1. The loop and the tool runner publish typed events to the session stream.
  2. The broker adds each event to the log with a sequence number.
  3. Each subscriber picks out the event types it cares about.
  4. Each subscriber reads from its own cursor (its place marker) on the log. A slow one falls behind and catches up. It does not block the loop.
Code sketch
bus.publish(sid, Event('tool_use', call))

@bus.subscribe('text_delta')
def to_ui(ev): sse[ev.sid].send(ev.data)

@bus.subscribe('*')
def persist(ev): transcript.append(ev)

@bus.subscribe('turn_complete')
def bill(ev): meter.add(ev.usage)

Be carefulWhen handlers send more events, the chains get hard to follow. Put a correlation id (one shared tracking number) on every event. Without it, a stuck turn is very hard to trace.

8.07

CQRSCommand Query Responsibility Segregation

Send user requests down a command path, and show the transcript from a separate read model fed by events.

WRITE SIDEREAD SIDEcommandsacceptappendeventsupsertstreamChat clientCommandhandlervalidate intentAgent runtimeEvent logappend-onlyProjectorfold eventsTranscriptviewread model
Fig. 8.07Commands change state on one side. The transcript is read from a separate view on the other.
Classic meaning

CQRS splits the part that changes data (commands) from the part that answers questions (queries). Then you can shape and scale each part for its own job.

In the agent system

Commands such as send_message, approve_tool_call and cancel_turn go to a command handler. The handler checks each one against the session's current state and then drives the agent runtime. The runtime adds events to an event log. A projector (code that turns events into a ready-to-read view) folds those events into a read model shaped for the transcript UI. That view holds messages with tool cards, approval status and token counts. The UI reads and subscribes to that view. It never asks the runtime for its internal state. So you can rebuild a heavy transcript view or a search index without touching the loop.

In a large company

In large platforms, CQRS appears as one append-only session log (a log you only add to) that feeds several read models. These include the transcript view and an admin console for compliance review. They also include a search index for eDiscovery (finding records for legal cases) and a cost dashboard per tenant (customer company). The write side runs approvals and policy checks. It must check against the true, current state. It must never check against a read model that may be behind. Read models must use the same tenant and RBAC filters as the write side. RBAC is role-based access control: permissions given by job role. GDPR erasure requests (a person's legal right to have data deleted) must reach every view. That is easier when you can rebuild views from a log with sensitive parts removed. CQRS does not need event sourcing (storing every change as an event). But teams often combine the two here, because the log also serves as the audit trail.

Step by step
  1. The client sends a command such as approve_tool(call_id).
  2. The command handler checks it against current state and lets the runtime act.
  3. The runtime adds events like tool_approved and tool_result to the log.
  4. A projector updates the transcript view, and the client gets the change on its stream.
Code sketch
def approve_tool(cmd):           # write side
    s = sessions.load(cmd.sid)
    s.require_pending(cmd.call_id)
    log.append(ToolApproved(cmd.call_id),
               expected=s.version)

def project(ev):                 # read side
    view = views[ev.sid]
    view.card(ev.call_id).status = 'approved'
    push(ev.sid, view.diff())

Be carefulThe read model runs slightly behind the write side. A client that sends approve and reads again at once may see the old state. Show the change straight away in the UI, or wait for the event.

8.08

Anti-Corruption Layer

Translate every MCP server's odd schemas, names and error shapes into one standard tool model before core code sees them.

CORE DOMAINrawcanonicalunmappableMCP server Asnake_case argsMCP server BcamelCase, isErrorLegacy RESTtoolXML error pagesAnti-corruptionlayertranslate both waysReject logunmappable shapesAgent coreToolSpec only
Fig. 8.08Three formats go in, and one standard tool model comes out.
Classic meaning

A layer that translates between your own data model and an outside system's model. It stops the outside design from leaking into your code.

In the agent system

MCP is a standard way to connect tools to an AI model. Connected MCP servers disagree about almost everything. Arguments may be snake_case or camelCase. They use different JSON Schema drafts (versions of a format for describing data). Results come as text blocks or nested JSON. Errors come as isError flags, HTTP codes or plain strings. One translator per server maps each of these into the harness's own types: ToolSpec, ToolResult and ToolError. It adds a prefix to tool names to avoid clashes. It drops fields the core does not understand. The permission engine, the context builder and the transcript only ever see these standard types. When a server changes its schema, only its translator changes.

In a large company

In a large platform the ACL sits in the MCP connector layer. It often sits behind an MCP gateway that the platform team runs for all tenants (customer companies). Besides translating, it is the natural place for several controls. It can allow only approved servers and pin tool descriptions that security has reviewed. It can map outside error codes to standard ones. It can tag every result with its source for audit. Results that cross the layer should pass DLP scanning (data loss prevention: checks that catch leaks of sensitive data). They should also be marked as untrusted before they enter the context window (the text the model can see at one time). The risk is a connector update that quietly changes a tool description. That is why the layer compares description hashes (short fingerprints of the text) on each connect.

Step by step
  1. On connect, the ACL (anti-corruption layer) reads the server's tool list and maps each tool to a standard ToolSpec.
  2. When the model calls a tool, the ACL converts the standard arguments into the server's own format.
  3. It converts the raw reply or error back into a ToolResult or ToolError.
  4. It rejects and logs any shape it cannot map. It never passes it through.
Code sketch
class McpTranslator:
    def to_spec(self, raw):
        name = self.prefix + '__' + raw['name']
        schema = normalise(raw['inputSchema'])
        return ToolSpec(name, schema)
    def to_result(self, raw):
        if raw.get('isError'):
            raise ToolError(text_of(raw))
        return ToolResult(text_of(raw))

Be carefulAn ACL that passes unknown fields through "just in case" stops protecting anything. Also, translating is not cleaning. Tool descriptions and results stay untrusted. So pin (lock) the approved descriptions and flag any change.

8.09

Strangler

Move traffic one intent at a time from an old prompt chain to a new agent loop, until you can delete the old chain.

migratedthe restpromote intentRequestStranglerrouterintent + flagAgent loopmigrated intentsPrompt chainthe shrinking restEval compareshadow traffic
Fig. 8.09A router moves traffic to the new loop one intent at a time, guided by evals.
Classic meaning

Replace an old system bit by bit. You put a facade (a single front door) in front of it and move each feature over to new code, one piece at a time.

In the agent system

Some harnesses are built as a fixed prompt chain: classify, retrieve, draft, format. This works for narrow requests, but it cannot choose tools as it goes. A router sits in front. It reads the request's intent (what the user wants to do) and a migration flag for that intent. Moved intents go to the new agent loop, which can use tools. The rest still go through the old chain. Both paths write the same transcript format and reply shape. So clients cannot tell which path served them. Shadow runs replay live requests through the new loop without showing the result to users. In these runs, tools with side effects (like sending email) are stubbed (replaced by fakes) or run in dry-run mode. Eval comparisons (scored tests) on those runs decide when the next intent moves over.

In a large company

Large companies moving off old prompt chains or older chatbot platforms route traffic through a feature flag service. That service can target by tenant (one customer company). So you can turn an intent on for internal users first, then pilot tenants, then everyone. You can also roll it back at once. The gates for each move are eval suites run in CI (the automatic build and test system). They also include live numbers such as task success, how often requests go to a human, and cost per solved request. Change management records each move for audit. Regulated tenants may need to agree first before their traffic moves to a new model path. The risk is the long tail: the last few intents never move. So the old chain needs an owner and a shut-down date.

Step by step
  1. Every request enters the strangler router, which finds its intent.
  2. Moved intents go to the agent loop. All others go to the old chain.
  3. Both paths return the same reply shape and write the same transcript format.
  4. Eval results move one intent at a time, until nobody uses the chain and it is removed.
Code sketch
MIGRATED = {'summarise', 'search_docs'}

def handle(req):
    intent = classify(req.text)
    if intent in MIGRATED:
        return agent_loop.run(req)
    return legacy_chain.run(req)

# after evals pass on shadow traffic
MIGRATED.add('draft_email')

Be carefulThe two paths might write different transcript or error formats. Then clients start to depend on which path served them, and the migration gets stuck.

8.10

Backends For Frontends

Direct fit

Give each surface (CLI, web, team chat) its own thin backend that fits one shared agent core to that surface.

keystrokesHTTPwebhookrun_turn()CLIWeb appTeam chatCLI backendlocal files, ANSIWeb backendSSE, artifactsChat backendwebhooks, editsAgent coreone session API
Fig. 8.10Each surface gets a backend shaped for it, and all of them share one core.
Classic meaning

Build a separate backend for each kind of client app. Each backend fits how that client works, and its team usually owns it. All the backends sit in front of shared services.

In the agent system

The agent core offers one session API: start a session, run a turn, stream events. The CLI (command-line tool) backend adds local file access, hints for drawing text in the terminal, and handling for when the user interrupts. The web backend packages events as SSE (a live stream of updates to a browser). It also draws tool cards and artifacts, and stores uploads. The team-chat backend answers webhooks (calls from the chat platform) within the platform's few-second limit. It links chat threads to sessions. It turns the live stream into message edits every so often. Each backend changes at its own surface's pace. And the core stays free of special cases like "if surface == 'chat'".

In a large company

Large agent products ship on many surfaces at once. These include a web app, IDE (code editor) plugins, Slack or Microsoft Teams apps, a mobile app and a public API. Each has a BFF (backend for frontend) owned by that surface's team. Each chat BFF must link the chat platform's user id to the company identity from the IdP (identity provider, the company's login system). It does this through SSO (single sign-on) account linking. It then passes that identity to the core. So RBAC (role-based access control: permissions given by job role) and tenant policy are decided in one central place. A tenant is one customer company. BFFs also deal with platform limits, such as time limits to answer webhooks and limits on how fast you can edit messages. The risk is policy drift. So approval rules, DLP (data loss prevention) and audit logging must live in the shared core. Never rebuild them inside a BFF.

Step by step
  1. A surface sends input in its own shape: a keystroke, an HTTP post or a chat webhook.
  2. Its backend maps that to the core's session API and adds surface details.
  3. The core runs the turn and streams general events back.
  4. The backend reshapes the events for its surface, such as SSE frames or edited chat messages.
Code sketch
class ChatBff:  # team-chat surface
    def on_webhook(self, ev):
        ack_soon(ev)  # platform time limit
        sid = threads.session_for(ev.thread)
        stream = core.run_turn(sid, ev.text)
        msg = chat.post(ev.channel, '...')
        for e in stream.coalesce(ms=800):
            msg.edit(render(e))

Be carefulBackends may start to copy agent logic, such as permission checks. Then the surfaces drift apart. Keep policy in the core, and keep only display work in each backend.

8.11

API Gateway

Put one entry point in front of all harness services. It checks identity, limits request rates and routes each request.

HTTPSHTTPSverify keyWeb appSDK clientAPI gatewayauth, quota, routeSessionservice/v1/sessionsFile storage/v1/filesTool sandbox/v1/toolsUsage billing/v1/usage
Fig. 8.11One door checks every request once, then routes it by path.
Classic meaning

One entry point for all clients. It sends each request to the right microservice (a small, separate service). It also handles shared concerns such as login checks and rate limits.

In the agent system

Some harnesses split into separate services, such as sessions, tool sandboxes, file storage and usage billing. They expose all of them through one gateway at /v1. The gateway checks the API key or OAuth token (a login token). It applies rate limits and usage quotas for each org. It adds a request id so the request can be traced. It routes /v1/sessions to the session service and /v1/files to storage. It removes any identity headers the client sent. Then it forwards the checked identity as a signed internal token. The services still check that token before they apply their own permission rules. Streaming routes are passed through with long timeouts and no buffering (holding data back). Clients and SDKs see one host and one login method. Behind it, services can move or split.

In a large company

Large companies run gateways such as Kong, Apigee, AWS API Gateway or ones based on Envoy at the edge. These check OIDC tokens (from a standard login protocol), apply WAF (web application firewall) rules and set rate limits per tenant (customer company). They also log requests. This is different from the LLM gateway on the outgoing side. That one handles model routing, provider keys, prompt logging and charging token costs to the right team. Behind the edge, services check a signed identity token. Or they use mTLS (both sides prove identity with certificates) through a service mesh (a network layer between services). So a hacked internal service cannot pretend to be a tenant. Gateways must pass streaming replies through without buffering. Their idle timeouts must be long enough for agent turns that run for minutes.

Step by step
  1. A client calls one host, for example POST /v1/sessions/{id}/turns.
  2. The gateway checks identity, checks the org's quota and adds a trace id.
  3. It matches the start of the path to a service and forwards the request.
  4. It streams the reply back and turns internal errors into the public format.
Code sketch
ROUTES = {
    '/v1/sessions': 'http://sessions:8000',
    '/v1/files':    'http://files:8000',
    '/v1/tools':    'http://sandbox:8000',
}
def gateway(req):
    key = req.headers['x-api-key']
    org = auth.verify(key)
    limits.check(org, req.path)
    req.headers['x-identity'] = sign(org)
    upstream = match_prefix(ROUTES, req.path)
    return proxy(upstream, req, stream=True)

Be carefulA gateway might start making product decisions, such as which model a turn uses. Then it becomes a second monolith that every team must change. Keep it to routing and edge work.

8.12

Feature Toggle

Decide for each account and session which tools, skills and artifact types exist, by reading flags when the session is built.

startevaluateonoffAccountorg, plan, cohortFlag storerules, rollout %Sessionbuilderevaluate oncecode_execon?Manifest Abase + code_execManifest Bbase tools only
Fig. 8.12Flags are read once at session start and decide what the model can see.
Classic meaning

Turn features on or off while the system runs, using settings. Code can ship hidden and be turned on for chosen users without a new release.

In the agent system

When a session starts, the harness reads flags for this user. It checks them against the tenant (customer company), org, plan and cohort (test group) from the checked identity. For example: is code running allowed? Which skills and MCP connectors are allowed? (MCP is a standard way to connect tools to an AI model.) Is the new compaction (history-shortening) method on for this group? Only allowed tools go into the tools array, and only allowed skills appear in the system prompt. The tool dispatcher (the code that runs tool calls) also rejects any call outside that list. This matters because hiding a tool from the model is not the same as blocking it. Release and experiment flags stay frozen for the whole session. So the tools array and system prompt stay the same, and the prompt cache keeps working. The prompt cache is saved work the provider can reuse when a request starts the same way. Kill switches are the exception. The dispatcher checks them live on each call. So operators can stop a broken tool in running sessions without rebuilding the prompt. Rollouts start with a small share of users. They widen while evals (scored tests) and error rates stay healthy.

In a large company

Large companies read flags through LaunchDarkly, Unleash or an OpenFeature provider (flag services). They target by tenant, plan, region and cohort. Often they let tenant admins turn connectors and skills on or off for their own org. Plan rights and admin policy are better written as permission rules in the policy engine. They should not be long-lived release flags. Do not mix up the two systems. Every flag change goes through change management and is audited. Teams test kill switches often. A rollout only widens if it meets eval and error-rate SLOs (reliability targets). The main risk is the flag service going down. So the harness needs safe defaults that fail closed (block by default) for risky tools.

Step by step
  1. A session start request arrives. The server gets tenant, org and plan from the checked identity.
  2. The session builder reads each relevant flag once for that user.
  3. Allowed tools, skills and artifact types go into a manifest (a fixed list) that is frozen for the session. It drives the system prompt and tools array.
  4. The dispatcher rejects any tool call outside the manifest.
  5. The dispatcher checks kill-switch flags live, so it can stop a tool mid-session without changing the prompt.
Code sketch
def build_session(acct):
    f = flags.evaluate(acct)   # once
    tools = [t for t in ALL_TOOLS
             if f.on('tool.' + t.name)]
    return Session(tools, f.frozen())

def dispatch(s, call):
    if call.name not in s.tool_names:
        return error('tool not enabled')
    if flags.live('kill.' + call.name):
        return error('disabled by ops')
    return run(call)

Be carefulFlags that nobody removes pile up into untested combinations. Give each flag an owner and an end date. Delete it once the rollout is done.

8.13

Component

Adapted fit

Build an agent as a thin entity that carries plug-in components (skills, MCP servers, memory, hooks), each adding tools and prompt text.

COMPONENTStools()Agent loopAgent entityid + parts[]PDF skill+ prompt, + scriptMCP part+ server toolsMemory part+ recall toolHooks part+ pre_tool_use
Fig. 8.13The entity is just a carrier. Behaviour comes from the components bolted onto it.
Classic meaning

An entity (an object) is just a container of separate components. Each component owns one kind of behaviour. Features combine by adding components, not by inheritance (building new classes on top of old ones).

In the agent system

The harness does not have a CodingAgent subclass and a ResearchAgent subclass. Instead it has one Agent entity with a list of components. A skill component adds a prompt section and maybe a script tool. An MCP component adds that server's tools. (MCP is a standard way to connect tools to an AI model.) A memory component adds recall and remember tools. A hooks component registers pre_tool_use checks (code that runs before each tool call). On each turn, the loop asks every component for its tools and prompt pieces. Then it sends each tool_use block (the model's request to run a tool) to the component that owns that tool. A plugin can bundle several components, and you can install it for one session.

In a large company

In large platforms the components are usually plugins from a managed internal marketplace. These are skills, MCP connectors, memory and hooks. Tenant admins approve them, pin them to a version and assign them to groups. (A tenant is one customer company.) Control here means signed plugin bundles and a security review before listing. It also means an allowlist for each tenant, checked when components attach. Hooks that carry company policy should come from managed settings. These settings must win over components that users install. Then a plugin cannot register a hook that weakens them. Each component's tools should record their source in audit logs. So you can trace a tool call back to the plugin and version that supplied it.

Step by step
  1. The harness creates an Agent entity with an id and an empty component list.
  2. Installed plugins attach components such as a skill, an MCP server or memory.
  3. Before each model call, the loop collects tools and prompt text from every component.
  4. Each tool_use block goes to the component that registered that tool.
Code sketch
class Agent:
    def __init__(self, components):
        self.parts = components
    def tools(self):
        return [t for p in self.parts
                for t in p.tools()]
    def prompt(self):
        return '\n'.join(p.prompt()
                         for p in self.parts)

agent = Agent([PdfSkill(), McpPart(git),
               Memory(store), Hooks(policy)])

Be carefulTwo components might both register a tool named search. They clash silently, unless the entity rejects or prefixes duplicate names when they attach.

8.14

Subclass Sandbox

Let plugin and skill authors subclass one base class, and build behaviour only from the safe building blocks it provides.

HARNESS SANDBOXextendsblocked_tool()ChangelogSkillReviewSkillScraperSkillSkill base class_read _tool _emit _askWorkspace FSsandboxed pathsPermissiongateapproves _toolEvent busUI updatesRaw importssubprocess,sockets
Fig. 8.14Subclasses only touch the base class, and every building block passes through the sandbox.
Classic meaning

Subclasses define behaviour using a set of protected operations from a base class. This keeps the subclasses separate from the rest of the system.

In the agent system

A plugin SDK (a kit for building plugins) offers a Skill base class with protected methods. These read files inside the workspace, call a tool through the permission gate, send an event to the UI, ask the user to confirm, and log. Plugin authors override run() and combine those building blocks. They do not import the HTTP client, the database driver or subprocess (the module that runs other programs). The pattern keeps plugins separate from the rest of the code. But a base class is not a security boundary. So outside plugins run in an isolated process or a WASM runtime (a sealed space for running code). There, each building block is an RPC (a remote call) back to the host. The host enforces sandbox rules, approvals and rate limits in one place. It can also change how a building block works, say from local disk to a remote sandbox, without breaking any plugin.

In a large company

Large company plugin SDKs follow this shape, so platform teams can control what extensions do. Every file read, tool call and user prompt goes through building blocks. These apply RBAC (role-based access control: permissions given by job role), approvals, DLP (data loss prevention) and audit logging. Real isolation comes from running plugin code in containers, gVisor, Firecracker microVMs or WASM runtimes. (These are all sealed spaces for running code.) The code gets no ambient credentials (keys lying around for it to grab). Network policy limits where it can send data. Plugins are signed, reviewed and pinned to a version for each tenant (customer company). The host can also turn one off centrally. The risk is building-block creep. Each new building block widens what every plugin can do, so it needs its own security review.

Step by step
  1. The harness ships a base class with a small set of protected building blocks.
  2. A plugin subclasses it and writes run() using only those building blocks.
  3. The plugin runs in an isolated process or WASM runtime, with no direct file, network or subprocess access.
  4. Each building block calls back to the host. The host applies the workspace sandbox, the permission gate and rate limits.
  5. A check on imports at load time gives authors early errors. But the isolation is the real security boundary.
Code sketch
class Skill:
    def _read(self, p): return ws.read(p)
    def _tool(self, name, **args):
        return gate.call(name, args)
    def _emit(self, ev): bus.publish(ev)
    def run(self, task): ...

class ChangelogSkill(Skill):
    def run(self, task):
        log = self._tool('git_log', n=50)
        self._emit(Draft(summarise(log)))

Be carefulThe base class might gain a building block for every plugin request. Then it becomes a god object (one class that does everything). Add building blocks slowly, and make each one safe on its own.

8.15

Dependency Injection

Direct fit

Build the model client, tool registry and stores at startup, and pass them into an agent loop that never builds its own.

new(api_key)from_mcp(servers)open(db_url)AgentLoop(m, t, s)evals pass FakeModel and StubTools at this pointcomplete(msgs)run(tool_use)Compose rootModel clientTool registryTranscriptAgent loop
Fig. 8.15The loop gets its helpers ready-made and never builds them itself.
Classic meaning

Give an object the things it depends on from outside, usually through its constructor. The object does not create or look them up itself.

In the agent system

The composition root is the one place where the program is wired together, such as main or a small container. It reads the settings and creates the Anthropic SDK client with its retry policy. It connects MCP servers into a ToolRegistry. (MCP is a standard way to connect tools to an AI model.) It opens the transcript store and builds a PermissionPolicy. Then it builds AgentLoop(model, tools, store, policy). The loop only calls methods on what it was given. In evals (scored tests) and unit tests, the same loop gets a scripted FakeModel, stub (fake) tools and an in-memory store. So a full turn runs offline and gives the same result every time.

In a large company

A multi-tenant platform serves many tenants (customer companies). Its composition root builds long-lived shared services once, such as the LLM gateway client (one shared front door to AI models) and connection pools. It builds tenant-specific parts for each request. Examples are a tool registry filtered by tenant policy, and a store tied to the tenant's own section of data. Secrets come in at startup from Vault, a cloud KMS (key management service) or workload identity. They are not read from settings files. The main risk is a lifetime mistake. A singleton (one shared object for the whole program) might capture one tenant's keys or tool list. Then it leaks them to every other tenant. Plain wiring also makes it easy to pass in OpenTelemetry tracers and fakes for eval checks in CI. OpenTelemetry is a standard for tracing requests. CI is the automatic build and test system.

Step by step
  1. At startup, the composition root reads settings and secrets.
  2. It builds each dependency: model client, tool registry, store and policy.
  3. It passes them into the AgentLoop constructor.
  4. Tests build the same loop with fakes in place of the real clients.
Code sketch
def compose(cfg):
    model = SdkModel(cfg.api_key, retries=3)
    tools = ToolRegistry.from_mcp(cfg.servers)
    store = PgStore(cfg.db_url)
    return AgentLoop(model, tools, store)

# in an eval
loop = AgentLoop(FakeModel(script),
                 StubTools(), MemoryStore())

Be carefulA container that finds dependencies by name hides wiring mistakes until the program runs. For most harnesses, plain constructor calls in one setup function are clearer.

8.16

Service Layer

Offer the harness through a few app operations, such as create_session and send_turn, that every surface calls the same way.

SERVICE LAYERsend_turn()start()check()load / saveWeb APICLIScheduled taskSession servicecreate, send_turn, approveAgent loopPermissionpolicySession repo
Fig. 8.16Every surface calls the same few operations, which direct everything behind them.
Classic meaning

Mark the edge of an app with a layer of services. For each thing a client can do, a service directs the domain objects (the core business objects) and the database transaction.

In the agent system

A SessionService offers create_session, send_turn, approve_tool_call, cancel_turn and fork_session. Each operation checks that the caller is allowed and loads the session. Then it runs the agent loop or the permission policy. It saves state in one transaction (a group of database changes that succeed or fail together). It returns a result or a stream of events. The web API, the CLI (command-line tool), scheduled tasks and the SDK all call these same operations. So rules like "only one running turn per session" are enforced in one place. Domain objects such as Conversation and PermissionPolicy stay free of web and login code.

In a large company

In large platforms the service layer applies several controls to every operation, whichever surface called it. It asks a policy engine such as OPA or Cedar whether the action is allowed. It writes audit records and checks each tenant's budget. (A tenant is one customer company.) It also uses idempotency keys (tags that make a repeated request safe to run twice). Linking each operation to a named permission makes RBAC easy to review. RBAC is role-based access control: permissions given by job role. It also gives auditors one place to inspect for SOC 2 (a security audit standard). Long turns usually pass to a durable workflow, such as Temporal, once the operation saves. (A durable workflow survives crashes and restarts.) So the service returns a handle instead of keeping a request open. The risk is surfaces that call repositories directly and skip these controls. Internal batch jobs count too.

Step by step
  1. A surface calls an operation such as send_turn(session_id, text).
  2. The service checks the caller is allowed and loads the session from its repository (its storage class).
  3. It directs the domain objects: the loop, the permission policy, the budget.
  4. It saves the changes and returns a stream or result to the caller.
Code sketch
class SessionService:
    def send_turn(self, user, sid, text):
        with self.uow:
            s = self.repo.load_for_update(sid)
            self.authz.require(user, s, 'w')
            if s.has_running_turn():
                raise Busy(sid)
            s.mark_turn_running()
            self.repo.save(s)
        # model call starts after commit
        return self.loop.start(s, text)

Be carefulA surface might skip the service and call the loop or repository directly. Then the shared rules quietly stop applying to it.

8.17

Business Delegate

Give app code a local client that hides how the remote harness is found, called and retried, and how it fails.

run(prompt)endpoint('agents')eu-1 URLPOST /sessions529 overloadedbackoff, retryPOST /sessionseventsTaskResultApp codeTask clientEndpoint lookupRemote harness
Fig. 8.17App code makes one local call. The delegate handles lookup, retries and error shapes.
Classic meaning

An object on the client side that hides the details of finding and calling remote business services. It also handles retries and turns errors into simpler ones.

In the agent system

App code that wants an agent to do some work calls TaskClient.run(prompt). The delegate does the rest. It picks which regional endpoint (server address) to use and attaches credentials. It opens the session. It retries 429 and 529 replies ("too many requests" and "overloaded") with backoff (waiting longer each time). It honours the Retry-After header and resends the same idempotency key. (That key makes a repeated request safe to run twice.) If the event stream drops, it resumes from the last event id. It turns network errors into a few business errors, such as TaskRejected or TaskTimedOut. The calling code stays the same when the harness moves regions or switches from REST to WebSocket.

In a large company

Large companies ship business delegates as internal client libraries. Business apps use them to call the agent platform. The library handles SSO (single sign-on) token exchange, mTLS (both sides prove identity with certificates) and tenant headers inside. (A tenant is one customer company.) Endpoint lookup must respect data residency (keeping data in a certain region). So an EU tenant's delegate only finds EU endpoints. It never switches to another region unless a policy allows it. The delegate should pass along W3C trace context, a standard tracking header. Then the calling app and the harness appear in one OpenTelemetry trace (a record of one request across services). The risk is many client apps retrying at once and making an overload worse. So retries need jitter (random delay), a budget and respect for Retry-After.

Step by step
  1. The app calls a plain method on the delegate, such as run(prompt).
  2. The delegate looks up the endpoint and logs in.
  3. It makes the remote calls and retries short-lived failures with backoff.
  4. It returns a business result, or raises one of a few business errors.
Code sketch
class TaskClient:
    def run(self, p):
        url = self.lookup.endpoint('agents')
        key = new_idempotency_key()
        for wait in backoff(tries=4):
            try:
                return self._post(url, p, key)
            except Overloaded as e:
                sleep(e.retry_after or wait)
        raise TaskUnavailable(p[:40])

Be carefulA delegate might retry a call that is not idempotent (not safe to run twice), such as starting a session that sends email. Then the side effect can happen twice. Send an idempotency key with every retry.

8.18

Session Facade

Offer one big remote call that does a whole job on the server, so clients do not chain many small calls.

start_task(spec)one round trip, not fivecreate_session()attach(files)configure(tools)run_turn(prompt)event streamhandle + streamSDK clientTask facadeSession storeAgent runtime
Fig. 8.18One remote call goes in, many local calls run behind the facade, and one handle comes out.
Classic meaning

A facade (a single front door) on the server that wraps many small business objects behind a few big operations. It cuts the number of remote calls and keeps the workflow logic on the server.

In the agent system

Without a facade, an SDK client would make five calls. It would create a session, upload files, register tools, set the system prompt and then send the first turn. That is five round trips, and any one can fail halfway. A start_task(spec) endpoint on the server does all of it in one call. It returns a session handle plus the event stream. Files and the runtime live outside the session database, so one database transaction cannot cover them. So the facade creates the session as "pending". It only turns it on when every step succeeds, and it cleans up on failure. An idempotency key on the request makes client retries safe. (That key makes a repeated request safe to run twice.) The small internal APIs for the session store, file service and runtime stay hidden and can change freely. Waiting time drops, and half-set-up sessions disappear.

In a large company

Large platforms offer big operations like start_task to integration teams and workflow tools. This cuts chatty traffic across regions. It also keeps the order of policy checks on the server. The facade is a good place to apply tenant policy once for the whole job. (A tenant is one customer company.) That covers allowed tools, the data class of uploaded files, DLP scanning (checks that catch leaks of sensitive data) and reserving budget. For long jobs it usually starts a durable workflow in Temporal or AWS Step Functions and returns a handle. (A durable workflow survives crashes and restarts.) Then the engine handles retries and compensation (undoing steps after a failure). The risk is a facade that quietly grants every tool the request asks for. So it must only grant requested tools that the tenant and caller are both allowed to use.

Step by step
  1. The client sends one start_task request with the prompt, files and tool list.
  2. The facade creates the session and stores the files.
  3. It sets up tools and starts the first turn on the runtime.
  4. It returns a single handle and event stream to the client.
Code sketch
@api.post('/v1/tasks')
def start_task(spec):
    with uow:
        s = sessions.create(spec.owner)
        files.attach(s.id, spec.files)
        runtime.configure(s.id, spec.tools)
    stream = runtime.run_turn(s.id, spec.prompt)
    return TaskHandle(s.id, stream)

Be carefulA facade might grow a flag for every variation. Then it becomes one giant procedure. Keep a few well-named big operations, and let unusual clients use the internal APIs.

8.19

Separated Interface

Direct fit

Keep SDK interfaces in one package and their implementations in others, so code depends on the contract, never a provider.

HARNESS-COREHARNESS-INTERFACESPROVIDER PACKAGESusesimplementsimplementsimplementsAgent coreModelClientcomplete()ToolHostcall()TranscriptStoreappend()AnthropicClientMcpToolHostPgTranscript
Fig. 8.19The core and implementations both point at the interface package, never at each other.
Classic meaning

Put an interface (a list of functions with no code behind them) in a different package from the code that fills it in. Then clients only depend on the package that holds the interface.

In the agent system

A harness-interfaces package declares ModelClient, ToolHost and TranscriptStore. It imports no outside libraries. The agent core and the plugin SDK depend only on that package. Separate packages, such as harness-anthropic, harness-mcp and harness-postgres, fill in the interfaces. They pull in the heavy libraries. A user who installs the CLI (command-line tool) with a local SQLite store never downloads the Postgres driver. And a third party can publish a new sandbox backend without any change to the core.

In a large company

Large platforms publish the interface package as the contract for internal teams and third parties. These groups build tool hosts, model adapters or storage backends. The package uses semantic versioning (version numbers that signal breaking changes). It comes with contract tests that every implementation must pass. So a bank can plug in its own LLM gateway client (its shared front door to AI models). Or it can plug in an on-premises vector store (a database for search by meaning, run in its own building). It does not need to fork (copy and change) the core. Supply-chain controls apply to the implementation packages. These are signed artifacts, an SBOM (a list of every library inside) and dependency scanning. The risk is a breaking change to the interface package spreading through every plugin. So removals need a published warning period.

Step by step
  1. Put interface definitions and shared types in a small package with no heavy libraries.
  2. Make the core and plugins import only that package.
  3. Fill in each interface in its own package, which depends on the interface package.
  4. Choose implementations at install time and wire them in at startup.
Code sketch
# harness_interfaces/model.py
class ModelClient(Protocol):
    def complete(self, req: Request) -> Reply:
        ...

# harness_anthropic/client.py
from harness_interfaces import ModelClient
class AnthropicClient(ModelClient):
    def complete(self, req):
        return self.sdk.messages.create(...)

Be carefulDo not split every interface into its own package when there is only one implementation. That adds version-tracking pain for no gain. Split only where real alternatives exist.

8.20

Domain Model

Direct fit

Put turn-handling rules inside rich objects like Conversation, Turn and ToolCall, so behaviour lives with the data it protects.

AGGREGATEsubmit(text)load / saveturns[]charge()tool_callspending > doneTurn handlerload, call, saveConversationrepoConversationsubmit(), fork()Turnmay_call_model()TokenBudgetcharge(),exhaustedToolCallapprove(),finish()
Fig. 8.20The handler calls one method. The objects enforce the rules among themselves.
Classic meaning

An object model of the business area that joins data and behaviour. Business rules are written as methods on the objects themselves.

In the agent system

A Conversation aggregate (a group of objects saved and changed as one unit) owns its Turns. Its submit() method refuses new input while a turn is still running. A ToolCall moves through pending, approved, running and done. It rejects any step that breaks that order. A TokenBudget charges usage and reports when it runs out. (A token is a small chunk of text that models count and bill by.) The request handler just loads the Conversation, calls one method and saves it. Some rules matter a lot. For example, every tool_use (the model's request to run a tool) must get a tool_result before the next model call. Rules like this are enforced in one place. They are unit-tested without a database.

In a large company

In large platforms, the domain model is where approval rules become code that is enforced. ToolCall.approve can require that the approver has the right role and is not the person who asked. This supports separation of duties (no one person can both ask and approve). Aggregates such as Conversation carry the tenant id (which customer company) and the data class. So the objects themselves can check rules on how long data is kept and where it is stored. Each change of state sends out domain events (records of what happened) that feed the audit trail. The risk is that permission checks drift out of the model and into handlers on some surfaces. So unit-test the invariants that compliance depends on at the model level.

Step by step
  1. The handler loads a Conversation aggregate from its repository (its storage class).
  2. It calls a behaviour method such as conversation.submit(text).
  3. The objects check invariants (rules that must always hold) and update each other: Turn, ToolCall, TokenBudget.
  4. The repository saves the whole aggregate in one go.
Code sketch
class ToolCall:
    def approve(self, by):
        if self.state != 'pending':
            raise BadTransition(self.state)
        self.state = 'approved'
        self.approver = by

class Conversation:
    def submit(self, text):
        if self.turns and self.turns[-1].open:
            raise TurnInProgress()
        self.turns.append(Turn.start(text))

Be carefulLoading the full rich model on every turn gets slow for long transcripts. Load old turns only when needed, or keep them as summaries inside the aggregate.

8.21

Transaction Script

Handle each request with one procedure that runs top to bottom: load rows, call the model, run tools, write rows, return.

complete()SQLSQLSQLPOST /turnPOST /approvePOST /cancelhandle_turn()load > call > savehandle_approve()update one rowhandle_cancel()mark cancelledModel APIDatabaseplain rows
Fig. 8.21Each request has its own straight-line procedure that talks to the database directly.
Classic meaning

Organise business logic as procedures, one for each request or transaction. Each procedure talks to the database directly.

In the agent system

An early harness often has one function per endpoint (web address). For example, handle_turn reads the session's messages and builds the prompt. It calls the model, then loops over the tool_use blocks (the model's requests to run tools) and runs each tool. It adds the new message rows and returns the reply. handle_approve and handle_cancel are separate procedures with their own SQL (database queries). There are no domain objects (core business objects). So each script is easy to read and you can debug it from start to end. As the harness grows, shared rules such as the token budget check get copied between scripts.

In a large company

Transaction scripts are common in company proofs of concept. They are also common in internal tools built quickly on one model endpoint. They become a compliance problem as they multiply. Each script rebuilds its own tenant checks, audit logging, PII redaction and budget checks. (A tenant is one customer company. PII is personal data such as names and emails.) Sooner or later one of them skips a control. A common fix is to move shared rules into a service layer. Then put model access behind the LLM gateway (one shared front door to AI models). That makes usage tracking and logging the same everywhere. At the least, each script should limit steps and spending per request. It should also write an audit record for every tool call.

Step by step
  1. Each request goes to its own script, such as handle_turn.
  2. The script reads the rows it needs straight from the database.
  3. It calls the model and runs tools in a plain loop with a step limit.
  4. It saves each message as it goes, so tool calls that ran are on record. Then it returns the reply.
Code sketch
def handle_turn(sid, text):
    msgs = db.messages(sid) + [user(text)]
    db.append(sid, msgs[-1])
    for _ in range(MAX_STEPS):
        r = model.complete(msgs, TOOLS)
        msgs.append(r.message)
        db.append(sid, r.message)
        if r.stop_reason != 'tool_use':
            return r.text
        res = [run(u) for u in r.tool_uses]
        msgs.append(user(res))  # one message
        db.append(sid, msgs[-1])
    raise StepLimit(sid)

Be carefulSeveral scripts may end up repeating the same budget, permission and transcript logic. Then a fix lands in one script and not the others. That is the moment to pull out a service layer or domain model.

8.22

Table Module

Write one module per table or collection (sessions, messages, tool_calls) that holds the logic for all its rows at once.

for_session()SELECTreturnspassed topending(rows)UPDATETurn handlerMessagesModulefor_session, trimmessagestableRecord setrows, not objectsToolCallsModulepending, approvetool_callstable
Fig. 8.22One module per table works on whole record sets, which pass from module to module.
Classic meaning

One object handles the business logic for every row in a database table or view. It works on sets of records, not one object per row.

In the agent system

A MessagesModule offers for_session(sid), token_total(rows) and trim_to_budget(rows, limit). A ToolCallsModule offers pending(rows) and mark_approved(ids). The turn handler gets a record set (a batch of rows) of messages from one module. It hands that set to the next module. This suits harnesses already built on a SQL or dataframe-style store (data kept as tables). Bulk jobs come naturally. Examples are expiring every tool call older than an hour, or totalling tokens per org. (A token is a small chunk of text that models bill by.) This pattern sits between Transaction Script and Domain Model. Logic is grouped by table, but there is no object for each message.

In a large company

Table modules suit company reporting and control jobs that work on whole sets of rows. Examples are totalling tokens per tenant for chargeback (billing each customer), expiring old approvals, or applying retention rules and legal holds across the messages table. (A tenant is one customer company. A legal hold stops data from being deleted during a legal case.) These jobs run in batches against a data warehouse or the live database. So every query must be limited to one tenant id. Ideally row-level security (rules in the database that limit which rows each user sees) enforces this. Each module should not have to remember a filter. GDPR erasure (a person's legal right to have data deleted) fits this shape too. One module call can remove every row about one person. The risk is a bulk update that crosses tenants because someone missed a filter.

Step by step
  1. The turn handler asks MessagesModule for the session's record set.
  2. It passes that record set to functions such as trim_to_budget.
  3. ToolCallsModule works on the waiting tool-call rows for the same session.
  4. Each module writes its changes back to its own table.
Code sketch
class MessagesModule:  # one per table
    def for_session(self, sid):
        return db.select('messages', sid=sid)
    def trim_to_budget(self, rows, limit):
        # cut at turn edges so no tool_use
        # loses its tool_result
        keep, total = [], 0
        for t in reversed(turns(rows)):
            total += tokens(t)
            if total > limit: break
            keep = t + keep
        return keep

Be carefulTable modules fit badly when a rule covers more than one table. One example is a tool call that changes the session's state. That logic drifts into the handler, with no clear home.

09
Chapter 9 · Small functions, joined together

Functional Plumbing

A lot of agent work is data passing through steps: messages become a prompt, the model's reply becomes tool requests, tool results become a summary. Functional patterns keep each step small and easy to chain together safely.

9.01

Collection Pipeline

Prepare the transcript for the model as a chain of filter, map and take steps over the message list.

MSGS.FILTER().MAP().TAKE_LAST()41238038096 messagesTranscript412 recordsfilterdrop ui recordsmapstub stale, to APItake_last60k tokensRequestmessages[]
Fig. 9.01Each operation returns a new list, smaller or cleaner than the last.
Classic meaning

Organise work as a series of operations that each take a collection and return a new one. The usual ones are filter (keep some items), map (change each item) and reduce (combine them).

In the agent system

Turning a stored transcript into the messages for the next request is a collection task. First the harness filters out events that are only for the screen. Then it maps old tool results to a short placeholder. This way every tool_use (the model's tool request) still has its tool_result (the tool's answer). Next it maps each record into the API's message format and cuts down outputs that are too large. Then it takes messages from the newest end until the token budget is reached. Each step is a pure operation, with no side effects, over lists that never change. So each step is easy to test, and preparing a request never changes the transcript on disk. Keep the output the same from turn to turn where you can. This matters for the prompt cache (saved work the provider can reuse when a request starts the same way). The cache only reuses an unchanged prefix, the start of the request.

In a large company

Enterprise harnesses build the request with the same kind of chain, plus extra stages. One stage does PII redaction (removing personal data such as names and emails). Another removes content that the tenant (the customer company) has marked as not to be shared with the model. A third checks residency rules before the request leaves the region. The stored transcript is the system of record for audit, meaning the official copy. So the chain must never change it. It makes a separate derived view for each request. The cost control is cache-awareness. Stable prefixes keep prompt cache hit rates high, and that shows up directly in each tenant's bill.

Step by step
  1. Start from the full list of stored transcript records.
  2. Filter out records the model should not see.
  3. Map each remaining record to an API message, and cut down large content.
  4. Take from the newest end until the token budget is used, then send.
Code sketch
msgs = (Seq(transcript)
        .filter(lambda r: r.kind != 'ui')
        .map(stub_if_stale)  # keep pairs
        .map(to_api_message)
        .map(truncate(max_chars=8000))
        .take_last_within(tokens=60_000)
        .to_list())

Be carefulTaking from the end can split a tool_use from its tool_result. A window that slides every turn also defeats the prompt cache. Drop each pair together, and trim in larger steps.

9.02

Function Composition

Build one tool-result formatter by joining small single-purpose functions, then reuse that joined function everywhere.

composeapplyone callstrip_ansiredacttruncateRaw stdoutcolour codes, keysformat_resultcompose(a, b, c)tool_resultclean, bounded
Fig. 9.02Small functions snap together into one function that is applied once.
Classic meaning

Join simple functions into a new function, where each one's output becomes the next one's input. In maths, compose(f, g)(x) means f(g(x)), which runs right to left. The left-to-right form used here is often called pipe.

In the agent system

Raw tool output needs several clean-ups before it goes back to the model. The harness strips ANSI colour codes, the hidden characters that colour terminal text. It redacts (hides) anything that looks like a credential, such as a password or key. It cuts the text to a size limit and wraps it in a tool_result block (the tool's answer to the model). Each clean-up is a small pure function, with no side effects, that takes a string and returns a string. The harness composes (joins) them once into format_result. It then uses that one function for every tool. A tool with special needs composes a slightly different chain from the same parts.

In a large company

Enterprise platforms compose tool-output cleaners in one central place. This is often the LLM gateway (the central service all model calls pass through) or a shared harness library. So every team gets the same ANSI stripping, secret redaction, PII masking and size limits. PII is personal data such as names and emails. Regex-based secret redaction, which finds secrets by text patterns, is only an extra layer of defence. The main control is that the tool runtime injects credentials from Vault or workload identity. Vault is a secrets store, and workload identity gives each service its own login. Then credentials never appear in tool output at all. Each composed formatter should have a version number and tests that use known fake secrets. Then a reordering that weakens redaction fails CI, the automatic checks run on every change.

Step by step
  1. Write each clean-up as a small function with one input and one output.
  2. Compose them in order into a single named function.
  3. Apply the composed function to every raw tool output.
  4. Build variants by composing a different set of the same parts.
Code sketch
def compose(*fns):
    return lambda x: reduce(
        lambda acc, f: f(acc), fns, x)

format_result = compose(strip_ansi,
                        redact_secrets,
                        truncate(16_000),
                        to_tool_result)

block = format_result(proc.stdout)

Be carefulOrder matters. Cutting the text before redacting can split a secret in half, so the redactor misses it.

9.03

Combinator

Build complex permission rules or output checkers from a few basic checks and operators such as all_of, any_of and not_.

checkfalsenot runTool calledit .envall_ofreturns predicatenot_(*.env)any_ofread_onlyin_workspace
Fig. 9.03Operators combine checks into a bigger check of the same shape.
Classic meaning

Build complex functions by combining simpler ones. You use higher-order functions (functions that take and return functions) of the same type.

In the agent system

Permission policies grow fast when written as nested if-statements. With combinators, the harness offers basic predicates over a tool call. A predicate is a check that answers true or false, such as read_only, in_workspace and matches(glob). A glob is a file name pattern, like *.env. The harness also offers the operators all_of, any_of and not_. Each one takes predicates and returns a new predicate. Take the policy all_of(not_(matches('*.env')), any_of(read_only, in_workspace)). It is itself a predicate. So it can be nested further, tested on its own, and built from configuration. The same approach builds output checkers and parsers for structured model output.

In a large company

Enterprise policy languages such as Cedar and Rego are combinator systems at heart. They join small conditions with and, or and not into policies that can be tested and versioned. Platforms often let tenants (customer companies) build their own tool policies from approved basic checks. The platform wraps its required deny rules around the outside, so a tenant rule cannot weaken them. Each decision should carry an explanation of the deciding branch into the audit log. Reviewers and incident responders depend on it.

Step by step
  1. Define basic predicates that take a tool call and return true or false.
  2. Define operators that take predicates and return a new predicate.
  3. Combine them into a policy expression.
  4. Call the policy on each tool call before it runs.
Code sketch
def all_of(*ps):
    return lambda c: all(p(c) for p in ps)
def any_of(*ps):
    return lambda c: any(p(c) for p in ps)
def not_(p):
    return lambda c: not p(c)

allow = all_of(not_(matches('*.env')),
               any_of(read_only,
                      in_workspace))
if not allow(call): ask_user(call)

Be carefulDeeply nested combinators are hard to debug. Give each policy an explain() method that reports which branch made the decision.

9.04

Currying

Direct fit

Turn a tool runner with many inputs into a chain of one-input functions, fixing the sandbox at startup and permissions per session.

sbx=dockerperms=usercallperms=strictrun_tool(sbx)(perms)(call)in_sbx(perms)(call)session_run(call)tool_useevery turnsubagent_run(call)
Fig. 9.04Each stage fixes one argument and hands on a smaller function.
Classic meaning

Turn a function with several arguments into a series of functions that each take one argument. This allows partial application, which means filling in some arguments early.

In the agent system

Running a tool needs a sandbox (a locked-down area where code runs safely), a permission policy and the tool call itself. But the harness learns these at different times. It picks the sandbox when it starts. It sets the permission policy when a session begins. A tool call arrives every turn. So run_tool is written as a curried function. Startup code fills in the sandbox to make run_in_sandbox. The session fills in the policy to make session_run. The loop then just calls session_run(call). Subagents (helper agents) get their own partly filled runner, with a stricter policy and the same sandbox.

In a large company

Enterprise harnesses use the same staging. They fix the tenant configuration when a worker (a server process) starts. They fix the authenticated principal (the verified user or service) and its policy when a session starts. The tool call comes each turn. Identity is fixed early inside a closure, a function that remembers the values it was given. This means the model can supply only the call. It can never supply the tenant, role or credentials. This removes a whole class of confused-deputy bugs, where a trusted program is tricked into using its power for someone else. Short-lived credentials captured this way must be refreshed or fixed again when they expire. They should not be kept for the whole of a long session.

Step by step
  1. Write the runner so each argument can be given separately.
  2. At startup, fill in the sandbox to get a narrower function.
  3. At session start, fill in the permission policy.
  4. In the loop, call the resulting one-input function with each tool call.
Code sketch
def run_tool(sandbox):
    def with_perms(perms):
        def run(call):
            perms.check(call)
            return sandbox.exec(call)
        return run
    return with_perms

in_sbx = run_tool(DockerSandbox())  # boot
session_run = in_sbx(user_perms)    # session
result = session_run(call)          # turn

Be carefulCurried layers hide which values were fixed where. Name each partial function after what it has captured.

9.05

Monad

Direct fit

Chain tool steps that return Result values (a success or an error), so the first error skips the rest and arrives as one clean failure.

Ok(args)Ok(call)Ok(out)Errpass throughvalidateschema checkauthorizepermissionsexecuterun tooltool_resultOk(output)Err trackskips later stepsError resultis_error: true
Fig. 9.05Steps run on the Ok track, and the first Err skips straight to the end.
Classic meaning

A type that wraps a value with extra context, such as the chance of failure. It provides bind, which chains functions that return that type and handles the context for you.

In the agent system

Running a tool call has several steps, and each one can fail. The harness parses the JSON arguments and checks them against the schema (the expected shape). It checks permissions, runs the tool and formats the output. Each step returns Ok(value) for success or Err(reason) for failure. bind chains the steps, so the next step runs only on Ok. The first Err passes through the rest untouched. It becomes a tool_result with is_error set, a flag that marks the tool's answer as an error. The model reads it and can correct itself. The harness never throws an exception in the middle of the chain. Every failure carries a clear message instead of a stack trace, which is a dump of the program's internal calls.

In a large company

Enterprise harnesses use Result chains so that failures are typed (labelled by kind) and auditable. This covers failures in validation, authorization, running the tool and output filtering. Without this, they get lost in exception traces. The error kind drives policy. Denials are logged as security events. Timeouts and HTTP 429 or 5xx errors (rate limits and server failures) are retried with backoff, waiting longer each time. But this happens only when the step is idempotent (safe to run twice with the same result). Validation errors go back to the model. Error messages sent to the model should be cleaned first. Stack traces and internal hostnames are a common way for data to leak out.

Step by step
  1. Each step takes a plain value and returns Ok or Err.
  2. Start the chain with Ok(raw tool_use input).
  3. bind runs the next step only when the current value is Ok.
  4. At the end, Ok becomes a tool_result and Err becomes an error tool_result.
Code sketch
res = (Ok(block.input)
       .bind(validate(schema))
       .bind(authorize(perms))
       .bind(execute(tool))
       .map(format_output))

if res.is_ok:
    reply = tool_result(block.id, res.value)
else:
    reply = tool_error(block.id, res.error)

Be carefulWrapping everything in Result hides which failures are worth retrying. Carry an error kind, so the loop can retry timeouts but not denials.

10
Chapter 10 · Where people meet the agent

The Harness Frontend

A chat window looks simple, but behind it the reply arrives word by word, tool activity appears as cards, approval buttons pop up, and documents open beside the chat. These patterns organise the screen side of the system.

10.01

MVCModel-View-Controller

Split the chat app into three parts: a model that holds the conversation, a view that shows it, and a controller that changes it.

keysappendnotifyreadrendersdeltasUserInputcontrollercomposer keysTranscriptviewredraws on changeConversationmessages, statusAgent loopstreams deltas
Fig. 10.01Input goes through the controller. The view reads the model, never the controller.
Classic meaning

MVC splits an app into three parts. The model holds the data, views show it, and controllers handle input and update the model.

In the agent system

The Conversation model holds the messages, the tool calls (the model's requests to run tools) and the run status. It knows nothing about the screen. The transcript view (the list of messages on screen) subscribes to the model, which means it asks to be told about changes. It redraws when a message is added, or when a streamed delta (a small new piece of text arriving live) makes one longer. The input controller owns the composer, the box where the user types. On Enter, it adds the user's message to the model and asks the harness to start a turn. On Escape, it asks the harness to cancel the run. The harness then records the cancelled status in the model. Streamed events from the agent loop also go into the model. So the view has exactly one place to read from.

In a large company

In a large company, the transcript store on the server is the system of record (the official copy). It is used for audits and for rules about how long data is kept. The Conversation model in the app is only a cache (a local copy). The app reloads it from the server after a reconnect. Controller actions such as cancel must reach the server. Only the server can stop running tools and stop token spend (the cost of model usage). Client telemetry (usage data the app sends home) that watches the model should remove PII (personal data such as names and emails) before events leave the browser.

Step by step
  1. The user types in the composer, and the input controller receives the key presses.
  2. The controller adds a user message to the Conversation model and starts a turn.
  3. Streamed pieces of text from the agent loop make the assistant's message in the model longer.
  4. The model tells the transcript view, which reads the model and redraws.
Code sketch
class InputController:
    def __init__(self, convo, harness):
        self.convo, self.h = convo, harness
    def on_enter(self, text):
        self.convo.append('user', text)
        self.h.start_turn(self.convo.id)

convo.subscribe(transcript_view.redraw)
harness.on_delta(convo.extend_last)

Be carefulDo not let the view also read the harness stream directly. Then there are two sources of truth (two places that each claim to be correct). The transcript on screen slowly stops matching the saved conversation.

10.02

MVPModel-View-Presenter

Direct fit

Make the transcript view passive. A presenter reads the conversation, decides what to show, and tells the view what to display.

NO LOGICon_send()show_*()appendchangedtype, readdeltasTranscriptviewpassive interfaceChatPresenterdisplay decisionsConversationmessages, statusUserAgent loopstreams deltas
Fig. 10.02The view and the model never touch. The presenter sits between them.
Classic meaning

MVP is a kind of MVC where the view is thin and passive. A presenter holds the display logic and stands between the view and the model. Unlike MVC, the view never reads the model itself.

In the agent system

The transcript view offers a small interface (a fixed list of functions). Examples are show_messages, show_spinner, disable_composer and an on_send callback (a function the view calls when the user presses Send). The view holds no logic. The ChatPresenter receives on_send. It adds the message to the Conversation model and starts the turn. When the model changes, the presenter decides how to show it. It may fold up a long tool result, or show a retry banner after an overloaded error. It disables the composer (the typing box) while a run is active. The view is only an interface. So tests can give the presenter a fake view that records each call, with no browser needed.

In a large company

Regulated teams (teams under strict legal rules) like MVP for approval and consent screens. With a fake view, unit tests (small automatic checks) can prove one thing. A destructive tool call never appears without its Deny option or its risk banner. The presenter also turns tenant policy into controls. A tenant is one customer company sharing the platform. The server sends policy decisions, such as which actions a role may take. The presenter turns them into enabled or disabled buttons. The server must still enforce those policies, because a hidden button is not an access control.

Step by step
  1. The view passes the user's Send event to the presenter.
  2. The presenter updates the Conversation model and starts a turn.
  3. As new text arrives, the model tells the presenter, not the view.
  4. The presenter formats the state and calls view methods such as show_messages.
Code sketch
class ChatPresenter:
    def __init__(self, view, convo):
        self.v, self.c = view, convo
        view.on_send = self.send
        convo.on_change = self.refresh
    def send(self, text):
        self.c.append('user', text)
        self.v.disable_composer()
    def refresh(self):
        self.v.show_messages(self.c.rows())

Be carefulPresenters grow too big when every small display rule goes into them. Move pure formatting into helper functions. Then the presenter only decides what to show.

10.03

Model-View-ViewModel

Bind chat widgets to a view model's watched fields, so typing, streaming and busy states stay in sync without hand-written glue code.

BOUND WIDGETSbind draftbind messagessend()deltasComposertext box, SendTranscriptlistone row per msgChatViewModeldraft, messages, busySessionserviceharness API
Fig. 10.03Bindings keep widgets and view model fields in sync, with no hand-written glue.
Classic meaning

A view model offers state and commands as bindable properties (fields a screen element can be linked to). A binding framework keeps the view and the view model in sync. The view model talks to the model. Unlike MVP, nobody calls the view's methods; the bindings copy changes automatically.

In the agent system

A ChatViewModel holds observable fields (values that announce when they change). They are draft, messages, busy, pending_approval and token_usage. It also has commands such as send and stop. The composer's text box has a two-way binding to draft, so each side updates the other. The Send button is enabled or disabled by a computed value, can_send, which is worked out from other fields. The transcript list is bound to messages. When the harness streams a text_delta (a small new piece of text), the view model updates messages. The binding layer then redraws only that row. The view model imports no widgets (screen elements). So a unit test can simply set draft and call send.

In a large company

Enterprise apps built on binding frameworks use the view model as the single place where server policy becomes screen state. Examples are pending approvals, remaining budget and data residency banners (notices about which country data is kept in). View models are plain objects, so they get fast unit tests in CI (the automatic build and test system). Screenshot tests catch visual mistakes separately. App analytics should never log whole bound fields that hold user text. Drafts often contain PII (personal data such as names and emails) or pasted secrets.

Step by step
  1. When the screen opens, the binding layer links widget properties to view model fields.
  2. Typing updates draft through the two-way binding, and can_send is worked out again.
  3. The send command calls the session service and sets busy.
  4. New streamed text updates messages, and the bound widgets redraw on their own.
Code sketch
class ChatViewModel:
    draft = observable('')
    messages = observable([])
    busy = observable(False)
    @computed
    def can_send(self):
        return self.draft and not self.busy
    def send(self):
        self.busy = True
        session.start_turn(self.draft)
        self.draft = ''

Be carefulIf you update a bound messages field on every token (a small piece of text), it redraws on every token. So coalesce deltas per animation frame: collect the new pieces and apply them once per screen refresh. Also keep two-way bindings few. Chains of them make the order of updates hard to follow.

10.04

MVIModel-View-Intent

Turn every click and stream event into an intent, fold it into one fixed state, and draw the whole chat from that state.

emitdispatchTextDeltanew statecurrentrenderChat viewrender(state)IntentSend, Approvereduce()(state, intent)Stream eventtext_deltaChatStateimmutable snapshot
Fig. 10.04One loop: intent in, new state out, and the view redrawn from the state alone.
Classic meaning

In MVI, data flows in one direction only. The view sends intents (messages saying what happened). A pure function (one with no side effects) combines each intent with the current state into a new state, and the view draws that state. Unlike MVVM, there is one state value, and only this function makes new versions of it.

In the agent system

The chat app keeps one ChatState value. It holds the messages, the run status, pending approvals and the composer draft (the text being typed). Clicks become intents, such as Send, Stop and ApproveTool. The harness event stream becomes intents too, such as TextDelta, ToolUseStarted and TurnEnded. A pure function, reduce(state, intent), returns a new state. It never changes the old one. The view redraws from the new state and uses no other input. If you record the intents, you can replay a session exactly. This helps with bug reports and eval review (checks of how well the agent behaves).

In a large company

Recorded intent logs let support staff replay a customer's session on a new app version. This makes them strong input for debugging and evals. In a large company they are also personal data. So recording is opt-in or done for only a sample of users. PII (personal data such as names and emails) is removed at the edge, before data leaves the device. They are kept and deleted under the same rules as transcripts, following GDPR (the EU data protection law) and the tenant contract. A tenant is one customer company sharing the platform. Side-effect commands sent after a state change, such as starting a turn, should carry an idempotency key. That is an ID that makes a repeated request safe to run twice. These commands should also be switched off during replay, so a replay never triggers real work.

Step by step
  1. The view or the event stream sends an intent, such as TextDelta.
  2. The reducer combines the current state and the intent into a new state.
  3. The store replaces the old state with the new snapshot.
  4. The view draws the whole screen from the snapshot and waits for the next intent.
Code sketch
def reduce(s, i):
    if i.kind == 'send':
        m = s.msgs + [Msg('user', i.text)]
        return s.copy(busy=True, msgs=m)
    if i.kind == 'text_delta':
        old = s.msgs[-1]
        new = old.copy(text=old.text + i.text)
        msgs = s.msgs[:-1] + [new]
        return s.copy(msgs=msgs)
    if i.kind == 'turn_end':
        return s.copy(busy=False)
    return s

Be carefulDo not call the harness API from inside the reducer, because that breaks replay. Instead, return side effects (actions that change the outside world) as separate commands. They run after the state changes.

10.05

Flux

Direct fit

Send every action through one dispatcher to several stores, each updating its own part, so chat views refresh in one direction.

wrapdispatchbroadcastchangeuser actionSSE eventtext_deltaAction{type, data}Dispatcherone at a timeTranscriptStoreToolStoreUsageStoreChat viewre-renders
Fig. 10.05One dispatcher sends each action to all stores. Every store updates only its own part.
Classic meaning

Flux is an app design where data flows one way. Actions go to a single dispatcher. The dispatcher passes each action to every registered store. Stores update and tell the views. Unlike MVI's single state, Flux has several stores, each keeping its own slice of the data.

In the agent system

Harness events and clicks become plain action objects, such as text_delta, tool_result, usage and approve. A single dispatcher hands each action to every registered store. The TranscriptStore adds text. The ToolStore tracks the cards for running tools. The UsageStore adds up token counts (tokens are the small pieces of text the model reads and writes) for the cost meter. Each store sends a change event, and the views subscribed to that store redraw. The dispatcher refuses a new action while one is still being handled. This stops chains of updates that trigger more updates.

In a large company

Large enterprise chat apps split state into separate stores per area, such as transcript, tool runs, approvals and usage. Then separate teams can own each part. The UsageStore behind the cost meter is for display only. Per-tenant budgets and chargeback (billing each team or customer for its own usage) are measured and enforced on the server, at the LLM gateway (one shared front door to all AI models). A tenant is one customer company sharing the platform. The app's figure should be checked against that server record. Dispatcher action logs make useful client traces (records of what happened in a request). Tag them with the OpenTelemetry trace id (a standard tracing ID) that the server returns.

Step by step
  1. An SSE event (a message streamed from the server) or a click becomes an action object.
  2. The dispatcher passes the action to every registered store, one at a time.
  3. Each store updates its own part if the action concerns it, then announces the change.
  4. Subscribed views redraw, and the next user action starts the cycle again.
Code sketch
dispatcher = Dispatcher()
dispatcher.register(transcript_store.handle)
dispatcher.register(tool_store.handle)
dispatcher.register(usage_store.handle)

def on_sse(evt):
    dispatcher.dispatch({'type': evt.type,
                         'data': evt.data})

Be carefulAvoid stores that wait on each other through chains of waitFor calls. They bring back the tangle that Flux removes. Keep stores independent, or merge them.

10.06

Bloc

Feed screen events into a component's input and listen to the stream of states it sends back, so one Send gives many states.

add()eventsawaityield x NlistenChat widgetrebuilds on stateEvent sinkSendPressedChatBlocasync generatorHarnesstext deltasState streamSending, Streaming, Done
Fig. 10.06One event goes in, and a stream of states comes out over time.
Classic meaning

Bloc means Business Logic Component. It receives events through a sink (an input slot), runs logic that may wait on other work, and sends its results out as a stream of states. Widgets rebuild from each state. Unlike Flux, one event can produce a whole series of states over time.

In the agent system

A ChatBloc accepts events such as SendPressed, StopPressed and ApprovalGiven on its sink. Its SendPressed handler is an async generator, a function that can wait and hand out several results over time. First it yields (sends out) Sending. Then it waits on the harness stream and yields Streaming, with the growing text, after each new piece. It yields AwaitingApproval when a tool needs permission. It ends with Done or Failed. Widgets only listen to the state stream and rebuild. They never call the harness. A StopPressed event cancels the running handler. It then calls the harness cancel endpoint, so the server stops the turn. Finally it sends out Cancelled.

In a large company

Enterprise agents often have mobile apps built with Flutter (a toolkit for phone apps). There, one bloc per screen keeps streaming, approval and error states testable. CI (the automatic build and test system) feeds them recorded event sequences. Cancelling is the critical path. StopPressed must reach a cancel endpoint on the server, so tools stop and spending ends. The cancel is written to the audit trail (the permanent record of who did what). Failed states should turn server error types into clear messages, without showing internal details. Examples are 429 (too many requests) with Retry-After, and 529 (overloaded).

Step by step
  1. The widget adds SendPressed to the bloc's event sink.
  2. The bloc's handler yields Sending and opens the harness stream.
  3. Each new piece of streamed text yields a new Streaming state with the text so far.
  4. When the turn ends, the bloc yields Done, and widgets rebuild on every state.
Code sketch
class ChatBloc(Bloc):
    async def on_send(self, ev):
        yield Sending()
        text = ''
        async for d in harness.stream(ev.msg):
            text += d.text
            yield Streaming(text)
        yield Done(text)

bloc.add(SendPressed('fix the test'))

Be carefulSending a state for every token (small piece of text) floods widgets with rebuilds. Collect the new pieces and send at most one Streaming state per frame (one screen refresh).

10.07

Presentation Model

Keep the chat screen's state and behaviour in a plain object that several thin views copy from and tests use directly.

THIN VIEWScallupdatesyncUnit testasserts on fieldsHarness eventsdelta, errorChatScreenModelrows, banner, stopWeb viewTerminal viewIDE panel
Fig. 10.07One screen model class, usable on any screen, feeds several thin views and is tested on its own.
Classic meaning

The state and behaviour of a screen live in a model that is separate from any buttons or widgets. Each view keeps itself in step with that model. It is like MVVM without a binding framework: each view copies fields by hand.

In the agent system

A ChatScreenModel holds everything the screen shows, in terms that fit any kind of screen. It holds the rows to show and whether Stop is visible. It holds the banner text after a rate-limit error (an error saying too many requests were sent). It also holds which tool card is open and the options on the approval prompt. The same class drives the web app, the terminal app and an IDE (code editor) side panel. Each screen gets its own instance (copy) of the class. Each view is a thin adapter (connecting code). It copies fields into its widgets and passes clicks back as method calls. Unit tests (small automatic checks) call model methods directly and check the fields. They run in milliseconds, with no browser.

In a large company

Some platforms ship web, desktop, IDE and terminal apps. They share one presentation model class per screen, often in a shared TypeScript or Kotlin Multiplatform package (code that runs on many platforms). So approval and error behaviour is the same everywhere. That matters for compliance (meeting legal and audit rules). An approval prompt that shows risk text on the web but not in the terminal is an audit finding. The model's tests give the same result every time. They run on every pull request (proposed code change). They are separate from model-graded evals, which use an AI model to score how well the agent behaves.

Step by step
  1. Harness events update the ChatScreenModel through its methods.
  2. The model works out derived fields again, such as stop_visible and banner.
  3. Each view catches up by copying fields into its own widgets.
  4. Input in any view calls the same model method, such as approve or stop.
Code sketch
class ChatScreenModel:
    def __init__(self):
        self.rows, self.banner = [], None
        self.stop_visible = False
    def on_error(self, err):
        if err.type == 'overloaded_error':
            self.banner = 'Busy, retrying'

def test_banner():
    m = ChatScreenModel()
    m.on_error(Err('overloaded_error'))
    assert m.banner == 'Busy, retrying'

Be carefulWith no binding framework, each view must catch up by hand. One forgotten update call leaves a screen showing old state.

10.08

Front Controller

Send every request to the harness server through one entry handler that checks identity, logs, and passes it to the right handler.

requestverifydispatchWeb clientCLI clientFrontcontrollerauth, log, routeToken checkrate limitTurn handlerPOST /turnSessionshandlerGET /sessionsUpload handlerPOST /upload
Fig. 10.08Every request passes the same checks before any handler sees it.
Classic meaning

One handler receives all requests for a web app. It does the shared work, then passes each request to a specific command or view.

In the agent system

The harness server has one entry point that every app request hits. That includes starting a turn, listing sessions, uploading a file and answering an approval. It checks the session token (proof of who is logged in). It finds the user's tenant, the customer company they belong to. It applies the rate limit for that tenant (a cap on how many requests it may send). It adds a trace id, a label used to follow the request through the system. Only then does it look up the route table and call the matching handler. New endpoints get authentication (checking who the user is) and audit logging for free. A fix to token checking goes into one file. Handlers still check that the user may act on this specific session or file. Checking identity at the door is not object-level authorization (permission for one specific item).

In a large company

In large company setups, the front controller's job is usually split in two. An API gateway at the edge (a shared front door for all requests) checks OIDC tokens (sign-in proof from the company login system). It also applies per-tenant rate limits and terminates TLS, meaning it decrypts the secure connection. Then an entry point inside the app finds the tenant, starts an OpenTelemetry span (one timed step in a trace) and writes the audit record. Permission for a specific item still belongs in each handler. An example is whether this user may resume this session. It can also be a shared policy check against OPA or Cedar (policy engines that answer "is this allowed?"). A route table written as plain data lets security reviewers see every open endpoint in one place.

Step by step
  1. An app request arrives at the single entry handler.
  2. The handler checks the session's identity and the rate limit.
  3. It tags the request with a trace id and logs it.
  4. It looks up the route and passes the request to the turn, sessions or upload handler.
Code sketch
ROUTES = {'POST /turn': turn_handler,
          'GET /sessions': sessions_handler,
          'POST /upload': upload_handler}

def front_controller(req):
    user = auth.verify(req.token)
    limiter.check(user)
    req.trace_id = new_trace_id()
    key = f'{req.method} {req.path}'
    return ROUTES[key](req, user)

Be carefulA front controller that collects business logic becomes the file every team edits. Keep it to shared checks and routing.

10.09

Page Controller

Give each screen of the harness app, such as sessions, chat and settings, its own controller that handles its route.

/sessions/chat/:id/settingsrenderrenderrenderBrowserSessionscontrollerlist, renameChatcontrollerstream, send, stopSettingscontrollermodes, MCP configSessions pageChat pageSettings page
Fig. 10.09Each route has its own controller and view. The router only links paths to controllers.
Classic meaning

A page controller handles the requests for one page or action on a web site. There is one controller for each page.

In the agent system

The app's router links each path (web address) to one controller. /sessions goes to a SessionsController. It loads the list of sessions and handles rename and delete. /chat/:id goes to a ChatController. It opens the event stream and handles send and stop. /settings goes to a SettingsController. It reads and saves permission modes and MCP server settings (MCP is a standard way to connect tools to an AI model). The router only links a path to a controller and does no shared work. So each controller owns its own page's data loading and actions. Adding a usage page means one new route and one new controller. The others need no edits.

In a large company

Agent platforms have admin consoles for tenants, policies, MCP server allowlists (lists of approved servers), audit search and usage. A tenant is one customer company sharing the platform. These consoles are often built page by page. The team behind each backend owns its page. Each controller must limit every query to the right tenant and check the caller's role. Page controllers share no gate unless one is added. A common safeguard is a base controller or middleware (code that runs before every handler). It enforces the SSO session (single sign-on), RBAC (role-based access control: permissions given by job role) and audit logging. Then a new page cannot skip them.

Step by step
  1. The router matches the web address to one page controller.
  2. That controller loads the data its page needs.
  3. It draws its own view and handles that page's actions.
  4. Other pages and their controllers stay untouched.
Code sketch
class SessionsController:
    def get(self, req):
        rows = store.list_sessions(req.user)
        return view('sessions', rows=rows)
    def post_rename(self, req):
        s = store.get(req.id, owner=req.user)
        s.rename(req.title)

router.add('/sessions', SessionsController)
router.add('/chat/:id', ChatController)
router.add('/settings', SettingsController)

Be carefulShared steps, such as checking login and loading the user, get copied into every controller. Move them into a base class (shared parent code), or put a front controller in front.

10.10

Composite View

Build the transcript as a tree of message parts, so text, tool cards and artifact cards all draw through one shared interface.

update(delta)TranscriptViewrender(), update()User messageAssistantmessageTextTextstreamingTool cardtool_use + resultArtifact card
Fig. 10.10Messages and blocks share one interface, so a new piece of text finds its leaf by walking the tree.
Classic meaning

A screen is built from smaller views arranged in a tree. Each one is managed on its own, so a page is put together from reusable parts.

In the agent system

The TranscriptView holds one MessageView per message. Each assistant MessageView holds child views, chosen by content block type (the kind of piece inside a message). TextView is for text. ThinkingView is for a folded-up thinking block. ToolCardView is for a tool_use, the model's request to run a tool. The card is later matched to its tool_result (the tool's answer) by tool_use_id. That result arrives in the next user message. If the tool call produced an artifact (a file or page the agent made), the card holds an ArtifactCardView child. Every part has the same two functions: render() to draw itself and update(delta) to take in new text. Suppose a streamed piece of text arrives for block 2 of the last message. The transcript passes it down the tree, and only that leaf (end part) redraws.

In a large company

Enterprise transcripts can run to thousands of blocks. So the views are virtualised: only the parts visible on screen are drawn. Each part is keyed by message id and block id. Each leaf type is a natural place for policy. For example, a tool card can hide redacted fields (data removed for privacy). A citation view can show document sensitivity labels. An older app may meet block types it does not know. It should show a safe placeholder for them, so a server upgrade never breaks the whole tree.

Step by step
  1. The transcript view creates one message view per message.
  2. Each message view creates a child view per content block, chosen by block type.
  3. render() on the top view draws the children, in order, all the way down.
  4. A streamed piece of text is passed down the tree to the one leaf it belongs to.
Code sketch
BLOCK_VIEWS = {'text': TextView,
               'thinking': ThinkingView,
               'tool_use': ToolCardView}

class MessageView(View):
    def __init__(self, msg):
        self.kids = [BLOCK_VIEWS[b.type](b)
                     for b in msg.content
                     if b.type in BLOCK_VIEWS]
    def render(self):
        return div(*(k.render()
                     for k in self.kids))

Be carefulIf one new piece of text makes the whole tree redraw, long transcripts get slow. Give each part a stable key (a fixed ID), so only the changed leaf redraws.

10.11

Template View

Draw tool cards and status lines from page templates with blanks, filled from the content block's fields when shown.

templatefieldsfieldsHTMLtool_card.html{{ name }} {{ status }}tool_use blockname, inputtool_resultstatus, textRendererfill + escapeTool cardBash: done
Fig. 10.11The layout lives in the template. Each block only supplies values for its gaps.
Classic meaning

A template view makes a web page from a fixed page template with markers in it. When the page is made, each marker is replaced with a worked-out value.

In the agent system

Each content block type (kind of piece in a message) has a template file. tool_card.html shows the tool name, a summary of its input, a status badge and a result you can fold up. approval.html shows the command, the reason, and Allow and Deny buttons. The renderer (the code that draws the screen) picks the template by block type. It fills the placeholders from the fields of the tool_use (the model's request to run a tool) and the tool_result (the tool's answer). It escapes every value, which means special characters are made harmless. So model output cannot inject markup (sneak page code into the screen). Designers change the layout in templates. They do not touch the code that handles the event stream.

In a large company

Model output and tool results are untrusted input. So enterprise apps use a template engine that escapes values automatically (autoescaping). They also use a strict Content Security Policy, a browser rule that limits which scripts may run. Any markdown (formatted text) shown on screen also goes through an HTML sanitiser such as DOMPurify, which strips dangerous page code. White-labelled setups (sold under each customer's own brand) often give each tenant its own template theme. A tenant is one customer company sharing the platform. This keeps branding out of code. Security review treats any raw or unescaped filter in a template as a finding. Otherwise a tool result with prompt injection (hidden instructions planted in content) could run script in an approver's browser.

Step by step
  1. A content block arrives, such as a finished tool_use with its result.
  2. The renderer picks the template for that block type.
  3. It fills each placeholder from the block's fields, escaping the values.
  4. The filled-in page code is added to the transcript.
Code sketch
# tool_card.html (autoescape on)
# <div class='tool'>{{ name }}: {{ status }}
#   <pre>{{ result | truncate(400) }}</pre>
# </div>

tpl = templates.get('tool_card.html')
# the engine escapes every placeholder
html = tpl.render(name=block.name,
                  status=result.status,
                  result=result.text)

Be carefulTemplates that call services or follow business rules become hidden code. Keep them to placeholders and simple loops. Put formatting in helpers.

10.12

View Helper

Direct fit

Move markdown display, code colouring and number formatting out of views into small helpers that every view calls.

VIEW HELPERSmd(text)codefmt(n)Message viewlayout onlyUsage meterlayout onlymd()markdown to HTMLhighlight()code fencesfmt_tokens()12345 -> 12.3k
Fig. 10.12Views keep only layout. Every formatting rule lives once, in a shared helper.
Classic meaning

Formatting logic lives in helper objects, so views stay focused on layout. Many views can reuse the same logic.

In the agent system

Message views, tool cards and the usage meter all need the same formatting. Markdown (formatted text) must become safe HTML. Code blocks need colouring by language. 12345 tokens (small pieces of text the model reads and writes) show as 12.3k. Cost shows as $0.04. Timestamps show as 2m ago. Long file paths get shortened. All this lives in helpers such as md(), highlight(), fmt_tokens() and rel_time(). Views call them and stay as plain layout. A change to how token counts look is made once.

In a large company

Shared helpers hold the company's display rules. They format numbers and currency for each country, for chargeback figures (billing each team for its usage). They show timestamps in the user's time zone. They also mask secrets or PII (personal data such as names and emails) in tool output. With masking in one helper, it is easy to test and audit. But masking in the browser is only cosmetic. Sensitive values should already be removed on the server. The markdown helper must sanitise HTML (strip dangerous page code). Model output and tool results can carry injected page code.

Step by step
  1. A view receives a message or usage record to show.
  2. It calls a helper for each formatted piece, instead of formatting inside the view.
  3. Helpers return safe strings or page pieces, ready to show.
  4. The view places them into its layout.
Code sketch
def fmt_tokens(n):
    if n < 1000:
        return str(n)
    return f'{n / 1000:.1f}k'

# inside the usage meter view
label = f'{fmt_tokens(u.input)} in, '
label += f'{fmt_tokens(u.output)} out'
body = md(msg.text, highlight=highlight)

Be carefulHelpers that fetch data, such as prices over the network, slow down every redraw. Pass the data in, and keep helpers pure (no side effects, same output for the same input).

10.13

Service to Worker

Direct fit

Run the turn to the end first, then choose and draw a result view, so views only show results that already exist.

POST /summariserun(session, prompt)model + toolsTurnResultnothing rendered until the turn endspick viewrender(result)summary pageClientDispatcherAgent loopResult view
Fig. 10.13The work finishes before any view is chosen or drawn.
Classic meaning

A front controller and a dispatcher handle the request and run the business logic first. Then they hand control to a view. The view draws the prepared data, with help from view helpers.

In the agent system

This suits an endpoint that does not stream, such as a scheduled task run or a summarise-this-session call. The controller passes the request to the agent loop. The loop calls the model and tools until the turn ends. The loop returns a TurnResult with the final text, the tool calls, the stop_reason (why the model stopped) and the usage. The dispatcher then picks the view from that result. It shows a summary page for end_turn and an error page for max_tokens (the reply hit its length limit). It shows an approval page if a tool is waiting for permission. Views get a finished result they cannot change, and they never call the model.

In a large company

Enterprise platforms use this style for batch and scheduled agent work, such as nightly report runs. But they run the turn in a durable workflow engine like Temporal or AWS Step Functions. These tools keep long jobs going even through crashes. The server replies 202 Accepted (request received, work in progress) with a job id. The result view is drawn when the app polls (checks back) or a webhook (a call back from the server) fires. So gateway timeouts never cut a long turn in half. The stored TurnResult carries its trace id and usage. So the page shown, the audit log and the chargeback ledger (the record used for billing) all agree.

Step by step
  1. The front controller receives the request and checks who sent it.
  2. The dispatcher runs the agent loop to the end and gets a TurnResult.
  3. It chooses a view based on stop_reason and pending approvals.
  4. The view draws the prepared result, using view helpers for formatting.
Code sketch
def handle(req):
    result = agent_loop.run(req.session,
                            req.prompt)
    if result.pending_approval:
        view = 'approval'
    elif result.stop_reason == 'end_turn':
        view = 'summary'
    else:
        view = 'error'
    return render(view, result=result)

Be carefulIn this style, a long agent turn keeps the response waiting. So use it for short or background work, and stream interactive chat instead.

10.14

Client-Side UI Composition

Let the chat shell build its screen from pieces owned by separate services: the transcript, the artifact viewer and widgets.

BROWSERbundleframebundlemountChat serviceArtifactserviceWidget serviceTranscriptpaneArtifact framesandboxed iframeMap widgetClient shelllayout, auth, bus
Fig. 10.14Each service ships its own piece. The shell only lays them out and passes messages.
Classic meaning

Each microservice team (a team owning one small, separate service) ships its own piece of the screen. A shell in the browser loads those pieces and puts them together into one page while the app runs.

In the agent system

The chat app is a thin shell. It owns the layout, the login and an event bus (a shared channel for passing messages). The transcript pane comes from the chat service. The artifact panel comes from an artifact service. It loads into a sandboxed iframe, a walled-off frame on its own origin (its own web address). Inline widgets such as a map or chart are bundles (packed code files) from a widget service. The shell mounts (places) each piece into a slot. It passes messages between them, such as open artifact 42 from a link in the transcript. Each service releases its piece on its own schedule.

In a large company

Large agent platforms build the app from team-owned pieces. They use module federation or web components (two ways to load separate code pieces into one page). The shell owns SSO (single sign-on), the design system and a typed message bus (one that checks the shape of each message). Untrusted pieces, such as artifacts, run in sandboxed iframes on a separate origin. A strict Content Security Policy (a browser rule limiting what may run) applies. The shell checks the origin and the schema (expected shape) of every postMessage, the browser's way of sending messages between frames. A manifest (a list of versions) pins each piece's version. So one piece can be rolled back without redeploying the shell.

Step by step
  1. The shell loads with layout slots and a shared event bus.
  2. It fetches each piece's code or frame from the service that owns it.
  3. Pieces mount into their slots and draw on their own.
  4. Actions across pieces, such as opening an artifact from a transcript link, go through the bus.
Code sketch
shell = Shell(layout='chat')
shell.mount('main',
    remote('chat-svc/transcript.js'))
shell.mount('side',
    iframe('artifacts-svc/view/42',
           sandbox='allow-scripts'))
shell.mount('inline',
    remote('widgets-svc/map.js'))
shell.bus.on('open_artifact',
    lambda id: shell.slot('side').load(id))

Be carefulIf each piece packs its own copy of a UI framework, the app gets heavy and slow to load. Share core libraries through the shell.

10.15

Naked Objects

Build approval cards and input forms straight from each tool's JSON schema, so a new tool gets a working screen with no new screen code.

fieldsvaluesrenderedit, Allowargsinput_schemapath, mode, forcetool_use inputmodel's argumentsForm generatorfield per propertyApproval cardauto-built formUserTool executorvalidated args
Fig. 10.15The schema the model already uses becomes the form the user approves.
Classic meaning

The screen is built automatically from the structure and behaviour of the business objects. So users act directly on those objects.

In the agent system

Each tool already has an input_schema, written in JSON Schema (a standard way to describe the shape of data). It is there for the model, and the screen code reuses it. A string becomes a text field, an enum (fixed list of choices) becomes a dropdown, and a boolean (yes or no) becomes a checkbox. Required properties get a marker, and descriptions become help text. Some tool_use requests (the model's requests to run a tool) need approval. For those, the card is built from the schema and filled in with the model's input. So the user can read, edit and approve the exact arguments. Edited values are checked against the same schema before the tool runs. A tool newly listed by an MCP server gets an approval card at once. MCP is a standard way to connect tools to an AI model.

In a large company

With approval cards built from schemas, a company can add new MCP tools without releasing new screen code. This matters when hundreds of internal tools are on the allowlist (the list of approved tools). Titles and descriptions in a third-party schema are untrusted text. So the card escapes them. Next to them it shows risk labels set by the platform's policy engine (the system that decides what is allowed). When a user edits arguments, the audit record keeps both the model's proposal and the approved input. The tool_result (the tool's answer) tells the model what actually ran.

Step by step
  1. The app fetches the tool list, with each tool's input_schema.
  2. A tool_use that needs approval arrives with the model's input.
  3. The generator builds one form field per schema property, filled in with the input.
  4. The user edits or approves, the values are checked against the schema, and the tool runs.
Code sketch
WIDGETS = {'string': TextField,
           'integer': NumberField,
           'boolean': Checkbox}

def approval_card(tool, call):
    fields = []
    for k, spec in tool.input_schema[
            'properties'].items():
        w = Select if 'enum' in spec \
            else WIDGETS[spec['type']]
        fields.append(w(k, call.input.get(k)))
    return Card(tool.name, fields, ['Allow'])

Be carefulSchema text from a third-party MCP server is untrusted. So escape it (make special characters harmless), and take risk labels from your own policy. When the user edits arguments, record both versions. Tell the model what actually ran.

11
Chapter 11 · Speed and memory

Performance Internals

Two patterns from video game engines help when an agent platform serves many users: keep data that is used together stored together, and divide data into areas so a search only looks where the answer can be.

11.01

Data Locality

Adapted fit

Put stable content first in the prompt and keep a session's often-used data together, so caches hit and loads stay cheap.

hash matchreuse KVfresh tokensone readStable prefixtools, system, past turnsVolatile tailnew message, time, idsSession recordtranscript + memory + resultsPrompt cacheprefix KVModelcomputes tail only
Fig. 11.01Stable bytes at the start get reused. Only the changing end is computed fresh.
Classic meaning

Data locality means storing items that are used together next to each other in memory. The CPU cache (a small fast memory) and prefetcher (which loads data ahead of time) can then serve them instead of slower main memory.

In the agent system

Prompt caches and the provider's KV cache (the model's saved internal work for text it has already read) reuse work only for an identical prefix. The match starts from the very first token. So the harness orders every request from most stable to least stable. Tool definitions come first, then the system prompt. Next come long-lived project context and earlier turns. Only then come the new user message and fresh tool results. Timestamps, request ids and per-turn instructions go at the end, never at the top. On the storage side, a session's transcript, memory files and recent tool results live in one record or one shard (one slice of the database). So building a turn's context takes a single read.

In a large company

In an enterprise platform, prompt layout is a governed template. The platform owns the order of tools, system policy text and tenant context. A tenant is one customer company. A template change that breaks the cached prefix is caught before release by cost and latency regression gates (checks that block a release that makes things worse). Per-tenant context goes after the shared platform prefix. This way the common part can be reused, while tenant data stays in its own section. On the storage side, keeping a session's hot (often-used) data in one record or shard also helps with data residency and erasure. Data residency means keeping data in an approved region. It is simpler because one tenant's session lives in one place.

Step by step
  1. Sort prompt sections by how often they change, most stable first.
  2. Move changing items (time, ids, retrieved snippets) after the last cache breakpoint.
  3. Add new turns at the end, so earlier bytes never shift.
  4. Store each session's often-used data together, so one read builds the context.
Code sketch
def build_request(s, user_msg):
    return {
      'tools': TOOLS,        # never changes
      'system': [RULES, s.project_ctx],
      'messages': s.history + [  # append
          {'role': 'user', 'content': [
              user_msg, f'now: {clock()}']}],
    }

Be carefulPutting a retrieved document or the current date near the top of the prompt shifts every later byte. Then the cache is lost on every turn.

11.02

Spatial Partition

Direct fit

Split the embedding space into regions, so a memory or code search checks only the few cells nearest the query.

VECTOR INDEXquery vectorprobeprobeskipRetrieval toolsearch('auth bug')Centroidrouternearest 2 of 64Cell 12scannedCell 31scanned62 other cellsnever scanned
Fig. 11.02The query visits two nearby cells and never touches the other sixty-two.
Classic meaning

Spatial partition organises objects by position in a structure that splits space, such as a grid, tree or clusters. A search for nearby objects then checks only the regions that matter.

In the agent system

An agent's long-term memory, docs index or codebase can hold millions of embeddings. An embedding is a list of numbers, also called a vector, that stands for the meaning of a piece of text. A partitioned index such as IVF clustering splits these vectors into cells. Each cell has a centroid (its centre point). When a vector is written, it goes into the cell of its nearest centroid. At query time, the retrieval tool turns the question into an embedding and ranks the centroids by distance. It scans only the closest nprobe cells (nprobe is the number of cells to check). It returns the top-k chunks (the k best matches) in milliseconds. Partitions can also follow metadata. With shards per repository or per tenant (customer company), a memory search never crosses into another user's data.

In a large company

In an enterprise platform, vector indexes use partitioning for speed. Examples are pgvector with IVFFlat, FAISS, or a managed vector database. Tenant isolation is enforced separately. It uses per-tenant indexes, namespaces or row-level security (database rules that limit which rows each user can see). It does not rely on which cells happen to be probed. Apply metadata filters for document permissions inside the index query itself. Filtering after an approximate search can return too few results. It can also tempt code to fall back to a query with no filter. Recall at a given nprobe is part of the retrieval eval suite. Teams check it again when the embedding model changes.

Step by step
  1. At write time, embed each chunk and put it in the cell of its nearest centroid.
  2. At query time, embed the query and rank the centroids by distance.
  3. Scan only the closest nprobe cells for candidate vectors.
  4. Rerank the candidates and return the top-k chunks to the agent.
Code sketch
def add(chunk):
    v = embed(chunk.text)
    cell = nearest(centroids, v)
    cells[cell].append((v, chunk))

def search(query, k=8, nprobe=3):
    q = embed(query)
    near = closest(centroids, q, nprobe)
    cands = sum((cells[i] for i in near), [])
    return top_k(cands, q, k)

Be carefulIf you probe too few cells, you miss relevant memories that sit near a cell border. Tune nprobe on a recall eval (a test of how many right answers are found), not on speed alone.

12
Chapter 12 · Checking that it works

Testing and Evals

AI agents do not give the same answer every time, so tests need extra structure. These patterns organise test cases, sample data, fake services and automated browser checks.

12.01

Arrange/Act/Assert

Build each eval case in three steps: set up a sample conversation and fakes, run the agent, then check the transcript.

ARRANGEACTASSERTloadinjectrecordinspectFixture convobug_report.jsonStub toolsseeded sandboxAgent rununtil end_turnTranscripttool calls, textedit_file?
Fig. 12.01Setup, one run, then checks: each phase stays in its own box.
Classic meaning

Each test has three parts. Arrange sets up the starting state. Act does one thing to exercise the behaviour. Assert checks the outcome.

In the agent system

An eval case is one test of how the agent behaves. Its arrange step sets up a starting conversation: a system prompt (standing instructions), earlier turns and a user request. It also sets up a sandbox (a walled-off workspace) with files already in it, and stubbed or recorded tools (fake tools with set answers). The act is one call: run the agent until end_turn or a turn cap (a maximum number of turns). The asserts then inspect the transcript and side effects (changes to the outside world). Fixed checks come first. Was edit_file called on the right path? Did any tool run without approval? Do the tests pass now? Then a model-graded rubric score (an AI judge scoring against a checklist) must reach a threshold. Runs vary from one to the next. So the suite repeats each case several times. It gates on the pass rate compared with the last release. Each trial still has a single act, so failures are easy to trace.

In a large company

Enterprise eval suites keep their test cases in a versioned golden dataset (an official, tracked set of examples). Fixed cases run against recorded tool answers on every pull request (proposed code change). Cases that use the live model run several trials each. They run on a schedule and before any change of model or prompt. Regression gates in CI (automatic checks that block a change that makes things worse) compare pass rates and rubric scores with the last released baseline. A significant drop blocks the merge. Model-graded checks use a pinned judge model (a fixed version that does not change). Its rubric is calibrated against human labels. So a change of judge cannot quietly move the gate.

Step by step
  1. Arrange: load the sample conversation, fill the sandbox and register the fake tools.
  2. Act: run the agent once, until it stops or hits the turn cap.
  3. Assert: check tool calls, file changes and the final text in the transcript.
  4. Record pass or fail, with the transcript attached for review.
Code sketch
def test_fixes_failing_test():
    # arrange
    convo = fixtures.load('bug_report.json')
    box = Sandbox(files='repo_snapshot/')
    # act
    t = agent.run(convo, box, max_turns=20)
    # assert: deterministic checks first
    assert t.called('edit_file', 'calc.py')
    assert box.run('pytest').ok
    # then a model-graded rubric score
    assert judge(t, 'explains the fix') >= 4

Be carefulChecking for exact wording makes evals flaky (they pass and fail at random), because model output varies. Check tool calls, side effects and rubric scores instead.

12.02

Object Mother

Direct fit

Give evals a factory of named, ready-made test setups, such as an angry customer or a session waiting on an approval.

askbuildRefund evalCompactionevalApproval evalFixtureMothernamed factoriesAngry personaangry_customer()Long convolong_convo(200)Pendingapprovalpending_approval()
Fig. 12.02Evals ask for setups by name. One class knows how to build them correctly.
Classic meaning

An Object Mother is a class with factory methods (functions that build objects). Each one returns a ready-made test object in a named state. So tests share realistic fixtures (prepared test data) without repeating setup.

In the agent system

Eval suites need the same starting points again and again. One is a 200-turn conversation close to the compaction threshold (the point where old messages get summarised to save space). Others are a session waiting on a tool approval and a persona (a pretend user) who writes short angry messages. Another is a sandbox (walled-off workspace) with a broken test. A FixtureMother offers methods such as long_convo(200), pending_approval(tool='bash') and angry_customer(). They build valid sessions with the correct roles and sensible defaults. Each tool_use (request to run a tool) is correctly paired by id with its tool_result (the tool's answer). When the message format changes, only the mother is updated.

In a large company

Enterprise fixture factories build synthetic (made-up) personas and sessions. Copying real production transcripts into fixtures would move customer data into code repositories and CI logs (the logs of the automatic test system). That breaks GDPR purpose limits (the EU rule that data be used only for its stated purpose) and most customer contracts. Where real traffic is needed, it is sampled with consent and has personal data removed. It is kept in a controlled eval data store with rules on how long it is kept. The mother is also the place to describe tenant shapes. A tenant is one customer company sharing the platform. Examples are a tenant whose data must stay in the EU, or a role without approval rights. This way, policy paths get tested.

Step by step
  1. An eval asks the mother for a named setup, such as pending_approval().
  2. The mother builds a valid session with defaults and paired ids.
  3. The eval changes only the details it cares about.
  4. Many evals share the same setups, so a format change is fixed in one place.
Code sketch
class Mother:
    @staticmethod
    def pending_approval(tool='bash'):
        s = Session(user=Mother.persona())
        s.add_user('clean up the build dir')
        s.add_tool_use(tool, cmd='rm -rf out')
        s.status = 'awaiting_approval'
        return s
    @staticmethod
    def persona(mood='neutral'):
        return Persona(name='Sam', mood=mood)

Be carefulA mother with dozens of nearly identical methods gets hard to find your way around. Pair it with a builder (an object that makes small changes step by step) for small variations.

12.03

Page Object

Hide a chat screen's selectors behind methods like send() and approve_tool(), so evals and browser agents speak in user actions.

send('hi')transcript()selectorseventstextE2E evalBrowser agentChatPageknows selectorsBrowser driverfill, click, waitChat UIDOM
Fig. 12.03Callers speak in user actions. Only the page object knows the selectors.
Classic meaning

A page object wraps a web page in an object. Its methods stand for the things a user can do on the page. So tests do not depend on how the page's HTML is built.

In the agent system

End-to-end tests (tests that drive the whole app like a real user) use a ChatPage object. It has methods such as send(text), wait_for_turn_end(), approve_tool(name), open_artifact(n) and transcript_text(). Inside, it knows the selectors (the codes that find elements on the page) for the composer and the stop button. It also knows how to tell that streaming has finished. The same idea helps browser tools for agents. A page object for a known site gives the agent steady, high-level actions. The agent does not have to make raw clicks on fragile screen positions. A page object given to an agent leaves out approval controls. So the agent can never approve its own tool calls. When the screen is redesigned, only the page object changes.

In a large company

Enterprise teams run page-object test suites in Playwright or Selenium (tools that drive a real browser). They run against every app build in CI, the automatic build and test system. Selectors use stable test ids, so redesigns do not break the gate. Sometimes a browser-using agent drives internal web apps. Then a page object or a narrow set of tools limits it to approved actions. Each action gets a permission check, like any other tool. The approval controls of the agent harness itself must never be reachable through such a set of tools. That would let the agent approve itself.

Step by step
  1. The test or agent calls a high-level method, such as send('hi').
  2. The page object turns it into browser actions on known selectors.
  3. It waits for the right signal, such as the stop button disappearing.
  4. It returns plain data, such as the transcript text, for checking.
Code sketch
class ChatPage:
    def __init__(self, browser):
        self.b = browser
    def send(self, text):
        self.b.fill('#composer', text)
        self.b.press('#composer', 'Enter')
    def wait_for_turn_end(self):
        self.b.wait_hidden('[data-stop]')
    def approve_tool(self, name):
        self.b.click(f'#{name} .allow')

Be carefulPutting checks inside page objects mixes checking with driving. Return data, and let the test decide what is correct.

12.04

Service Stub

Direct fit

Replace the model and MCP servers with fakes that give scripted answers, so agent tests run fast, free and the same every time.

TEST DOUBLESmessagestool_usepop()call_toolresultAgent loopreal, under testStubModelnext scriptedreplyScripttool_use,tool_use, textStub MCPservercanned resultsReal model andMCP nevercalled
Fig. 12.04The real loop runs against scripted stand-ins for the model and the tools.
Classic meaning

During testing, you swap a troublesome outside service for a simple stand-in that returns set answers. This removes the test's need for the real service.

In the agent system

The agent depends on a model client interface and an MCP client. MCP is a standard way to connect tools to an AI model. In tests, a StubModel returns a fixed list of responses, in order. First comes a tool_use (request to run a tool) for read_file, then one for edit_file. Last comes a final text block with stop_reason end_turn, meaning the model has finished. A stub MCP server lists the same tools as the real one but returns set results. It can also be told to fail or time out. Everything else runs for real: the harness loop, the permission gates, compaction (summarising old messages to save space) and the transcript logic. There is no network, no token cost and no randomness.

In a large company

Enterprise CI (the automatic build and test system) runs the harness against stubbed or recorded model and MCP answers on every pull request. Live calls are slow, costly and give different results each time. Tests with the live model are kept for scheduled and pre-release gates. Recorded fixtures (saved test data) are captured in a record mode. Secrets, tokens and PII (personal data such as names and emails) are removed from them. They are refreshed when the model version or tool schemas change. Stubs are also the cheapest way to test error handling. Examples are 429 (too many requests) with Retry-After, 529 (overloaded), MCP timeouts and badly formed tool_use input.

Step by step
  1. The test builds a StubModel with a script, and a stub MCP server with set results.
  2. The harness is connected to the stubs instead of the real clients.
  3. The agent loop runs as normal, taking the next scripted response on each model call.
  4. The test checks the loop's behaviour and checks that the script was used up.
Code sketch
model = StubModel([
    tool_use('read_file', path='calc.py'),
    tool_use('edit_file', path='calc.py'),
    text('Fixed the off-by-one.'),
])
mcp = StubMCP(tools={'read_file': 'def add...',
                     'edit_file': 'ok'})
agent = Harness(model=model, mcp=mcp)
agent.run('fix calc.py')
assert model.exhausted()

Be carefulA stub tests the harness only against answers you imagined. Refresh the scripts from recorded real transcripts, so they stay close to what the model really sends.

13
Chapter 13 · Small coding habits

Code-Level Idioms

The last few patterns are small habits inside the code. They do not shape the whole system, but they keep everyday code tidy and they show up in almost every project.

13.01

Monostate

Direct fit

Let code create as many config objects as it likes, while every one reads and writes the same shared state.

a.mode='plan'writesreads 'plan'reads 'plan'Config()in bash toolConfig()in pre-tool hookConfig()in subagentUser: planmodeShared state_shared dict
Fig. 13.01Many instances, one dictionary: a change through any one is seen by all.
Classic meaning

In a monostate, all instances of a class share the same state through class-level storage. It acts like a singleton (a class with one instance) but looks like an ordinary class.

In the agent system

Tools, hooks (code that runs at set points) and subagents often create a HarnessConfig() locally. They use it to read the model name, the sandbox root or the permission mode. No one hands them a shared object. With monostate, the class keeps its fields in one class-level dict (dictionary). So every new instance sees the current values. Suppose the session switches to plan mode or raises max_tokens. That change is visible everywhere at once. Callers keep the normal constructor syntax and do not need to know the state is shared.

In a large company

In an enterprise platform, a multi-tenant worker serves many sessions in one process. A tenant is one customer company. A monostate holding permission mode, model or tenant settings would leak one session's choices into another. That is a security defect, not a style issue. Session and tenant settings belong in a context object or a contextvars scope (Python's way to keep values separate per request). The scope is set when the request comes in, from the logged-in identity. Monostate is acceptable only for settings that truly cover the whole process and are mostly read, such as telemetry endpoints. Changes to them go through change-managed configuration (a formal review process).

Step by step
  1. Store the configuration fields in one class-level dictionary.
  2. Point each new instance's attribute storage at that dictionary.
  3. When any instance changes a field, it changes for every instance.
  4. Reset the shared dictionary between tests.
Code sketch
class HarnessConfig:
    _shared = {'model': 'large',
               'mode': 'default'}
    def __init__(self):
        self.__dict__ = self._shared

a = HarnessConfig()     # in a tool
b = HarnessConfig()     # in a hook
a.mode = 'plan'
assert b.mode == 'plan'  # same state

Be carefulShared state that looks local surprises readers. Two sessions in one process will also overwrite each other's settings. So scope it per session if you run more than one.

13.02

Mute Idiom

Direct fit

Catch and ignore one specific, expected, harmless error, such as a failed best-effort cleanup, and explain why in the code.

raisesexpectedanything elsecontinuesurfacecleanup()rmtree(scratch)Whicherror?suppress()FileNotFoundErrorRe-raisedPermissionErrorRun completesError shownto loop and user
Fig. 13.02One named, harmless error is ignored. Everything else still shows up.
Classic meaning

The mute idiom deliberately ignores one known, unimportant exception (an error the program raises) in a small block of code. The code also notes why it is safe to ignore.

In the agent system

At the end of a run, the harness deletes a temporary sandbox folder or scratch file. A tool may already have removed it, so FileNotFoundError is expected there. Some other actions are best-effort (nice to have, but not required). One is sending a final 'done' event to a chat client that has already disconnected. Another is writing an optional usage metric. The harness wraps just that call in a narrow handler for the one exception type. It adds a comment explaining why, and it may log the error at debug level. Every other error still propagates (passes up to the caller). Examples are a permission error or a failed transcript write.

In a large company

In an enterprise platform, muted exceptions are still counted, usually as a debug log line plus a metric. So if a cleanup failure becomes frequent, someone notices. They catch it before it fills disks or leaves orphaned sandboxes (sandboxes that nobody owns). Writes that feed the audit trail, billing ledger, or retention and deletion jobs are never muted. A silent failure there becomes a compliance gap (a break in legal or policy rules). Static analysis rules (automatic code checks) that flag bare except blocks are a common part of the code-review gate.

Step by step
  1. Find a call whose failure does not change the outcome of the run.
  2. Name the exact exception type that is expected there.
  3. Catch only that type around only that call, with a comment.
  4. Let every other exception pass up as normal.
Code sketch
from contextlib import suppress

def cleanup(run):
    # the bash tool may have removed it
    with suppress(FileNotFoundError):
        shutil.rmtree(run.scratch_dir)
    # the client may already be gone
    with suppress(ConnectionClosed):
        run.ui.send({'type': 'done'})
    run.transcript.flush()   # never muted

Be carefulA bare except: pass (code that catches every error and does nothing) around a tool run hides real failures from the model and the user. Mute one named exception around one line, never a whole block.

13.03

CRTPCuriously Recurring Template Pattern

Give SDK base classes the concrete subclass as a type parameter, so shared chained methods return the specific builder or client type.

extendsextendsreturns T.with_tools()Builder[T]with_tools() -> TChatBuilderT = ChatBuilderBatchBuilderT = BatchBuilderAgent codechains calls
Fig. 13.03The base is told its subclass, so shared methods hand back the specific type.
Classic meaning

In CRTP, a class inherits from a generic base (a template that takes a type as input), and passes itself as that type. The base can then share behaviour that returns or calls the child type. It does this without virtual dispatch (choosing the method at run time).

In the agent system

An agent SDK has a base RequestBuilder with shared fluent methods. Fluent methods return the object, so you can chain calls one after another. Examples are with_system, with_tools and with_max_tokens. Subclasses such as ChatRequestBuilder or BatchRequestBuilder add their own methods. Suppose with_tools says it returns the base type. Then chaining .with_custom_id() after it fails type checking. The fix is to make the base generic over its subclass, written Builder[T]. This is the same shape as C++ CRTP or Java's Builder<T extends Builder<T>>. Then every shared method returns the concrete builder. Agent code can chain freely, and the type checker catches misuse. Some languages have a self type, such as typing.Self in Python or Self in Rust traits. A self type gives the same result without the type parameter.

In a large company

In an enterprise platform, this mostly shows up in internal SDKs and request builders that many teams share. Fluent APIs with accurate types reduce misuse of request fields such as tenant ids, budgets or data-classification tags. A tenant is one customer company. Strict type checking in CI (the automatic build and test system) is the control that makes the pattern pay off. Examples are mypy or pyright for Python, and the compiler for Java or TypeScript. Keep the class tree shallow, so platform upgrades do not break other teams' builders.

Step by step
  1. Make the base builder generic in its own subclass type.
  2. Shared fluent methods update state and return self, typed as that subclass.
  3. Subclasses add methods for their own kind of request.
  4. Callers chain base and subclass methods in any order, with full type checking.
Code sketch
T = TypeVar('T', bound='Builder')
class Builder(Generic[T]):    # CRTP base
    def with_tools(self, tools) -> T:
        self.req['tools'] = tools
        return cast(T, self)

class BatchBuilder(Builder['BatchBuilder']):
    def with_custom_id(self, cid):
        self.req['custom_id'] = cid
        return self

b = (BatchBuilder()
     .with_tools(TOOLS)       # BatchBuilder
     .with_custom_id('run-7'))  # type-checks

Be carefulIn Python this helps only static type checkers (tools that check types before the code runs). So prefer typing.Self where you can. Do not build deep generic class trees for one or two methods.

13.04

Twin

Adapted fit

Pair sync and async clients as two linked objects over one shared state, instead of one class that inherits from both.

TWIN PAIRextendsextendstwin linkshareHttpTransportblocking baseAsyncTransportasync baseSyncClientscripts, threadsAsyncClientagent event loopShared statekeys, quota, meter
Fig. 13.04Two halves, each with its own parent, linked to each other and to one state.
Classic meaning

Twin handles a class that would need two parents. It uses a pair of objects instead. Each extends one parent and holds a reference to the other, and together they act as one.

In the agent system

A model SDK usually ships two clients. Client is sync (each call blocks and waits), for scripts and notebooks. AsyncClient is async (calls can wait without blocking), for the harness's event loop (the part that runs many async tasks). One class that inherits from both a blocking HTTP base and an async base would clash. Method names and event-loop assumptions would collide. As twins, each client extends its own transport base. They hold references to each other and to shared parts. These are settings, credentials, rate-limit state and the usage meter. So a sync tool in a worker thread and the async agent loop share one view of the quota. Either side can reach its twin.

In a large company

In an enterprise platform, sync and async twins often share rate-limit state, a usage meter and credentials. The credentials are rotated from Vault or a cloud KMS (key management service). Worker threads and the event loop touch the shared part at the same time. So it must be thread-safe, using a threading lock, not an asyncio one. Keeping metering and policy in the shared state means each tenant's budget is enforced the same way, whichever twin made the call. A tenant is one customer company. Contract tests run the same scenarios through both twins, so their behaviour cannot drift.

Step by step
  1. Build the shared state once: settings, credentials and usage meter.
  2. Create a SyncClient on the blocking transport and an AsyncClient on the async one.
  3. Link each client to the other and to the shared state.
  4. Code in either world uses its own client, or reaches the twin through .aio or .sync.
Code sketch
class SyncClient(HttpTransport):
    def create(self, **req):
        return self.post(PATH, req)

class AsyncClient(AsyncTransport):
    async def create(self, **req):
        return await self.post(PATH, req)

def make_clients(cfg):
    st = Shared(cfg)       # keys, meter
    s, a = SyncClient(st), AsyncClient(st)
    s.aio, a.sync = a, s   # the twin links
    return s, a

Be carefulKeep the twins thin. If logic is copied into both instead of living in the shared part, sync and async behaviour will drift apart.

A
Appendix · Enterprise review

How this book was checked

After the book was written, every entry was checked again by a separate reviewer acting as a senior architect for business AI platforms. Each entry was compared with three things: the pattern's original definition in the standard books, how real agent systems actually behave, and what large companies need (keeping customers' data apart, controlling who can do what, keeping records for audits, protecting personal data, controlling cost, and seeing what the system is doing). Mistakes were fixed in place, and every entry gained an "In a large company" note. This appendix lists what was found and what changed.

192entries reviewed
85correct as written
92small fixes
15important fixes

Verdicts by chapter

CorrectSmall fixesImportant fixes

How faithful is each fit?

Each pattern has a badge beside its name. Direct fit means the agent system uses the pattern exactly as the books describe it. Adapted fit means it is the real pattern with a small twist for AI agents. Loose fit means the agent system borrows the idea more than the exact structure. Counts: 157 direct, 35 adapted, 0 loose. The loose fits are listed here.

What changed, entry by entry

Each card shows what the reviewer found and what the entry says now. Entries with nothing to fix are not listed.

01 · The Agent Loop

1.01Game LoopSmall fixesAdapted fit
  • agent systemsmallThe text only handled end_turn and tool_use. A loop that treats every reply without tool_use as finished gets two cases wrong. max_tokens means a cut-off reply, which may hold a cut-off tool_use. pause_turn means a long server-tool turn that must be sent again.Say that max_tokens and pause_turn each need their own handling. Say that a cut-off tool_use is never run.
  • code sketchsmallThe code leaves the loop on any reply without tool_use blocks. So a cut-off or paused reply is shown as a final answer.Check stop_reason for max_tokens and pause_turn first. Only then treat an empty tool list as end_turn.
  • classic meaningsmallNystrom's Game Loop is about keeping game time separate from user input and processor speed. The agent loop goes turn by turn and waits on each model call. So it is an adapted use, not a textbook example.Keep the definition, but mark the harness use as an adapted fit.
Parts rewritten: code sketch, agent system
1.05Chain of ResponsibilityImportant fixesAdapted fit
  • code sketchimportantThe path guard in the code used p.startswith(WORKSPACE) on a path that was not cleaned up first. So '../' tricks, symlinks (shortcuts to other files) and similar folder names such as /work-evil all passed the check. That makes it unsafe as security advice.Resolve the path against the workspace root first, which turns it into its real full form. Then check it with is_relative_to against the resolved root.
  • agent systemsmallIf any hook can return allow, an early hook can skip later required guards. The text also did not rank user and plugin hooks below organisation policy.Say that managed hooks run first and cannot be removed. Say that a user-level allow can only skip the final approval prompt.
  • summarysmallThe gist said any hook can allow. That conflicts with the fail-closed order a guard chain needs, where the chain blocks when in doubt.Say that any hook can block, change or pass on the call, and only the end of the chain lets it run.
Parts rewritten: code sketch, steps, summary, agent system
1.06InterpreterSmall fixesDirect fit
  • agent systemsmallA parse error can only go back as a tool_result if the program came inside a tool_use input. For plain text from the model, there is no tool_use id to answer.Say that errors go back as an error tool_result for tool input, and as a user message for plain text.
  • agent systemsmallThe entry did not say that Run and Edit nodes must pass the same permission checks and sandbox as ordinary tool calls. This is the main safety risk of letting the model write programs.Say that each node's interpret method goes through the normal permission chain and sandbox.
Parts rewritten: agent system
1.08IteratorSmall fixesDirect fit
  • code sketchsmallcontent_block_delta events can also carry input_json_delta and thinking_delta payloads. These have no text field. So ev.delta.text fails as soon as the model streams a tool call.Check that ev.delta.type is text_delta before reading ev.delta.text.
Parts rewritten: code sketch
1.10VisitorSmall fixesDirect fit
  • code sketchsmallThe redaction example only overwrote a top-level api_key field. It even added one when none was there. That is a weak model for redacting tool input.Clean every value in the tool input with a recursive scrubber, one that goes down into every nested value.
Parts rewritten: code sketch
1.11Acyclic VisitorImportant fixesDirect fit
  • code sketchimportantIn Python, isinstance(v, ImageVisitor) raises a TypeError, because ImageVisitor is a Protocol that is not marked runtime_checkable. So the example does not run.Add the @runtime_checkable decorator to the protocol, or use an abstract base class.
  • agent systemimportantThe example let a secret redactor ignore the new DocumentBlock type. That means documents reach logs or the model without being redacted. Silent skipping is unsafe for security visitors.Say that safety visitors such as a redactor must fail closed on block types they do not handle.
  • be carefulsmallThe warning covered the runtime type check, but not the bigger risk: security visitors silently skipping new types.Warn that redactors and scanners must reject block types they do not handle, rather than pass them through.
Parts rewritten: code sketch, agent system, be careful
1.16PipelineSmall fixesDirect fit
  • code sketchsmallIn the code, each stage got only the previous stage's output. So the reranker and compressor never saw the query they were meant to score against.Pass the query and the current chunk list to every stage.
  • agent systemsmallFor the same reason, the 'list in, list out' description was incomplete. The entry also left out access control as a stage. That is the most important filter when many users share one retrieval system.Describe stages as taking the query and a list. Name an access-control stage as an example of a stage you can add.
Parts rewritten: code sketch, agent system
1.17Rule EngineSmall fixesDirect fit
  • code sketchsmallThe code wrote the rules as Python lambdas (small inline functions), while the text said they come from a settings file. Also, the 'rm -rf' text match is easy to get around with rm -fr, rm -r -f or a full path.Write rules as data with declarative conditions, which state the condition rather than code it. Match on a risk class produced by a command classifier.
  • agent systemsmallSpotting destructive commands by looking for rm -rf in the command text is an unsafe model for approval rules.Say that a command classifier parses the shell command and supplies a risk fact. The rules match on that fact.
Parts rewritten: code sketch, diagram, agent system
1.18TrampolineSmall fixesAdapted fit
  • agent systemsmallThe text described tree-shaped planning (subgoals of subgoals), but a trampoline only flattens tail calls. The code showed straight-line tail recursion. So the text and the code described different shapes.Describe a tail-recursive planner that adds one subgoal per step. Note that tree-shaped planning needs an explicit work stack.
  • diagramsmallThe diagram labelled the last step Done(plan). That suggests it returns the function, not the collected result.Label it Done(steps).
Parts rewritten: diagram, agent system

02 · The Tool System

2.02StrategySmall fixesDirect fit
  • agent systemsmallThe tool was called web_search, but two of its strategies search the user's private documents or a local repo. So the model is told the results come from the web, and it may cite private files as web sources.Give the tool a neutral name such as search. Give it a description that does not promise a particular backend.
  • agent systemsmallThe text said the tool definition sits in the system prompt. In fact, tool definitions travel in the request's tools array, and that array forms the start of the cached prefix (the start of the request that the cache saves).Say the tool definition in the request stays byte-identical, so the cached prefix does not change.
Parts rewritten: code sketch, diagram, steps, agent system
2.03AdapterSmall fixesDirect fit
  • code sketchsmallThe adapter names tools 'mcp__' + spec.name, without the server name. So two MCP servers with the same tool name clash. This also goes against the mcp__github__create_issue naming used in Registry.Add the server name as a prefix, as in f'mcp__{srv}__{spec.name}'.
  • code sketchsmallOnly isError is mapped. MCP keeps tool execution errors (isError) apart from JSON-RPC protocol errors and transport failures (broken connections). Here, those last two would escape as exceptions instead of error results.Catch protocol and transport errors in run() and return them as is_error results.
Parts rewritten: code sketch, agent system
2.04ConverterSmall fixesDirect fit
  • agent systemsmallThe text said tool_result content blocks carry error parts. There is no error part type. A failure is shown by the is_error flag on the tool_result block.Say results go back as tool_result blocks with text or image content and an is_error flag.
  • code sketchsmallEach internal type converts in one direction only. But the harness sends every past tool_use block again on each turn, and the round-trip tests in the flow need a reverse step. There is no call_out.Add call_out(), which turns a stored ToolCall back into a tool_use block for history replay.
  • diagramsmallEvery row in the diagram is one-way. So it does not show the caption's claim of both directions, and the ToolCall row is missing the replay direction.Draw the ToolCall row as two-way, through call_in and call_out.
Parts rewritten: code sketch, diagram, steps, agent system
2.06RegistrySmall fixesDirect fit
  • be carefulsmallThe text said adding to the tools array keeps the prompt cache. It does not. Tools sit at the very start of the cached prefix, so any change to them breaks the cache for the system prompt and history behind them.Freeze the tools array for each session. Bring in late tools through deferred loading, which leaves the prefix unchanged.
  • diagramsmallIn the diagram, the get(name) arrow suggests the model looks in the registry itself. The model only sends a tool_use name. The harness dispatcher does the lookup.Label the arrow with the tool_use name that the dispatcher looks up.
Parts rewritten: diagram, be careful
2.13PrototypeSmall fixesDirect fit
  • agent systemsmallClones share the prompt cache only if each clone's changes stay out of the cached prefix. Putting the scope into the system prompt breaks this. Also, clones started all at once all miss the cache, because a cache entry exists only after the first response begins.Say that the changes go in the first user message, and that one clone should start before the others.
Parts rewritten: agent system
2.14ProxySmall fixesDirect fit
  • agent systemsmallDeny rules that match shell text such as rm -rf were shown as a safeguard. But text patterns are easy to get around with quoting, aliases or scripts. So they cannot be the security boundary.Say pattern rules only decide what needs approval, and the sandbox stays the hard boundary.
  • agent systemsmallDecisions were logged only to the transcript. The model reads the transcript, and compaction may rewrite it. So it is not a reliable audit record.Write each decision to a separate append-only audit log, as well as to the transcript.
Parts rewritten: agent system
2.15Virtual ProxySmall fixesAdapted fit
  • code sketchsmallOn first use, run() loads the tool and at once passes on the arguments. But the model wrote those arguments before it saw the full schema. So a tool that changes things can run with guessed or wrong arguments.Check the arguments against the loaded schema. Return an error that includes the schema when they do not match.
  • stepssmallThe flow never says what happens to the first call's arguments once the schema arrives.Add a step that checks the arguments against the real schema before passing them on.
Parts rewritten: code sketch, steps
2.16Dynamic ProxySmall fixesAdapted fit
  • code sketchsmallThe key part of a dynamic proxy is one shared handler. But the code put the checks and the call inside each stub. So the invoke() handler named in the text and diagram never appeared.Move the checks and the tools/call into one invoke(client, schema, args) handler. Make each stub a thin lambda (a tiny one-line function) that passes the call to it.
  • agent systemsmallAn MCP server's tool list is not fixed when it connects. A server that declares listChanged sends notifications/tools/list_changed. Stubs built once at startup then go out of date.Add that the harness fetches tools/list again when it gets that notice, and rebuilds the stubs.
Parts rewritten: code sketch, agent system
2.17DecoratorSmall fixesDirect fit
  • agent systemsmallConfiguration chooses the Retry wrapper per tool. That is safe for web search. But on a tool that is not safe to repeat, such as create_ticket, it would repeat the side effect. The text gave no rule for this.Say that Retry only wraps tools that are safe to repeat, or tools that send an idempotency key. Also say that it follows Retry-After.
  • code sketchsmallThe retry loop ignored the server's Retry-After hint on 429 or 529 responses (the server saying it is too busy). It always used its own fixed wait.Wait for the error's retry_after when it is there. Otherwise use exponential backoff (doubling the wait each time), ideally with jitter (a small random extra delay).
Parts rewritten: code sketch, agent system
2.19ServantSmall fixesDirect fit
  • code sketchsmallThe guard stored the workspace root exactly as given. If the root was a relative path or itself a symlink, every checked path failed is_relative_to. So the check rejected real files.Resolve the root to its real full path once in __init__. Then both sides of the comparison are in the same exact form.
Parts rewritten: code sketch
2.25BytecodeImportant fixesAdapted fit
  • agent systemimportantThe claim that 'the worst program is a useless one' is unsafe. In a logged-in browser, CLICK and TYPE can submit forms, buy things or delete data. A CLICK on a link can also reach a website that the check before running never saw.Say that a program can do only what the opcodes allow on approved sites. Say that the VM checks the website again at every page change. Say that clicks with side effects still need approval or a read-only session.
  • agent systemsmallThe text missed the main security benefit of a fixed program. The program is complete before any page content is read. So text injected into a page cannot add steps.Add that the program is fixed before it runs, so page content cannot change the list of instructions.
Parts rewritten: agent system
2.26Abstract DocumentImportant fixesDirect fit
  • agent systemimportantThe model's tool_use input was merged into the same flat document as trust-bearing keys such as approved_by. So a model, or a prompt injection, could put 'approved_by' or 'timeout_s' in its arguments. The gate or executor would then read them as harness facts.Keep the model's arguments in their own 'input' child document. Let only the harness part that owns a trust-bearing key write it.
  • stepsimportantThe first step built the document from the input plus metadata with no separation. That is the unsafe merge.Put the tool_use input under an input key, and have each part write only the keys it owns.
  • diagramsmallThe input arrow in the diagram had no label. It suggested the model's arguments merge at the top level next to harness settings.Label the input arrow to show it lands in its own child document.
Parts rewritten: diagram, steps, agent system
2.27PropertySmall fixesDirect fit
  • code sketchsmallA key missing from the whole chain reached the root, whose parent is None. It then crashed with an AttributeError instead of a clear lookup error.Raise KeyError at the root when there is no parent.
  • agent systemsmallA subagent's child map could override approval_mode with no rule about direction. So a child could loosen a safety setting its parent required.Say that a child map may change approval_mode only to a stricter value.
  • everyday comparisonsmallState gaming laws are a ceiling that tables cannot override. Putting them at the bottom of a fallback chain suggested a table sign could overrule the law.Make the last fallback level the casino group's standard rulebook, which local rules may rightly override.
Parts rewritten: everyday comparison, code sketch, agent system
2.28Parameter ObjectSmall fixesDirect fit
  • code sketchsmallA plain dataclass built with ShellRequest(**block.input) does no type checks. It leaves cwd as text and raises a bare TypeError on unknown keys. So it did not reject bad fields as the flow promised.Use a model that checks data, for example Pydantic with extra='forbid' and limits on fields. Parse the input once with model_validate.
Parts rewritten: code sketch
2.29Execute AroundSmall fixesDirect fit
  • code sketchsmallbox.mount() ran after the container started but before the try block. So a failed mount left the container running. That is exactly the failure Execute Around exists to prevent.Move everything after containers.start() inside the try block. Then any setup failure still reaches the finally block.
Parts rewritten: code sketch

03 · The Model Call Layer

3.01GatewaySmall fixesAdapted fit
  • code sketchsmallThe failover loop tries every route set up for the model alias. It does not filter by tenant policy. So an outage can quietly move a regulated tenant's prompts to a provider or region it never approved.Filter the route list through the tenant's policy (approved providers, residency) before trying any route. Record usage against that tenant.
Parts rewritten: code sketch
3.02AmbassadorSmall fixesDirect fit
  • stepssmallThe text says the sidecar streams the response back and retries short-lived failures. But once it has passed on part of a streamed response, it cannot quietly retry. The harness would see repeated or restarted output.Say the sidecar retries only failures that happen before it passes on the first response byte. It reports mid-stream errors to the harness.
  • agent systemsmallThe sidecar listens on plain localhost HTTP and adds the API key. So any process in the pod can spend that key. This is a confused-deputy risk (a trusted helper used by the wrong caller) in shared or multi-tenant pods.Bind the sidecar to loopback (this machine only) or a Unix socket (a local file-based connection). Check who the caller is. Prefer workload identity over a long-lived key.
Parts rewritten: steps
3.04RetrySmall fixesDirect fit
  • agent systemsmallA streamed call can fail after an HTTP 200 with an overloaded_error event inside the stream. A check on the status code never sees it. Also, some 429s carry no retry-after and will not clear by retrying. One example is a used-up spending cap.Treat error events inside the stream like their HTTP versions. Fail fast on quota or spending-cap 429s that carry no retry-after.
  • code sketchsmallThe backoff has no upper limit. 504 timeouts and 408s are not retried. After the final failure, the loop still waits before it raises the error.Cap the exponential delay. Add 408 and 504 to the list of retryable errors. Raise the error at once on the last attempt.
Parts rewritten: code sketch, agent system
3.05FallbackSmall fixesDirect fit
  • code sketchsmallThe chain is global, so every tenant tries the regional fallback ('large-eu'). Also, the code returns the canned 'busy' reply like a model message. So the loop may treat 'busy' as the assistant's final answer.Filter the chain by tenant policy. Return a typed Degraded result, which the loop shows as a status, not as the assistant's words.
  • agent systemsmallFalling back to another model or region starts with a cold prompt cache. Earlier thinking blocks from the primary model may also need to be dropped for the substitute.Say that fallback pays the full input price on the first call. Say that fit() removes model-specific content, such as earlier thinking blocks.
Parts rewritten: code sketch, agent system
3.06Circuit BreakerSmall fixesDirect fit
  • code sketchsmallOnce the first caller sets the state to half_open, every other caller at that moment also gets through. So the breaker sends a burst instead of one probe. Timeouts are also not counted as failures.Make the state check atomic (done as one step that cannot be interrupted). Refuse callers while a probe is running. Count timeouts along with server errors.
  • agent systemsmallThe text does not say how 429s are handled. Suppose rate-limit responses count as provider failures. Then the breaker opens for everyone when only one key or tenant is over its limit.Say that rate limiting and retry-after handle 429s. Only 5xx, 529 and timeouts count toward the breaker.
Parts rewritten: code sketch, agent system
3.07Rate LimitingSmall fixesDirect fit
  • code sketchsmallA request bigger than the bucket's capacity can never succeed. Yet it is told to retry after a computed wait. Also, an in-process bucket on each replica does not enforce one limit when the harness runs on many machines.Refuse oversize requests outright. Keep the buckets in a shared store such as Redis, so all replicas draw from one balance.
  • agent systemsmallCharging the full max_tokens up front is a careful design choice, but the provider does not do it. The provider counts output tokens as they are generated. Most models also do not count cache reads toward input limits. So the internal limiter can refuse work that the provider would accept.Say the reservation is deliberately cautious. Say it is settled against reported usage, including cache reads, after each call.
Parts rewritten: code sketch, agent system
3.08ThrottlingImportant fixesAdapted fit
  • classic meaningimportantThe standard definitions of Throttling (Azure Architecture Center, java-design-patterns) limit what an instance, tenant or service may use. They clearly include rejecting requests over the limit, as well as degrading and deferring. Defining it as only delaying 'rather than failing outright' gets the pattern wrong. It also blurs the real difference from Rate Limiting.Define Throttling as controlling the resources an instance, tenant or service may use. Past a threshold, it rejects, degrades or defers work, so the system keeps meeting its SLOs.
  • agent systemsmallPacing yourself from the provider's headers alone is what Azure calls the Rate Limiting pattern. The throttling part of this harness is the graded response: cap how many run at once, degrade, defer. The platform applies it to its own agents and jobs.Show the harness throttling its own agents and jobs with graded steps. Pacing is one of those steps.
  • code sketchimportantThe provider's rate-limit reset headers are RFC 3339 timestamps (a date and time), not a number of seconds. So float(h['tokens-reset']) fails. Also, wait_turn reads and writes self.last around an await (a pause point). So callers running at the same time can take the same slot.Parse the reset timestamp and subtract now(). Reserve the next slot before sleeping.
Parts rewritten: classic meaning, code sketch, steps, summary, agent system
3.09Load SheddingSmall fixesDirect fit
  • agent systemsmallThe text ranks every subagent as medium priority. Under load, the harness would then shed the subagents that a live chat turn is waiting on. That stalls exactly the interactive work that shedding should protect.Make each request inherit the priority of the root of its run. Then subagents working for a waiting user rank as interactive.
Parts rewritten: agent system
3.10BackpressureSmall fixesDirect fit
  • agent systemsmallPausing reads from the provider's SSE stream (server-sent events, the stream of response pieces) does not slow the model. It keeps generating. The push stops at the provider's buffers. A long pause also risks the provider closing the stream while it holds a concurrency slot.Read the provider stream at full speed into the transcript and a bounded buffer per client. Merge text pieces for a slow client.
  • code sketchsmallOne dispatcher coroutine (a task that can pause and resume) runs one subagent at a time. But the text and diagram describe four call slots. Also, task_done is skipped if a subagent raises an error.Start four dispatchers. Call task_done in a finally block, so it always runs.
Parts rewritten: code sketch, agent system
3.11Queue-Based Load LevelingSmall fixesDirect fit
  • code sketchsmallThe worker takes a job without a lease or acknowledgement. A lease holds the job for a time, and an acknowledgement confirms it is done. So if a worker crashes while holding a job, the job is lost. That contradicts the claim that nothing is lost on restart. If redelivery is added, a re-run agent could also post duplicate review comments.Receive jobs with a visibility lease, and acknowledge them after they finish. Use a deterministic run id (the same input always gives the same id), so redelivered or duplicate webhooks are skipped.
Parts rewritten: code sketch
3.12CachingImportant fixesDirect fit
  • agent systemsmallThe text leaves out two facts that decide whether caching pays. A prompt-cache write costs more than normal input: 1.25 times for the default five-minute TTL, and more for one hour. Also, prompts below a model-specific minimum length are not cached.Say the first call pays a write premium, reads cost a fraction of the input price, and short prefixes are not cached.
  • code sketchimportantThe local cache key is (url, day), with no tenant or user. In a multi-tenant harness, a page fetched with one user's login or network access could then reach another tenant's agent.Include the tenant and user in every local cache key. Log cache_creation_input_tokens (tokens written to the cache) next to cache reads.
  • be carefulsmallA changed prefix does not turn reads into full-price input. It turns them into cache writes, which cost more than a call with no caching at all.Say a changing prefix makes every call pay the cache-write premium and never get a read.
Parts rewritten: code sketch, agent system, be careful
3.17Partial ResponseSmall fixesDirect fit
  • code sketchsmallThe code runs a tool at content_block_stop, before the message has finished. A later stream error or a max_tokens stop then leaves a side effect for a turn that is incomplete or will be retried. The loop also quietly ignores ping, error and unknown event types.Run tools after the stream ends with stop_reason tool_use, using the inputs the SDK has collected. Handle a max_tokens stop explicitly.
  • stepssmallThe stream ends with a message_stop event after message_delta. The usage counts in message_delta are cumulative (running totals). But the flow closes the turn on message_delta.Record stop_reason and usage from message_delta. Close the turn on message_stop, and only then run tools.
Parts rewritten: code sketch, diagram, steps
3.18MoneyImportant fixesDirect fit
  • code sketchimportantcost() prices only input_tokens and output_tokens. But input_tokens leaves out cached tokens. So every cache write (billed at a premium) and every cache read is missing from the ledger. Also, a price table in whole micro-dollars per token cannot show prices below one micro-dollar per token. Such prices are common for input and cache reads.Price all four usage fields. Keep prices and amounts in a whole-number unit small enough for one token, such as pico-dollars. Round only at display or invoice time.
  • agent systemsmallThe text does not say three things. input_tokens is only the uncached part of the prompt. Cache-write prices depend on TTL. Streamed usage in message_delta is cumulative and must not be added up per event.Add those three accounting facts to the harness text.
Parts rewritten: code sketch, diagram, agent system, be careful

04 · Context and Memory

4.01RepositorySmall fixesDirect fit
  • code sketchsmallThe harness text says the repository limits results to the user, but the code's search(q, k) has no scope at all. The Protocol also has no remove. So the example invites a search across tenants and gives no way to delete data.Fix the tenant and user scope when the repository is created. Pass it as the index namespace on every search. Add remove() so the interface also covers forgetting.
Parts rewritten: code sketch
4.04Data MapperSmall fixesDirect fit
  • code sketchsmallfrom_hit drops the timestamp that to_record saved. So a Memory does not come back the same after saving and loading, and is_stale() would break. The record also has no tenant field to filter on.Store tenant and ts (timestamp) in the metadata. Make from_hit rebuild every field, including the parsed timestamp.
Parts rewritten: code sketch
4.05DTOSmall fixesDirect fit
  • classic meaningsmallFowler defines the DTO for crossing process boundaries, bundling data so a remote call needs fewer round trips. The old wording, 'between processes or layers', mixes it up with the use inside one process that he warns against.Define it as an object that carries data between processes. It bundles values into one payload that can be sent, so remote calls need fewer round trips. It has no behaviour of its own.
  • summarysmallThe gist repeats the 'between layers' idea, while the harness example correctly talks about process boundaries.Say the DTO carries flat, versioned data across process boundaries, such as to the tool runner and the chat client.
Parts rewritten: classic meaning, summary
4.06Value ObjectSmall fixesDirect fit
  • agent systemsmallThe old text said prompt-cache keys hash the prefix of blocks. But the provider's prompt cache matches the exact serialised prefix on its own servers. The harness's job is to resend byte-identical content, and that also needs deterministic serialisation.Say local caches key on block values. Say that blocks which never change, plus deterministic serialisation, keep a resent prefix byte-identical for the provider's prefix cache.
Parts rewritten: agent system
4.07Composite EntitySmall fixesAdapted fit
  • agent systemsmallHanding the whole session group, including its permission grants, to a subagent quietly passes on approvals that the user gave to the parent agent.Say that a forked or handed-over copy carries messages and state. The grants are removed and given again, with a smaller scope, to the agent that receives it.
Parts rewritten: agent system
4.08Unit of WorkSmall fixesDirect fit
  • code sketchsmallThe example registers the assistant message before run_turn has created it, so the work happens in the wrong order.Make run_turn create the reply. Then register its memories, todo changes and message with the unit of work, and commit on exit.
  • be carefulsmallIf a tool already ran during a turn, rolling back the turn also drops that tool_use and tool_result from the transcript. That erases the audit record, and a retry may repeat the side effect.Say executed tool calls are logged outside the unit of work, and tools that cannot be undone need a separate check. Then rollback never hides what really happened.
Parts rewritten: code sketch, be careful
4.10Lazy LoadingImportant fixesDirect fit
  • agent systemimportantMCP has no way to fetch one tool's schema on its own. A client gets the full definitions from tools/list. So the lazy step is loading a stored schema into context, not fetching it from the server.Say the harness lists each server's tools once and keeps the schemas in a local registry. It then loads only the requested schema into context.
  • agent systemimportantAdding the schema to the tools list for the next request edits the front of the prompt. That breaks the prompt cache from that point, so the cached prefix does not stay intact.Say discovered schemas are added after the cached prefix, as tool search on the provider side does, so the prompt cache stays valid.
  • stepssmallStep 3 of the flow repeats the per-tool fetch from the MCP server.Load the schema from the local registry into context, after the cached prefix.
  • code sketchsmallThe code fetches each tool's schema from the server.Load it from the local registry that tools/list filled.
  • diagramsmallThe diagram shows a per-tool 'get schema' call to the MCP server, which the protocol does not have.Show tools/list called once when connecting. Then show the harness loading one schema into context when the model searches.
Parts rewritten: code sketch, diagram, steps, agent system
4.11FlyweightSmall fixesAdapted fit
  • agent systemsmallSharing one object in memory does not by itself give cache hits. The provider's prompt cache matches the bytes of the serialised prefix, within its scope and lifetime.Say that every session turns the bundle into the same bytes. So requests share a cache prefix, as long as they fall within the cache's lifetime and scope.
Parts rewritten: agent system
4.16Serialized LOBSmall fixesDirect fit
  • code sketchsmallThe 'recent' sessions query has LIMIT 20 but no ORDER BY. So it returns any 20 sessions, not the most recent ones.Add ORDER BY updated DESC before the LIMIT.
Parts rewritten: code sketch
4.17Metadata MappingSmall fixesDirect fit
  • agent systemsmallThe search tool's filters are generated from the same metadata. If user_id is marked as a filter, the model could set the scope filter itself and search another user's memories.Mark scope keys such as user_id separately. The harness fills them from the caller's identity and never offers them as filters the model can set.
  • code sketchsmallThe code marks the user field as an ordinary filter.Mark it scope=True instead of filter=True.
Parts rewritten: code sketch, agent system
4.22ShardingImportant fixesDirect fit
  • code sketchimportantThe code hashes every tenant across a mixed list of EU and US databases, so an EU tenant can land on db_us1. Plain hashing ignores data residency (rules about which country data must stay in).First look up the tenant's region in a tenant directory. Then pick a shard within that region by consistent hashing. Give large tenants pinned shards.
  • agent systemimportantThe old text claimed hashing keeps one customer's memories physically separate and helps with residency. But hash sharding puts many tenants on one shard, and it cannot keep data in a region on its own.Say the region placement comes from a directory. Say tenants that share a shard are separated only logically, so tenant filters or row-level security are still required.
  • diagramsmallThe diagram labels the shards as EU and US but routes by hash(tenant) % 4, which contradicts itself.Show a tenant directory that picks the region, then a hash among that region's shards.
  • stepssmallThe flow leaves out the region lookup.Add the directory lookup for the region and any pinned shard before the hashing step.
Parts rewritten: code sketch, diagram, steps, agent system

05 · Multi-Agent Orchestration

5.01Actor ModelSmall fixesAdapted fit
  • agent systemsmallIt said a crashed subagent can be restarted from its mailbox. But an in-memory asyncio.Queue (a message list held only in memory) loses the message being worked on. Also, in the actor model, restarting is a supervisor's job.Say that a supervisor restarts the crashed subagent. Say the in-progress message survives only if the mailbox is durable and removes the message after the reply is sent.
Parts rewritten: agent system
5.03Master-WorkerSmall fixesDirect fit
  • code sketchsmallOn every pass, the loop started up to MAX_WORKERS new tasks, no matter how many were already running. It never put timed-out workers back in the queue. They stayed 'running', and the loop could end early. It also retried a failing batch forever.Start tasks only in free slots. Treat a timeout as a failed result. Mark a batch failed after a set number of retries.
  • be carefulsmallA worker may partly edit a batch and then time out. Handing that batch out again repeats edits that are not idempotent (not safe to run twice) on files that are already changed.Say each worker should run in its own branch or worktree, so a batch that is handed out again starts clean.
Parts rewritten: code sketch, be careful
5.05Fan-Out/Fan-InSmall fixesDirect fit
  • code sketchsmallCalling j.result() on a finished task that raised an error raises that error again, and the whole bundle is lost. Also, 'missing' counted only late replies. It did not count failed ones or name which source was absent.Keep a map from each source name to its task. Collect only the successful results. List every missing source by name.
Parts rewritten: code sketch
5.07Leader-FollowersSmall fixesAdapted fit
  • be carefulsmallIn the POSA2 book, Leader-Followers is a pattern for threads inside one process. The entry did not say that across machines, passing the token becomes a hard coordination problem. A queue already solves that problem.Add that for workers spread across machines, competing consumers on a queue with visibility timeouts is the simpler, standard choice.
Parts rewritten: be careful
5.08Leader ElectionSmall fixesDirect fit
  • code sketchsmallThe code renewed the lease only after a whole round of coordination. A round longer than the TTL lets the lease expire while the old leader is still acting. It also tried to get a new lease instead of renewing the one it already held.Hold the lease in an inner loop. Do the work in slices shorter than the TTL, and renew between slices. Stop as soon as renewal fails. Pass the term as a fencing token (a number that lets other systems reject an old leader).
  • diagramsmallThe diagram drew the renewal as Agent A calling itself. But renewing a lease is a write to the lease store.Draw 'renew(lease) every 10s' as a message from Agent A to the lease store.
Parts rewritten: code sketch, diagram
5.13Polling Pub/SubSmall fixesDirect fit
  • be carefulsmallThe cursor was saved after the whole batch, so a crash replays events that were already handled. The entry did not say so. Also, database sequence ids can commit out of order. A reader using them as a cursor can skip an id that commits late.Say handlers must be idempotent because replays happen. Say cursor reads must not trust raw sequence ids written by transactions running at the same time.
Parts rewritten: be careful
5.15Microservices AggregatorSmall fixesAdapted fit
  • classic meaningsmallThe definition was right for the microservices Aggregator (API Composition, as described by Richardson). But it did not set it apart from the EIP Aggregator in Hohpe's book. That one is a stateful filter. It collects related messages that arrive over time and sends one when the set is complete or a timeout passes.Say this pattern combines replies at request time, inside one request. Contrast it with the EIP Aggregator.
  • code sketchsmallWith return_exceptions=True, errors went straight into merge as if they were results. No branch had a timeout. The code never named the missing parts that the flow and watch promise.Put a timeout on each call. Separate errors from results. Pass the list of missing sections to the report.
Parts rewritten: classic meaning, code sketch
5.16SagaImportant fixesAdapted fit
  • classic meaningsmallThe definition left out the two coordination styles that microservices.io defines. In choreography, each participant reacts to the previous one's events. In orchestration, a central orchestrator gives the participants their orders.Name both styles in the definition.
  • agent systemsmallThe harness text described an orchestrated saga without saying so. It also did not mention that a saga gives no isolation, so other users can see the in-between states.Call it an orchestrated saga, contrast it with choreography, and note the lack of isolation.
  • code sketchimportantThe code kept finished compensations in a list in memory. So a coordinator that crashed could not undo anything. That contradicts the harness text, which says it reads the saga log. Also, one failing compensation stopped the rest and hid the original error.Record each finished step durably and drive the undo steps from the log. Use idempotency keys. Retry each compensation until it succeeds.
Parts rewritten: classic meaning, code sketch, agent system
5.17CommanderSmall fixesAdapted fit
  • code sketchsmallAfter a step used up its retries, the code sent it to a person but then carried on with later steps. It could post notes for a package that was never published. It retried errors that retrying cannot fix, such as a 400 (bad request). It ignored Retry-After (the server's hint on when to try again). And it started a fresh job instead of picking up the logged one.Pick up from the log. Send an idempotency key. Retry only errors that can be retried, and follow Retry-After. Stop and hand the job to a person when a step cannot finish.
Parts rewritten: code sketch
5.18Distributed TracingSmall fixesDirect fit
  • code sketchsmallInside the subagent, the model and tool spans used the orchestrator's context as their parent. That made them siblings of the subagent instead of its children. It also contradicted the diagram, where span 4 has parent 2.Open a span for the subagent's own run from the context passed to it. Make its model and tool spans children of that span. Use the GenAI span names invoke_agent, chat and execute_tool.
Parts rewritten: code sketch
5.20Self-RegistrationSmall fixesDirect fit
  • code sketchsmallderegister ran only when serve returned cleanly. So an error skipped it, and the stale entry stayed until the TTL expired.Put deregister in a finally block (code that always runs, even after an error).
  • be carefulsmallThe watch note checked schemas but left out the main company risk of self-registration. If the registry does not check identity, any process can advertise tools, and the model will then be offered them.Say the registry must authenticate each service that registers and accept only allowlisted servers, before checking that live schemas match.
Parts rewritten: code sketch, be careful

06 · Async, Concurrency and Scheduling

6.01PromiseSmall fixesDirect fit
  • code sketchsmallThe code sets up the error handler with h.on_error after 'await h'. A rejected handle raises its error at the await, so the handler never runs.Wrap the await in try/except and handle the subagent failure there.
Parts rewritten: code sketch
6.02Async Method InvocationSmall fixesDirect fit
  • agent systemsmallThe text said the model goes on with other tool calls in the same turn. But the model only sees the task id once the tool_result comes back. So it goes on at its next step, not alongside the call.Say the model reads the task id in the tool_result. It then goes on with other tool calls in its next steps while the job runs.
  • code sketchsmallThe later-turn code reuses the local future variable. That variable no longer exists in a later turn. Also, nothing ties the task id to its session.Store the future in a registry (a lookup table) keyed by session and task id. Look it up there in the later turn.
Parts rewritten: code sketch, agent system
6.03Event-Based AsynchronousSmall fixesAdapted fit
  • code sketchsmallThe code starts the job with run_async before it registers its handlers. That is exactly the lost-completion race the watch line warns about.Create the task, register the progress, completed and failed handlers, then call run_async.
  • stepssmallStep 1 started the job before it mentioned any subscription. That repeats the same ordering problem.Step 1 says the caller registers its handlers, then starts the job and gets a task id.
Parts rewritten: code sketch, steps
6.04Producer-ConsumerSmall fixesDirect fit
  • code sketchsmallIf run_agent raises an error, the worker dies and never calls task_done. Then join() hangs forever. Also, the webhook producer awaits put. That holds the HTTP request open instead of answering 429 as the text describes.Wrap the job in try/finally so task_done always runs. Have the webhook producer use put_nowait and return 429 when it gets QueueFull.
Parts rewritten: code sketch
6.08Thread-Pool ExecutorSmall fixesDirect fit
  • agent systemsmallThe text sent every tool_use block in a turn to the pool. That is only safe for independent, read-only calls. Two parallel writes to the same file, or two calls with side effects, can race (clash in time). The text also called pool size the single control for CPU, sandboxes and model concurrency. That overstates it.Run independent read-only calls in the pool. Run calls that change things one at a time. Give model concurrency and sandbox count their own limits.
  • code sketchsmallf.result() raises a tool's error again. That stops the gathering step and loses the other calls' results. It should return an is_error tool_result for that one call instead.Catch the error for each future separately. Store an error tool_result under that call id.
Parts rewritten: code sketch, agent system
6.09Thread-Specific StorageImportant fixesAdapted fit
  • be carefulimportantThe old text suggested run_in_executor as a way to carry context into worker threads. But loop.run_in_executor does not copy contextvars. Only asyncio.to_thread and an explicit copy_context().run do. Following the old advice leaves permission checks in the worker reading no session, or an old one.Say context follows asyncio tasks and asyncio.to_thread. Say it does not follow run_in_executor or raw threads, which need copy_context().run.
Parts rewritten: be careful
6.10Guarded SuspensionSmall fixesDirect fit
  • code sketchsmallThe text and the watch line both require a timeout that denies by default. But the code waits on the condition with no timeout.Wrap the wait in asyncio.timeout. Set the decision to deny when TimeoutError happens.
Parts rewritten: code sketch
6.13Half-Sync/Half-AsyncSmall fixesAdapted fit
  • code sketchsmallThe sync half calls backend.run, but the async half defines backend_run. Also, on timeout the coroutine (the async function) keeps running and spending tokens, because the future is never cancelled.Use the name backend_run in both places. Cancel the cross-thread future when the blocking wait ends.
  • agent systemsmallPOSA 2 is a classic book on concurrency patterns. In its standard form, the async layer does the I/O and hands requests up through the queue to blocking worker threads. The version shown here, a sync front over an event loop, has the same three layers but calls in the opposite direction.No text change needed. The fit is recorded as adapted, and the enterprise note describes the standard gateway-to-workers form.
Parts rewritten: code sketch
6.15Lockable ObjectSmall fixesDirect fit
  • code sketchsmallThe lease lasts 60 seconds, but it is held across a model call that can take longer. Once it expires, another writer can take the lock. Then the compactor's replace_old_turns overwrites newer messages.Give out a fencing token with the lease. Have the store reject any write whose token is no longer current.
  • be carefulsmallThe watch line mentioned a lease and an owner id but not renewal or fencing. Those are what actually protect against an expired holder that is still running.Say to renew the lease while working. Say to reject writes that carry a stale fencing token.
Parts rewritten: code sketch, be careful
6.16Double-Checked LockingSmall fixesDirect fit
  • agent systemsmallThe text listed MCP connections and provider clients as singletons for the whole process, shared by many sessions. In a multi-tenant harness, these carry per-tenant keys or per-user OAuth tokens. So one global instance leaks one principal's access to others.Cache clients that carry credentials under a key that includes tenant and principal. Do the double check per key.
  • be carefulsmallGiving credit to the GIL (Python's global interpreter lock) is not precise. What matters is that publishing the reference is atomic and happens after construction. CPython guarantees this for a name binding. Java needs volatile and C++ needs an atomic.State the rule as atomic publishing after construction. Name what other runtimes need.
Parts rewritten: code sketch, agent system, be careful

07 · Safety, Permissions and Resilience

7.01Intercepting FilterSmall fixesDirect fit
  • be carefulsmallThe old watch line only flagged latency. The bigger risk in production is treating probabilistic injection and moderation classifiers as a security boundary. A filter that errors, or a stream that runs ahead of the output chain, lets content through.Say filters are defence in depth and must fail closed. Say hard limits such as tool permissions and egress allow-lists stay in the executor.
  • agent systemsmallAn output text filter that checks whether a tool call targets a blocked domain is weak. The check that truly decides belongs where the tool runs and where network egress (outgoing traffic) is controlled.Keep the output check as an early warning. Enforce blocked domains in the tool executor or the egress proxy.
Parts rewritten: be careful
7.02SpecificationSmall fixesDirect fit
  • agent systemsmallClauses such as UserRequested are only safe if they read facts the harness holds, such as recorded user turns and approval records. If they trust claims in the model's tool arguments, an injected instruction can satisfy the policy. An injected instruction is hidden text that gives the model orders.Say each leaf spec reads trusted harness facts, never claims supplied by the model.
Parts rewritten: agent system
7.04Role ObjectSmall fixesAdapted fit
  • code sketchsmallThe code removes a Reviewer role that it never attached.Attach Reviewer before removing it.
  • agent systemsmallThe text said the user grants more access mid-session. It left out that granted roles must stay within the user's own entitlements and tenant policy. Otherwise the agent becomes a confused deputy, a trusted helper misused to go beyond its requester's rights.Say roles are added from the user's entitlements or an audited admin grant. Say they never go beyond what the user holds.
Parts rewritten: code sketch, agent system
7.05BulkheadSmall fixesDirect fit
  • code sketchsmallThe code creates one container at module level and shares it with every pool. That is the opposite of the per-session sandbox the text describes. Also, the semaphore (a counter that limits how many can enter) makes a full pool wait. The flow and diagram say it should reject.Create a container per job inside the pool slot. Reject at once when the compartment is full.
Parts rewritten: code sketch
7.06Idempotent ConsumerImportant fixesDirect fit
  • code sketchimportantThe code inserts the key before sending the email. A crash between the insert and the send makes every redelivery look like a duplicate. So the email is never sent, and no stored result exists.Claim the key with a status. Skip only when the row is done. When it is still pending, retry the send with the same provider idempotency key.
  • agent systemimportantThe text claimed the idempotency key passed to the email API covers a crash mid-send. But with insert-before-act, the retry never reaches the API. So that key never gets the chance to help.Describe the pending and done states. Say the provider's idempotency key covers the retry of a pending row.
  • stepssmallStep 3 treated any existing key as finished work.Split the existing-key case in two. Done means ack. Pending means retry with the same key.
  • diagramsmallThe diagram's decision asked whether the key was seen. That builds in the same lost-action bug.Ask whether the key is done. Send pending keys to the send step with the same idempotency key.
Parts rewritten: code sketch, diagram, steps, agent system
7.08Health CheckSmall fixesDirect fit
  • agent systemsmallThe text dropped a connector's tools from the next request whenever a probe failed. That changes the tools list, which invalidates the prompt cache from that point. It also confuses the model if health keeps flipping.Return a connector-unavailable tool_result at call time. Hide tools only after a long outage.
  • agent systemsmallThe text tied dependency health into readiness without limits. During a shared outage, such as the model provider, that can mark every host unready. A partial failure then becomes a full one.Base readiness on the host's own dependencies. Keep liveness a cheap process check.
Parts rewritten: agent system
7.09ImmutableSmall fixesDirect fit
  • code sketchsmallThe hash covers prev_hash and content but not role. So someone could change a stored entry's role from user to assistant without breaking the chain.Hash every field of the entry.
Parts rewritten: code sketch
7.10Private Class DataImportant fixesAdapted fit
  • agent systemimportantThe old text claimed that scrubbing headers from tool results stops even an echoing endpoint from leaking the token. But an endpoint can echo the token in its body. So header-only scrubbing leaves a path for the secret into the context window (the text the model can see at one time).Scrub every tool_result, headers and body, for the secret's exact value and credential patterns. Keep the hard boundary outside the process.
  • agent systemsmallA long-lived token loaded once at startup is the opposite of enterprise practice. Enterprises use short-lived, narrowly scoped credentials per session or principal.Prefer a short-lived, scoped token fetched for the session over a long-lived key loaded at startup.
  • stepssmallStep 4 repeated the header-only scrubbing claim.Say results are scrubbed for the secret's value and credential patterns anywhere in the reply.
Parts rewritten: steps, agent system

08 · Harness Architecture

8.05Monolithic ArchitectureSmall fixesDirect fit
  • agent systemsmallThe scaling sentence skipped two real breakages. A local SQLite file cannot be shared across copies. And a scheduler thread inside each process runs every timed task once per running copy.Say that scaling out needs a shared database. It also needs a lease or leader election, so each timed task runs exactly once.
Parts rewritten: agent system
8.06Event-Driven ArchitectureSmall fixesDirect fit
  • stepssmallThe text said a slow subscriber never blocks the loop. That is only true if each subscriber has its own buffered cursor on a lasting log. With a bus that calls handlers directly in the same process, as the decorator code suggests, a slow handler blocks the sender.Say that each subscriber reads from its own cursor on the log. A slow one falls behind and catches up instead of blocking the loop.
Parts rewritten: steps
8.07CQRSSmall fixesDirect fit
  • code sketchsmallThe code calls require_pending and then always appends. This is a check-then-act race: two approve or deny commands for the same call can arrive at once. Both can pass the check and both can append.Append along with the version number that was read. Then the second command fails with a conflict.
Parts rewritten: code sketch
8.08Anti-Corruption LayerSmall fixesDirect fit
  • be carefulsmallThe entry treats the ACL only as a shape translator. It never says that translating is not cleaning. The tool descriptions and results it maps are untrusted input. They can carry prompt injection (hidden instructions aimed at the model) or change after approval.Add that tool descriptions and results stay untrusted after translation. So pin the approved descriptions and flag any change.
Parts rewritten: be careful
8.09StranglerSmall fixesDirect fit
  • agent systemsmallShadow runs of an agent loop that uses tools will repeat side effects, such as sending email or writing tickets, unless the tools are stubbed. The entry suggested shadow traffic without that guard.Say that shadow runs replay requests through the new loop with side-effect tools stubbed or in dry-run mode.
Parts rewritten: agent system
8.11API GatewaySmall fixesDirect fit
  • everyday comparisonsmallThe analogy said gates behind security never check passports again. That teaches blind trust inside the walls. It clashes with zero-trust practice, where services still check an identity passed along to them.Have gate staff scan the boarding pass that security already checked. This mirrors services checking a signed identity token.
  • agent systemsmallThe entry did not say how identity reaches the services. It gave the idea that anything arriving from the gateway is trusted.Add that the gateway first removes any identity headers from the client. Then it forwards the checked identity as a signed internal token that services still check.
Parts rewritten: everyday comparison, code sketch, agent system
8.12Feature ToggleImportant fixesDirect fit
  • agent systemimportantThe entry suggested that leaving a tool out of the tools array is enough. But the model can still ask for a hidden tool by name, and nothing in the dispatcher rejected it. So hiding was shown as if it were blocking.Add that the tool dispatcher rejects any call outside the session manifest.
  • agent systemimportantFreezing every flag for the session also froze kill switches. Then an operator could not stop a broken tool in sessions already running. That is unsafe for an operations switch.Keep release and experiment flags frozen so the cache stays stable. But check kill switches live at dispatch, and return an error tool_result (the reply block for a tool call).
  • code sketchsmallThe code showed only how the session is built. So it did not show the blocking or the live kill switch.Show build_session plus a dispatch function that checks the manifest and a live kill flag.
  • stepssmallThe flow did not tie the flag check to the checked identity. That matters when you target by tenant.Get tenant, org and plan from the checked identity. Never take them from details the client sent.
Parts rewritten: code sketch, steps, agent system
8.14Subclass SandboxImportant fixesDirect fit
  • agent systemimportantThe entry presented the base class and a load-time import check as the security boundary for outside plugins. In Python, dynamic imports, ctypes or reflection can get around such checks. So this is unsafe advice for untrusted code.Say that the pattern keeps plugins separate from the rest of the code. Outside plugins must run in an isolated process or WASM runtime, where each building block is an RPC to the host.
  • stepsimportantThe last step said that rejecting raw imports at load time keeps plugins safe. That is the same misleading claim.Describe isolation as the boundary, and the import check as an early error for authors.
Parts rewritten: steps, agent system
8.16Service LayerSmall fixesDirect fit
  • code sketchsmallThe code checks has_running_turn outside the transaction and then starts the loop. This is a check-then-act race: two send_turn calls at the same moment can both start. It also keeps the transaction open while the model call starts.Lock the session row. Check and mark the turn as running inside the transaction, then save. Start the model call after that, outside the transaction.
Parts rewritten: code sketch
8.17Business DelegateSmall fixesDirect fit
  • code sketchsmallThe watch tells readers to send an idempotency key with every retry. But the code retries POST /sessions without one, and it ignores Retry-After.Make one idempotency key before the loop and send it on every try. Wait for the Retry-After time when the server gives one.
Parts rewritten: code sketch, agent system
8.18Session FacadeSmall fixesDirect fit
  • agent systemsmallThe text claimed the facade does everything inside one transaction. But file storage and the runtime are separate systems, and a database transaction cannot cover them. Also, a big call that is not idempotent (safe to run twice) needs retry protection.Create the session as pending and turn it on only when every step succeeds. Clean up on failure, and accept an idempotency key.
Parts rewritten: agent system
8.21Transaction ScriptSmall fixesDirect fit
  • code sketchsmallThe loop has no step limit. So a model that keeps calling tools can run up endless cost. It also saves messages only at the end. If the request dies mid-loop, tool side effects that already happened are lost from the record.Limit the number of steps. Save each model and tool message as it is made. Return all tool_result blocks together in one user message.
  • stepssmallThe last step wrote rows only at the end. That matched the same saving problem in the code.Say the script saves each message as it goes, so tool calls that ran are on record.
Parts rewritten: code sketch, steps
8.22Table ModuleSmall fixesDirect fit
  • code sketchsmalltrim_to_budget cuts at any message row. So it can keep a tool_result whose matching tool_use was dropped. The model API rejects that.Cut only at turn boundaries, so every tool_use stays with its tool_result.
Parts rewritten: code sketch

09 · Functional Plumbing

9.01Collection PipelineSmall fixesDirect fit
  • agent systemsmallFiltering out old tool results breaks the tool_use and tool_result pairing that the API requires. The API rejects the request unless the matching tool_use is also removed, or the result is replaced by a placeholder.Replace old tool results with a short placeholder, so every tool_use still has its tool_result.
  • code sketchsmallThe second filter in the code dropped old records completely, which causes the same pairing problem.Map old results to a stub (a short stand-in) instead of filtering them out.
  • be carefulsmallA window that slides every turn changes the start of the prompt each time. That defeats prompt caching, and the entry did not mention it.Mention that trimming in larger steps keeps the cached start of the prompt stable.
Parts rewritten: code sketch, diagram, agent system, be careful
9.02Function CompositionSmall fixesDirect fit
  • classic meaningsmallThe classic text said compose(f, g)(x) equals f(g(x)). But the code's compose runs its arguments left to right, so compose(strip_ansi, redact) runs strip_ansi first. The definition and the code disagreed.Say that maths composition runs right to left, and that the left-to-right form in the code is usually called pipe.
Parts rewritten: classic meaning
9.03CombinatorSmall fixesDirect fit
  • code sketchsmallThe example policy let any read_only call through first. So reading a .env file (a file that often holds secrets) was allowed, and its secrets entered the context window, the text the model can see. The .env rule only applied to writes.Put the secret-file rule on the outside: all_of(not_(matches('*.env')), any_of(read_only, in_workspace)).
  • diagramsmallThe diagram showed the same policy shape, with the .env rule nested under the write branch.Redraw it with all_of at the top and not_(*.env) checked and failing first.
Parts rewritten: code sketch, diagram, agent system

10 · The Harness Frontend

10.01MVCSmall fixesDirect fit
  • agent systemsmallThe controller handled Escape by marking the run as cancelled in the model. That only changed the app's own state. Nothing told the harness to stop the running tools or the model call.On Escape, the controller asks the harness to cancel the run. The harness then records the cancelled status in the model.
Parts rewritten: agent system
10.03Model-View-ViewModelSmall fixesDirect fit
  • be carefulsmallThe text blamed the storm of redraws on two-way bindings. But the token stream flows only one way, into messages. The real cost comes from small bindings firing on every new piece of text.Say that a bound field updated on every token redraws on every token, so new pieces should be collected and applied once per animation frame. Keep the separate point that two-way bindings make the order of updates hard to follow.
Parts rewritten: be careful
10.04MVISmall fixesDirect fit
  • code sketchsmallThe text_delta branch called s.msgs[-1].extend(...). That reads as changing the last message in place. It undermines the fixed snapshot that replay depends on.Build a new message with the added text and a new list. Leave the previous state untouched.
Parts rewritten: code sketch
10.06BlocSmall fixesDirect fit
  • agent systemsmallA cancelled async generator cannot send out anything else, so it cannot send Cancelled itself. Also, cancelling the stream in the app alone does not stop the run on the harness server.The Stop handler cancels the subscription, calls the harness cancel endpoint, and then sends out Cancelled.
  • be carefulsmallLimiting Streaming states to a few per second makes streamed text look jumpy.Collect the new pieces and send at most one Streaming state per frame.
Parts rewritten: agent system, be careful
10.07Presentation ModelSmall fixesAdapted fit
  • agent systemsmallIt said the same object drives the web, terminal and IDE apps. But these run in different processes. What they share is the class, with one instance per screen. It also called fixed field checks frontend evals. The testing chapter keeps the word eval for checks of model behaviour.Say the same class drives each app, with one instance per screen. Call the field checks unit tests.
  • diagramsmallThe diagram node labelled Frontend eval wrongly names a unit test that gives the same result every time.Rename the node to Unit test.
Parts rewritten: diagram, agent system
10.08Front ControllerSmall fixesDirect fit
  • agent systemsmallSaying new endpoints get auth for free can sound like it covers permissions too. The front controller checks who the user is. But whether this user may touch this session or tenant is object-level authorization. Each handler must still check that.Add that the front controller checks identity and finds the tenant, while handlers still check access to the specific item.
Parts rewritten: agent system
10.09Page ControllerSmall fixesAdapted fit
  • diagramsmallThe caption said there is no central dispatcher, but the code registers every controller with a router. That blurs the line with Front Controller.Say that the router only links a path to a controller and does no shared work. A web server does this same job for classic page controllers.
  • code sketchsmallpost_rename renamed any session id it was given. It did not check that the session belongs to req.user. This is an insecure direct object reference (letting a user reach an item just by knowing its ID), and readers may copy it.Load the session limited to its owner before renaming it.
Parts rewritten: code sketch, diagram, agent system
10.10Composite ViewSmall fixesDirect fit
  • code sketchsmallThe block map used an 'artifact' content block type. The Messages API (the model provider's interface for sending messages) has no such type. The map also raised a KeyError (a lookup failure) on user messages, whose content holds tool_result blocks.Map real block types (text, thinking, tool_use). Skip block types the message view does not draw itself. Attach each tool_result to its tool card by tool_use_id.
  • agent systemsmallIt suggested that an artifact is a content block type. It also suggested that the tool_result sits in the same assistant message as its tool_use. In fact the result arrives in the next user-role message.Say the tool card is matched to its tool_result by tool_use_id from the following message. An artifact card is chosen when a tool call produced an artifact.
Parts rewritten: code sketch, agent system
10.11Template ViewSmall fixesDirect fit
  • code sketchsmallOnly result was escaped, and by hand. name and status were passed in raw. With an autoescaping engine, the hand escape escapes twice. Without one, the other fields are open to injection.Use an autoescaping engine and pass every value in raw. Then all placeholders are escaped the same way.
Parts rewritten: code sketch
10.14Client-Side UI CompositionSmall fixesDirect fit
  • diagramsmallThe mount arrows pointed from the pieces to the shell. But the shell is the active party. It loads each piece and mounts it into a slot.Point the mount arrows from the app shell to each piece.
Parts rewritten: diagram
10.15Naked ObjectsSmall fixesAdapted fit
  • be carefulsmallThe only risk named was unclear schemas. The bigger risk is that titles and descriptions from a third-party MCP server are untrusted text shown to the approver. Also, user edits make the arguments that run differ from what the model proposed.Warn that schema text from the server must be escaped and never used as a risk label. Warn too that both the proposed and the edited arguments must be recorded.
Parts rewritten: be careful

12 · Testing and Evals

12.01Arrange/Act/AssertSmall fixesDirect fit
  • code sketchsmallThe last check looked for the word 'fixed' in the final text. That is the exact-wording check the entry's own warning advises against.Replace it with a model-graded rubric score against a threshold, after the fixed checks.
  • agent systemsmallIt treated one run as the verdict. But agent runs vary. Real eval practice runs each case several times and gates on the pass rate against a baseline.Add that each case runs several trials, and the suite gates on the pass rate against the last release.
Parts rewritten: code sketch, agent system
12.03Page ObjectSmall fixesAdapted fit
  • diagramsmallIn the diagram, the browser agent calls approve() on the chat page object. That shows an agent clicking Allow on approval prompts, which defeats human-in-the-loop control (a person making the final decision).Give the agent a read or navigation action, such as transcript(). Keep approve() for the test.
  • agent systemsmallIt recommended page objects for browser agents without saying that approval actions must not be given to them.Add that page objects given to an agent leave out approval controls, so an agent can never approve its own tool calls.
Parts rewritten: diagram, agent system

13 · Code-Level Idioms

13.03CRTPSmall fixesAdapted fit
  • code sketchsmallThe code mixes two techniques. It makes the base take the subclass as a parameter (CRTP). But it also writes self: T, which is the method-level self-type trick and needs no Generic base. Type checkers can reject a class type variable used as the self type.Keep the CRTP form. Methods return T, and the base returns cast(T, self).
  • agent systemsmallRust has no implementation inheritance (a type cannot inherit code from a parent class). So Rust does not express CRTP with a template parameter. Its traits return Self directly. The closest common equivalent is Java's F-bounded Builder<T extends Builder<T>>.Cite C++ CRTP and Java's F-bounded generics. Name self types (typing.Self, Rust's Self) as the simpler alternative.
Parts rewritten: code sketch, agent system
B
Appendix · Words explained

Glossary

Short, plain explanations of the technical words used in this book. Each entry also explains these words where they first appear, so you only need this page as a reminder.

Agent harness
All the software around an AI model that turns it into a working agent: it sends messages, runs tools, remembers things, checks permissions and shows results.
Agent loop
The repeating cycle at the heart of an agent: send the conversation to the model, run any tools it asks for, add the results, and go again until it is done.
AI agent
An AI model that can also take actions (search, edit files, send messages) and decide its next step, not just reply with text.
Allowlist
A list of things that are explicitly permitted. Anything not on the list is refused.
API gateway
One front door for all requests to a set of services, handling sign-in checks, routing and limits.
Approval gate / human in the loop
A stop where a person must say yes before the agent does something risky.
At-least-once delivery
A message system promise that every message arrives, but some may arrive more than once. Receivers must handle repeats.
Atomic
Done as one indivisible step: it either fully happens or does not happen at all.
Audit log
A permanent, tamper-resistant record of important actions, kept for security checks and legal needs.
Audit trail
A tamper-resistant record of who did what and when, kept for security reviews and compliance checks.
Backoff
Waiting a little longer after each failed attempt before trying again, often with a random extra delay (jitter).
Broker
A service that receives messages from senders and passes them to receivers, such as a message queue server.
Cached prefix
The opening part of a request that the provider has saved. Requests that begin with the same text reuse it, which is faster and cheaper.
Chargeback
Charging each team or customer for the AI usage they caused, using tracked token costs.
Checkpoint
A saved copy of the agent's state at a moment in time, so a run can resume or roll back to it.
CI (continuous integration)
An automatic system that builds and tests code every time someone changes it.
Compaction
Shrinking an old conversation (for example by summarising it) so it still fits in the context window.
Compensating action
An action that undoes an earlier step's effect, used when a multi-step job fails partway.
Compliance (SOC 2, ISO 27001, GDPR, HIPAA, EU AI Act)
Laws and standards a business must meet for security, privacy and responsible AI. They shape what must be logged, kept and protected.
Container
A packaged, isolated environment for running a program with its own files and limits.
Content block
One piece of a message: text, an image, a tool call or a tool result. A message can hold several.
Context window
The amount of text the model can see at one time. Anything outside it is invisible to the model.
Correlation ID
One ID attached to every message and log line that belong to the same request, so they can be linked later.
Data residency
Rules that require data to stay in a certain country or region, for example EU data stored only in the EU.
Dispatcher
The part of the harness that receives a tool call, finds the right tool and runs it.
DLP / redaction
Data loss prevention: finding sensitive data and hiding or removing it before it is stored or shown.
Durable execution (Temporal, Step Functions)
Running a long multi-step job so that it can survive crashes and restarts and continue exactly where it stopped.
Embedding
A list of numbers that captures the meaning of a piece of text, so similar texts get similar numbers.
ETag / compare-and-set
A version tag on a stored item. A write only succeeds if the tag still matches, so two writers cannot silently overwrite each other.
Eval
A repeatable test of an AI agent: a fixed task with checks on what the agent did and said.
Event loop
A single worker that waits for many things at once (network replies, timers) and handles each one as it becomes ready.
Event streaming (Kafka, Pub/Sub)
A shared pipe where services publish events (something happened) and others subscribe to react to them.
Fail closed
When a check cannot decide (for example it times out), the answer is 'no'. The safe default.
Feature flag
A switch that turns a feature on or off for some customers without releasing new code.
Fencing token
A number that goes up each time a lock is granted. Storage rejects writes carrying an old number, so a worker whose lock expired cannot overwrite newer work.
Future
A placeholder for a result that is not ready yet. You can wait for it or attach a callback.
Guardrail
An automatic check on what goes into or comes out of the agent, such as blocking personal data or unsafe actions.
Hash / content hash
A short fingerprint made from data. Any change to the data changes the fingerprint.
Hook
A point where your own code can plug in and run, for example before every tool call.
Idempotency key
A unique ID sent with an action so the receiver can spot a repeat and skip it, instead of doing the action twice.
Idempotent
Safe to run twice: doing it again gives the same result and no extra side effects, like pressing a lift button twice.
Interface
A promise in code about what a component can do (its methods), without saying how it does it.
Kill switch
A feature flag used to stop something quickly in an emergency, even for sessions that are already running.
Lease
A lock that expires on its own after a set time unless it is renewed, so a crashed holder cannot block others forever.
Least privilege
Giving each user, agent or tool only the access it truly needs, and nothing more.
Legal hold
An order to keep certain data and not delete it, usually because of a lawsuit or investigation.
LLM gateway
One central service all model calls go through, handling login keys, routing between providers, limits, cost tracking and logs.
Lock
A way to make sure only one worker changes something at a time.
MCP
Model Context Protocol. A standard way to plug tools and data sources into an AI agent. An MCP server lists its tools; the harness calls them.
Model / LLM
Large language model. The AI that reads text and writes text. It runs on a provider's servers and is called over the internet.
Multi-tenant
One platform serving many customer organisations at once, with walls between their data.
Object storage
Cheap, durable storage for large files (blobs), such as transcripts, documents and images.
Observability / OpenTelemetry
Being able to see what the system is doing through traces, metrics and logs. OpenTelemetry is the common open standard for collecting them.
Orchestrator
The main agent or service that hands out work to other agents and combines their results.
Outbox
A table where actions are written down first, in the same transaction as the data change, and sent out afterwards. Nothing is lost if a crash happens in between.
PII
Personally identifiable information: names, emails, phone numbers and other data that points to a real person.
Policy engine (OPA, Cedar)
A separate service that answers 'is this allowed?' using written rules, so permission logic is not scattered through the code.
Principal
The identity an action is performed as: a person, a service or an agent acting for someone.
Prompt
Everything sent to the model in one request: instructions, the conversation so far, and tool descriptions.
Prompt cache
Saved work the model provider can reuse when a new request starts with exactly the same text as an earlier one. Any change near the start breaks the reuse.
Prompt injection
Hidden instructions inside a web page, file or email that try to trick the agent into doing something the user did not ask for.
Pseudocode
Simplified example code that shows the idea, not code you can copy and run as is.
Pure function
A function whose output depends only on its inputs and that changes nothing else. Easy to test and safe to reuse.
Queue
A waiting line for work items. Producers add items; workers take them off one by one.
Rate limit
A cap on how many requests or tokens you may send in a period. Going over it returns an error (HTTP 429) asking you to wait.
RBAC / ABAC
Role-based and attribute-based access control: permissions given by job role, or by facts like department, region or data type.
Regression gate
A test step that blocks a release if quality drops below the previous version.
Replica
A copy of a database or service kept in sync with the original, for speed or for backup.
Retry-After
A hint in an error reply that says how long to wait before trying again.
Row-level security
Database rules that only show each user or tenant the rows they are allowed to see.
Sandbox
A sealed-off place where code or tools run, so a mistake cannot harm the rest of the system.
Schema / JSON Schema
A description of what shape some data must have: which fields, what types, which are required. JSON Schema is the common format for tool inputs.
SDK
Software development kit: a library that makes it easy for developers to call a service from their code.
Secrets manager (Vault, KMS)
A secure store for passwords, keys and tokens, so they never sit in code, prompts or logs.
Side effect
A change in the outside world caused by an action, such as an email sent or money moved. Side effects are why retries need care.
SIEM
Security information and event management: a central system where security teams collect and search logs to spot attacks.
SLO
Service level objective: a reliability target, such as 99.9 percent of requests answered within 5 seconds.
Span
One timed step inside a trace, such as one model call or one tool call.
SSE (server-sent events)
A simple way for a server to keep a connection open and push updates to a browser, often used to stream model replies.
SSO / OIDC
Single sign-on: people log in once with their company account. OIDC is a common standard for doing this.
stop_reason
The field in a model's reply that says why it stopped, for example because it finished or because it wants to call a tool.
Streaming
Sending the model's reply in small pieces as it is written, so the user sees words appear right away.
Stub
A simple stand-in for a real component, used to connect to it remotely or to fake it in tests.
Subagent
A helper agent started by a main agent to do one part of a job, often in parallel, with its own separate context.
System prompt
Standing instructions sent at the start of every request, such as the agent's role and rules.
Tenant
One customer organisation sharing a platform with others. Tenants' data must be kept strictly apart.
Token
A small piece of text, roughly three quarters of a word. Models count, limit and charge by tokens.
Tool
An action the agent can ask for, such as reading a file, searching the web or querying a database.
Tool call / tool_use
The model's request to run a tool. It names the tool and gives the inputs. The harness runs it, not the model.
Tool result / tool_result
The answer sent back to the model after a tool runs. If the tool failed, it is marked with the is_error flag.
Trace
A record that follows one request through every step and service it touched, with timings, linked by one ID.
Transaction
A group of changes that either all happen or none happen.
Transcript
The full record of a conversation: every message, tool call and tool result, in order.
Trust boundary
A line in the system where data from a less trusted source (a user, a web page, the model) must be checked before it is believed.
TTL (time to live)
How long something is kept before it expires automatically.
Vector store
A database that stores text as lists of numbers (embeddings) so it can find passages with a similar meaning.
Webhook
A web address one system calls automatically to tell another system that something happened.
Workload identity
A way for a running service to prove who it is to cloud systems without storing a long-lived password.

That is the full catalog. Most agent systems use Command, Proxy, Strategy, State and Retry every day. The others become important as you add more agents, scheduled jobs, and more places where people use the agent.