Chapter 76 · Tools and actions

Composing tools, and letting the model chain them

Chains are where tools become an agent and where the fourth failure class lives: the chain that went further than it was asked. Preconditions, scope locking, and three bounds that are not step limits.

12 min read

Individually sound tools can compose into something nobody intended. Chapter 65 named it as the fourth new failure class:

"Can you check whether the Henderson discount is being applied?"

The model checks, finds a discrepancy, and fixes it. Every tool call was permitted, correctly argued and correctly executed. The user asked a question and the system took an action.

Why chaining is wanted anyway

"Why is the Henderson account past due and what should I do?"

Needs the account summary, the open invoices, the payment history and the ticket log. A single call cannot answer it, and the number of calls is not known in advance — which is chapter 47's sequential case and the only genuine reason to run a loop.

So chaining is kept, and bounded. Three bounds, and none of them is the step limit.

Bound 1: the intent boundary

The most important and the least implemented.

// Classify what the user asked for, once, before the loop. Every tool // call is then checked against it. export type Intent = 'inform' | 'prepare' | 'act' export function classifyIntent(question: string): Intent { // Imperatives with an object: "draft", "send", "open", "apply". if (ACT_VERBS.test(question)) return 'act' if (PREPARE_VERBS.test(question)) return 'prepare' // "draft", "put together" return 'inform' // the default } const ACT_VERBS = /\b(send|apply|issue|submit|raise|post|confirm|approve|cancel)\b/i const PREPARE_VERBS = /\b(draft|prepare|put together|write up|set up)\b/i
// And the gate in the executor, beside permission and validation. const ALLOWED_BY_INTENT: Record<Intent, ToolKind[]> = { inform: ['read'], prepare: ['read', 'write_reversible'], act: ['read', 'write_reversible', 'write_observed', 'write_irreversible'], } if (!ALLOWED_BY_INTENT[context.intent].includes(toolKind(tool))) { return { ok: false, error: 'outside_intent', // ⚠️ Returned to the model so it can offer, rather than silently // dropping the capability. "I found a discrepancy — shall I fix it?" message: `You were asked to ${context.intent}. Offer this action to the ` + `user instead of performing it.`, } }

A question is not a mandate. Classify intent before the loop and check every call against it — a chain that drifts from answering to acting is the most expensive failure in this section and the hardest to notice.

The failure in the opening example is prevented by one comparison: intent inform, tool kind write_observed, refused.

Bound 2: scope locking

A chain that starts on one account should not end on another.

// The first resolved entity locks the chain. Chapter 69's resolution // returns it; here it becomes a constraint. export interface RunContext { runId: string intent: Intent // ⚠️ Set by the first tool call that resolves one, then enforced. lockedAccount?: { id: string; name: string } called: string[] } export function checkScopeLock(args: ResolvedArgs, ctx: RunContext): Issue | null { if (!args.accountId) return null if (!ctx.lockedAccount) { ctx.lockedAccount = args.account; return null } if (args.accountId !== ctx.lockedAccount.id) { return { kind: 'scope_drift', message: `This run is about ${ctx.lockedAccount.name}. A call referencing ` + `a different account needs a new request.` } } return null }

Atlas's measurement: scope drift fired on 0.7% of multi-step runs. Every instance was the model following a reference in retrieved text — a contract mentioning a parent company, a ticket referencing a related account — and pivoting to it. None was malicious and all would have been wrong.

⚠️ The legitimate cross-account question exists — "compare Henderson's terms with Acme's" — and it is handled by the parallel decomposition path, where each branch has its own lock, rather than by relaxing the lock.

Bound 3: preconditions, as a graph

Chapter 70 introduced single preconditions. Composition makes them a small graph.

export const openTicket = registerTool({ name: 'open_ticket', kind: 'write', reversibility: 'reversible', preconditions: [ // Must have searched, and the search must have been for this account. { tool: 'search_tickets', sameAccount: true }, ], // ⚠️ And a check on the RESULT, not only that the call happened. // Chapter 70 noted that calling and ignoring satisfies a naive check. async guard(args, scope, ctx) { const search = ctx.resultOf('search_tickets') const open = search?.data?.filter((t) => t.status !== 'closed') ?? [] if (open.length > 0 && !args.supersedesExisting) { return { ok: false, error: 'existing_ticket', message: `${open.length} open ticket(s) already exist for this account: ` + `${open.map((t) => t.reference).join(', ')}. Either reference one ` + `or set supersedesExisting with a reason.` } } return { ok: true } }, })

Requiring an explicit supersedesExisting flag with a reason is what converts "the model ignored the search" into a deliberate, auditable decision. On Atlas it reduced duplicate tickets from about 90 a month to four, and the four each carried a stated reason.

What the model sees between steps

A chain is only as good as what each step returns, and the biggest lever is making results composable.

// ❌ A result the next step cannot use without re-deriving. { ok: true, data: [{ account_id: '4471', terms: 'Net 30' }] } // ✅ A result carrying the identifiers the next call will need, already // resolved, so the model copies rather than infers. This removes a // large share of chapter 69's argument failures in multi-step runs. { ok: true, data: [{ terms: 'Net 30', effectiveFrom: '2026-01-01' }], resolved: { accountId: '4471', accountName: 'Henderson Industrial Supply' }, nextStepHints: ['get_open_invoices', 'get_payment_history'], }

⚠️ nextStepHints is a suggestion and must not be a mechanism. It measurably improves tool selection in chains — correct next tool went from 0.91 to 0.96 on Atlas — and it is still only a description; the intent gate above and the permission check apply to whatever is actually called.

Parallel calls

A model can emit several tool_use blocks in one turn. Running them concurrently is sometimes right and often not.

export function canRunInParallel(calls: ToolRequest[]): boolean { // All reads: safe and worth doing. Four reads in parallel is one round // trip instead of four. if (calls.every((c) => TOOLS[c.tool].kind === 'read')) return true // ⚠️ Any write: run in order. Two writes in parallel can interleave in // the target system, and a write beside a read may invalidate it. return false }
// The parallel path, bounded, with a shared failure policy. const results = canRunInParallel(calls) ? await Promise.all(calls.map((c) => limit(() => executeToolRequest(c, scope, ctx)))) : await sequentially(calls, (c) => executeToolRequest(c, scope, ctx))

Parallel reads took Atlas's four-tool investigative questions from 2.1s to 0.9s of tool time, which is most of the gap between a usable and an unusable experience on that question shape.

Observing a chain

// The trace is the artefact. Chapter 89 makes it legible to a human; // this is the minimum that makes a chain debuggable at all. interface Trace { runId: string intent: Intent lockedAccount?: string steps: Array<{ n: number tool: string arguments: unknown outcome: 'ok' | 'refused' | 'error' refusalReason?: string // ⚠️ refusals are the interesting rows latencyMs: number }> terminated: 'answered' | 'step_limit' | 'timeout' | 'needs_confirmation' }
-- The query that finds composition problems, which is the refusal rate -- by reason rather than the error rate. select refusal_reason, count(*), count(distinct run_id) as runs from trace_steps where outcome = 'refused' and created_at > now() - interval '7 days' group by 1 order by 2 desc; -- outside_intent 41 ⚠️ the model wanting to act on inform questions -- existing_ticket 18 -- scope_drift 6 -- precondition 4

Forty-one outside_intent refusals in a week is not a problem — it is the gate working, and each one is a case where the model would have acted unasked. It is also a product signal: those users wanted something done, and the right response is to offer it rather than to loosen the gate.

// Which is what "offer, do not act" looks like in the answer. if (refusals.some((r) => r.reason === 'outside_intent')) { answer.suggestedActions = refusals .filter((r) => r.reason === 'outside_intent') .map((r) => ({ tool: r.tool, arguments: r.arguments, label: describeAction(r.tool, r.arguments) })) }
The Henderson discount is 12% per the contract [1], and two of three current
orders have it applied. Order ORD-8821 has no discount — it is $18,400, above
the $25,000 threshold... no, below it, so no discount is due.

[ Apply 12% to ORD-8821 ]   [ Open a ticket about this ]

The user gets the action as a button rather than as a fait accompli, which is the correct resolution of the opening failure.

The failure mode: the chain that was right and unreviewable

A rep asks why an account is past due. Atlas runs six tools, finds a disputed invoice, a rejected payment and an unresolved ticket, and writes a good four-sentence answer.

It is correct. Nobody can check it.

The answer cites nothing, because tool results are not citable chunks — they are rows from four systems. A rep who wants to verify the rejected payment has to go and look it up, which is the thing the assistant was supposed to save.

// Tool results need citations too, and they are cheaper than document // citations because the provenance is exact. interface ToolCitation { n: number tool: string label: string // "Payment history — Henderson Industrial" queriedAt: string href?: string // deep link into the source system rows: number }
<!-- And the same prompt rule as for documents, extended. --> Cite tool results as [n], exactly as you cite documents. Every figure taken from a tool must carry the citation of the tool that returned it.
The account is $23,100 past due [1]. One invoice is disputed [2], and a
payment of $8,400 was rejected on 14 October for an expired mandate [3].
There is an open ticket about the dispute, raised 9 October [4].

  [1] Account summary — Henderson Industrial, checked 14:22 →
  [2] Open invoices — 1 disputed of 4, checked 14:22 →
  [3] Payment history — last 90 days, checked 14:22 →
  [4] Tickets — 1 open, checked 14:22 →

A chain's output is only as useful as its verifiability. Four tool results and a confident paragraph is a worse artefact than four cited rows, because the paragraph cannot be checked and will eventually be wrong.

Key points

  • Chains produce the fourth failure class: a question answered by taking an action.
  • Classify intent before the loop — inform, prepare, act — and check every call against it. A question is not a mandate.
  • Refuse out-of-intent calls back to the model so it offers the action rather than losing the capability.
  • Lock the chain to the first resolved entity. Scope drift fired on 0.7% of runs, always from a reference in retrieved text.
  • Preconditions must check the result, not that the call happened. Requiring an explicit supersede flag cut duplicate tickets from ~90 a month to four.
  • Return resolved identifiers and next-step hints in tool results. Correct next tool went from 0.91 to 0.96.
  • Parallelise reads, never writes. It halved tool time on investigative questions.
  • Read the refusal rate by reason. Forty-one out-of-intent refusals a week is the gate working and a product signal.
  • Cite tool results as you cite documents. A correct chain with no citations is an answer nobody can check.

Next: tool errors, and what the model should see.