Session 7 · Week 4 · Tue 4 Aug 2026 · 90 min

Agents, and how they break.

An agent is a loop that lets a model choose the next step. That is all it is. Tonight we build one part by part, in TypeScript you can run, and for every part we name the exact way it fails in production.

Ehsan Gazar
Production-Ready Systems with LLMs and Agents · session 7 of 12
Where we're going · 90 minutes

The run sheet.

0–14The loop, and its four stopswhat an agent actually is
14–38Tools as contracts, then MCPthe model's hands
38–54Planning, and error compoundinghow it picks the next step
54–68Memory and durable runsthe run that dies at step 30
68–78Single vs multi-agentand the coordination tax
78–90Live sketch + Project 3 previewsecurity on Thursday
You’ll leave able to draw an agent’s loop and name all four of its stops, write a tool as a typed contract, pick a planner on purpose, say how many steps your reliability can afford, make a long run resumable, decide one agent or several, and name the failure mode you accepted with each choice.
Start here · plain English

An agent is a loop that lets the model choose.

In a workflow, you decide the order of the steps and write them down. In an agent, the model decides the next step each time round, using what it just learned. Same tools, same code. The difference is who is holding the steering wheel.

Workflow
you wrote the steps. Step 3 always follows step 2. Easy to test, easy to price, blind to anything you did not plan for.
you drive
Agent
the model picks the next step after every result. Handles surprises. Also free to wander, repeat itself, and spend your money doing it.
the model drives
Week 1 was about earning the agent. Tonight assumes you earned it, and builds it so it holds.
Anatomy

Four parts. Everything else is detail.

A model that decides, tools it may use, a history of what happened, and a rule that ends the loop. Beginners build the first three and forget the fourth.

flowchart LR
  G["Goal"] --> M{"1 Model
reads history, picks next step"} M -- "asks for a tool" --> T["2 Tool
your code runs it"] T --> O["3 History
result appended"] O --> M M -- "4 Stop rule" --> DONE["Answer"] classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef w fill:#FEF3C7,stroke:#1F2937,color:#0E1726; class M m; class DONE ok; class T w;
In plain English

The model never runs anything. It writes down a name and some arguments, like an order slip. Your code decides whether to fill that order. That gap is where every guardrail in this course lives.

The loop · in TypeScript

The whole idea is thirty lines.

No framework. A for loop, a call to the model, a call to a tool, and an append. If you can read this, you can read any agent framework, because they all wrap exactly this.

Notice

The model arrives as a parameter called policy. That single choice is why the whole thing is testable with no API key: you hand it a scripted brain and assert on the stops.

run it → s07/loop/agent.ts
// The brain: history in, next action out. Nothing else.
type Policy = (history: Entry[]) => Promise<{ action: Action }>;

type Action =
  | { kind: "final"; text: string }
  | { kind: "tools"; calls: ToolCall[] };

export async function runAgent(
  question: string,
  policy: Policy,
  registry: Registry,
  ctx: ToolContext,
  { maxSteps = 8 } = {},
) {
  const history: Entry[] = [{ role: "question", text: question }];

  for (let step = 1; step <= maxSteps; step++) {
    const { action } = await policy(history);

    // The model says it is finished. Believe it, and stop.
    if (action.kind === "final") return { answer: action.text, step };

    // Independent calls go together, not one after another.
    const results = await Promise.all(
      action.calls.map((c) => registry.call(c.name, c.args, ctx)),
    );

    for (const r of results)
      history.push({ role: "observation", text: r.observation });
  }

  // The step cap. Without this line you have a bill, not a feature.
  return { answer: "stopped: hit the step cap", step: maxSteps };
}
What actually happens

One run, step by step.

A customer writes in about a broken order. Read the middle column as "the model asks", and the right column as "your code answers". Nothing magic happens anywhere.

StepThe model asks forYour code hands back
0reads the question"Order o-1001 turned up damaged"
1lookup_order({orderId:"o-1001"})GBP 42.00, delivered, 3 days ago
2read_refund_policy({})auto-refund up to GBP 100, within 30 days
3issue_refund({orderId, reason:"damaged"})refunded GBP 42.00, receipt rf-o-1001
4says it is donestop reason done · 4 steps · 3 tool calls
Four rows. The model never touched your database, it asked three times and your code answered three times.
this exact run, as a test → s07/real-world/support.test.ts
The part people forget

An agent needs four ways to stop, not one.

"It finished" is the stop you write first. The other three are the ones that save you, because the classic agent incident is not a crash. It is a run that never decides it is done.

1
done
the model says it has an answer. The only happy stop.
2
step cap
too many turns. A hard number, chosen from your reliability math.
3
budget
too much money. Checked before the next call, never after.
4
no progress
same tool, same arguments, three times. That is spinning, not working.
A budget checked after the call is not a budget. It is a receipt.
Every stop is a different bug

Return the reason, not just the answer.

If every run returns a string, all failures look the same in your logs. Return a stopReason and each one becomes a different alert with a different fix.

stopReasonWhat it usually meansWhat you changeTell the user
doneit workednothingthe answer
step_capthe task needs more steps than you allowed, or the tools are too weakbetter tools, or split the taskhonest partial result
budgetcontext is growing every turn, or the model is re-reading the same thingtrim history, cache, cheaper model"this needs a human"
no_progressa tool keeps returning nothing useful and the model keeps retrying itfix the tool's error message"I could not find that"
The last row is the cheapest bug to fix and the most expensive to leave in.
one test per stop → s07/loop/agent.test.ts
Tools · the model's hands

A tool is a function you let the model ask for by name.

The model produces text. That is all it can ever do. A "tool call" is text in an agreed shape: a name and some arguments. Your code reads that text, checks it, and decides whether to run anything.

flowchart LR
  M{"Model"} -- "writes text
name plus arguments" --> P["Your code
is this tool allowed"] P -- "no such tool" --> E["Error observation"] P -- "bad arguments" --> E P -- "valid" --> F["The real function
database, API, email"] F --> R["Result"] R --> B["Bound it
truncate long output"] B --> H["Back into history"] E --> H classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class M m; class P,B ok; class E bad;
Three of the six boxes are checks. That ratio is not an accident.
A tool · in TypeScript

Four fields, and every one is load-bearing.

A name the model can type, a description it reads to choose, a schema that says what the arguments must look like, and the effect this has on the world.

The one people skip

effect. Marking a tool as read, write or irreversible is what later decides whether a crashed run can safely be replayed. It costs one word now and saves an incident later.

run it → s07/tools/tool.ts
import { z } from "zod";

const issueRefund = defineTool({
  // 1 · snake_case, short, unmistakable. The model types this.
  name: "issue_refund",

  // 2 · this IS prompt text. It is how the model chooses.
  description:
    "Refund one order in full. Only works inside the refund " +
    "policy. Money leaves the account, so use it once.",

  // 3 · the contract. Narrow types here are narrow privileges later.
  schema: z.object({
    orderId: z.string().min(3),
    reason:  z.enum(["damaged", "late", "wrong_item"]),
  }),

  // 4 · what it does to the world: read | write | irreversible
  effect: "irreversible",

  async run({ orderId, reason }, ctx) {
    const order = await findOrder(orderId);

    // ctx comes from YOUR code, never from the model's arguments.
    if (!order || order.tenantId !== ctx.tenantId)
      return `refused: no order ${orderId} on this account`;

    // The rule is re-checked HERE. The model only asked.
    if (order.totalGbp > MAX_AUTO_REFUND)
      return `refused: over the GBP ${MAX_AUTO_REFUND} limit`;

    return await refund(order, reason);
  },
});
The cheapest fix in agent engineering

The name and description are prompt engineering.

When an agent "picks the wrong tool", nine times in ten the tool was badly named or vaguely described. This is not documentation. It is the only thing the model reads when it chooses.

Instead ofWriteBecause
get_data(type, id)lookup_order(orderId)a name that says the noun cannot be confused with three other lookups
"Handles orders.""Look up ONE order by id. Returns total, status, delivery date."says what it returns, so the model knows if this is the step it needs
update(entity, fields)cancel_order(orderId) · change_address(orderId, address)one verb per tool. A generic writer is impossible to reason about or to secure
search(q) and find(q) and query(q)one of themnear-duplicate tools are the number one cause of wrong-tool errors
Rename before you re-prompt. It is faster and it actually works.
Least privilege · a schema decision

Same job. A hundredth of the blast radius.

"Give the agent database access" can mean two completely different systems. One of them can only ever refund one order that the caller owns. The other can do anything SQL can do, and a probabilistic caller decides which.

The rule

Enforce what is allowed in the tool, server-side, in code. Not in the prompt. A prompt is a request. A schema plus a server check is a rule.

// GOOD: narrow, typed, and the server re-checks everything
{
  name: "refund_order",
  input: {
    orderId: "string",
    reason:  "enum[damaged, late, wrong_item]"
  }
}
// server re-checks: caller owns this order,
//                   amount is under the cap,
//                   it was not already refunded.
// Worst case if the model is wrong or steered:
//   ONE wrong refund, under the cap, fully logged.


// DANGEROUS: unbounded, and the model picks how far it reaches
{
  name: "run_sql",
  input: { query: "string" }
}
// Worst case: every row of every table, read or deleted.

// Same family of mistake:
{ name: "http_get",  input: { url: "string" } }   // your whole VPC
{ name: "shell",     input: { cmd: "string" } }   // game over
{ name: "send_email",input: { to: "string", body: "string" } }
How tools break · failure is a message

A thrown error kills the run. A returned one teaches it.

The model will call a tool that does not exist, and pass arguments that do not validate, and hit a service that is down. All three are normal. None of them should end a nine-step run.

Write errors for a reader

"Invalid input" tells the model nothing, so it guesses again and you pay for another step. Name the field, say what was wrong, list what is valid. Then the next step is a fix instead of a repeat.

the registry that never throws → s07/tools/tool.ts
// Every failure comes back as an OBSERVATION the model can read.
async call(name, rawArgs, ctx): Promise<ToolResult> {
  const tool = byName.get(name);

  // 1 · invented a tool. Say so, and say what exists.
  if (!tool) return {
    ok: false, retryable: true,
    observation: `error: no tool named "${name}". ` +
                 `Available tools: ${names.join(", ")}.`,
  };

  // 2 · bad arguments. Name the field and the reason.
  const parsed = tool.parse(rawArgs);
  if (!parsed.ok) return {
    ok: false, retryable: true,
    observation: `error: invalid arguments for ${name}. ` +
                 parsed.message,  // "reason: expected damaged|late"
  };

  // 3 · the dependency fell over. Still an observation.
  try {
    const raw = await tool.invoke(parsed.args, ctx);
    return { ok: true, ...bound(raw, tool.maxChars) };
  } catch (err) {
    return {
      ok: false, retryable: true,
      observation: `error: ${name} failed. ${message(err)}`,
    };
  }
}
The quiet cost

Every tool result is context you pay for, twice.

A tool that returns 40,000 characters does not just cost you once. That text sits in the history and is re-sent on every remaining step of the run. One chatty tool can be the whole bill.

One noisy toolreturns 10k tokens of JSON on step 2
A six step runthat result is re-sent on steps 3, 4, 5, 6 · the tokens you thought
The fix, part 1the tool returns a summary, not the raw payload · ids and totals, not every field
The fix, part 2a hard maxChars per tool, and the truncation says so, so the model knows it saw a slice
The fix, part 3page it: next_cursor beats "here is everything"
The tellyour p95 cost per run is fine and your p99 is 20× it. That is one tool, on one input
Session 5 taught you to count tokens. This is where an agent leaks them.
Tool count

Fewer tools, better chosen.

Every tool you add is more prompt on every call, one more thing to pick wrongly, and one more door into your systems. Adding tools feels like adding ability. Past a point it subtracts reliability.

Tools in the promptWhat happensWhat to do
3 to 7the model picks well and the catalogue is cheapstay here for as long as you can
8 to 15near-duplicates start getting confused with each othermerge overlapping tools, sharpen the descriptions
16 to 40choice quality drops and the schemas are a real cost on every callshow only the tools this phase needs, or route to a sub-agent
40 and upyou are no longer designing, you are hopingmake tool discovery a tool: search, then call
"Which tools does this request need?" is itself a routing decision, and Session 1's router already knows how to make it.
Latency hides here

Independent calls should not queue.

Three lookups that do not depend on each other do not need three round trips through the model. Modern APIs let the model ask for several tools in one step, and your loop decides whether they run together or one by one.

flowchart TB
  subgraph SEQ["One at a time · 6 model calls, 6 waits"]
    direction LR
    A1["model"] --> A2["tool a"] --> A3["model"] --> A4["tool b"] --> A5["model"] --> A6["tool c"]
  end
  subgraph PAR["Together · 2 model calls, 1 wait"]
    direction LR
    B1["model"] --> B2["tool a"]
    B1 --> B3["tool b"]
    B1 --> B4["tool c"]
    B2 --> B5["model"]
    B3 --> B5
    B4 --> B5
  end
  SEQ ~~~ PAR
  classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
  classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
  class A1,A3,A5 bad;
  class B1,B5 ok;
        
One line in the loop: await Promise.all(calls.map(...)). It is the cheapest latency win an agent has.
The tool interface, standardised

MCP is that contract, published.

Everything from the last few slides still holds. MCP (Model Context Protocol) just standardises how a tool is offered: write it once as a server, and any agent can call it. The architectural change is quieter and bigger. Most tools you ship, you did not write.

flowchart LR
  AG{"Your agent"} --> C["MCP client"]
  C --> GW["Gateway
auth plus allow-list"] GW --> S1["Your server
issue_refund"] GW --> S2["Third-party server
you do not run this one"] classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef un fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class GW,S1 ok; class S2 un;
The gateway is the one place to decide which tools this agent may call. Thursday we attack exactly this.
Planning · how it picks the next step

Three shapes, and a fourth answer nobody is proud of.

"Planning" is not one thing. Pick on purpose, because each shape has a different price and a different way of going wrong.

ShapeModel callsGood forBreaks asGuardrail
none (a workflow)1 per fuzzy stepthe path is known and stableno branch for the case nobody predictedan explicit fallback, and a metric on it
react1 per step, count unknownopen-ended work, multi-hop lookups, recoverywandering, and errors compoundingstep cap, budget, repeat detection
plan then execute1 to plan, then 1 per steppredictable shape, and you want the plan reviewablethe plan goes stale at step twovalidate each step, allow bounded replanning
reflect2 to 3 per stepone high-value artifact where quality beats costpolishing forever, critic agrees with itselfhard round cap, critic uses a rubric
"None" wins ties. A workflow you can test beats an agent you can only hope about.
React, drawn

Think, act, look. Repeat.

flowchart LR
  T["Think
what do I need next"] --> A["Act
call a tool"] A --> O["Look
read the result"] O --> T T -. "goal met or capped" .-> D["Done"] classDef t fill:#EEE6FF,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class T t; class D ok;
The dashed exit is the entire difference between an agent and a runaway bill.
Plan then execute, drawn

Write all the steps first. Then check each one.

flowchart LR
  G["Goal"] --> P["Plan
one model call, all the steps"] P --> S["Run step n"] S --> V{"Did it pass
its check"} V -- "yes" --> S2{"More steps"} S2 -- "yes" --> S S2 -- "no" --> D["Done"] V -- "no" --> R{"Replans left"} R -- "yes" --> P R -- "no" --> STOP["Stop and say so"] classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726; class P m; class D ok; class STOP bad;
Without the check, this is a for-loop trusting six guesses in a row. The check and the replan budget are the pattern.
runPlan with validation and bounded replans → s07/planning/planner.ts
Reflect, drawn

Do it, criticise it, do it again. Twice, not forever.

flowchart LR
  D1["Draft"] --> C{"Critic
scores against a rubric"} C -- "fails the rubric" --> D2["Revise"] D2 --> C C -- "passes, or 2 rounds used" --> OUT["Ship it"] classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class C m; class OUT ok;
A critic without a written rubric mostly agrees with itself, and you paid triple for the agreement.
The choice, as code

Three questions decide it.

Do you know the steps before you start? Does the next step depend on the last result? Is this one artifact where quality is worth paying triple for?

Why write it as a function

Because then it is a decision with a reason attached, which is exactly what Project 3 asks you to defend. A function you can run beats a preference you can only assert.

print it for your system → s07/decide.ts
export function choosePlanner(shape: TaskShape): PlannerChoice {
  // Cheapest answer first, and it wins ties.
  if (shape.pathKnown && !shape.stepsDependOnResults) {
    return { kind: "none",
      because: "the path is known, so a workflow is cheaper and testable" };
  }

  if (shape.qualityOverCost && !shape.stepsDependOnResults) {
    return { kind: "reflect",
      because: "one artifact where quality is worth extra calls" };
  }

  if (shape.pathKnown) {
    return { kind: "plan_then_execute",
      because: "predictable shape, so plan once and validate each step" };
  }

  return { kind: "react",
    because: "the next step is only knowable after the last observation" };
}

// $ npm run lab .../s07-agent-architecture/decide.ts
//
// 1 · planning    react
//    because      the next step is only knowable after the last
//    breaks as    wandering, and error compounding
//    guardrail    step cap, budget, repeat detection, validation
The most useful number in agent design

Steps multiply. They do not add.

Say each step is right 95 percent of the time. That sounds good. Chain ten of them and the whole run is right about 60 percent of the time, with nothing thrown, nothing logged, and no error anywhere to find.

1 step95.0% 3 steps85.7% 5 steps77.4% 10 steps59.9% 20 steps35.8% 40 steps12.9%
run success = perStep ^ steps  ·  0.95 ^ 10 = 0.599
This is why "just let it run for forty steps" is not a plan. It is a coin flip with a wrapper.
The fix, priced

A check between steps buys back the run.

You cannot make the model more reliable tonight. You can stop a bad step from being built on. Catch four out of five bad steps and a ten-step run goes from 35 percent correct to 82 percent, with the same model.

What a check looks like

Not another model call. A schema that must parse, a number that must be in range, an id that must exist, a claim that must appear in the retrieved text. Boring code, from Session 2.

the math, as tests → s07/planning/planner.test.ts
// Bare chain: every step trusts the one before it.
export function chainSuccess(perStep: number, steps: number) {
  return perStep ** steps;
}

// With a check between steps. `catchRate` is the share of bad
// steps your validation actually catches and retries.
export function chainSuccessWithChecks(
  perStep: number, steps: number, catchRate: number,
) {
  const effective = perStep + (1 - perStep) * catchRate;
  return chainSuccess(Math.min(effective, 1), steps);
}

// 90% per step, 10 steps:
chainSuccess(0.9, 10);                 // 0.349  ouch
chainSuccessWithChecks(0.9, 10, 0.8);  // 0.817  same model

// And the number that sets your step cap:
maxStepsFor(0.95, 0.9);   // 2 steps before you drop under 90%
maxStepsFor(0.99, 0.9);   // 10
maxStepsFor(0.999, 0.9);  // 105
Memory · four different things with one name

"Memory" is four things. Only two are yours to design tonight.

People say "give the agent memory" and mean any of these. They have different lifetimes, different costs and different ways of going wrong.

KindLives forExampleBreaks as
Scratchpadone stepthe model's reasoning before it picks a toolyou keep it all and pay for it forever
Run historyone runthe calls and observations so fargrows every step, so cost and latency climb
Run statesurvives a crashwhich steps completed, what they returnedif it only lives in the process, the run dies with it
Long-term factsforever"this customer prefers email"stale facts retrieved as gospel
Row 1 and row 4 are Session 3 and 4 work: what goes in the window, and how you retrieve it. Rows 2 and 3 are tonight's.
The distinction that matters

The loop and its state are two different things.

If the only copy of "what has happened so far" is a variable inside a running function, then your run is exactly as durable as that process. Deploys, timeouts, rate limits and crashes all end it.

flowchart TB
  subgraph P1["State inside the process"]
    direction LR
    A["step 1"] --> B["step 2"] --> C["step 3"] --> X["pod restarts"]
    X --> Y["start again at step 1
pay twice, re-send every email"] end subgraph P2["State in a store"] direction LR D["step 1"] --> E["step 2"] --> F["step 3"] --> G["pod restarts"] G --> H["resume at step 4
steps 1 to 3 are not repeated"] end P1 ~~~ P2 classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class X,Y bad; class G,H ok;
A forty-step agent run is a distributed transaction wearing a hoodie. Treat it like one.
Durable runs · in TypeScript

Save after every step. Resume from the record.

Three rules and they are all boring. Write the state down after each step, outside the process. On restart, skip what is already recorded. Know which steps are safe to redo.

The gotcha

Batching the saves to "reduce writes" quietly reintroduces the exact problem you were solving. Checkpoint after every step, every time.

run it → s07/durability/checkpoint.ts
export async function runDurable(runId, steps, store, perform) {
  // Load, or start fresh. `completed.length` IS the resume point.
  const record = (await store.load(runId)) ?? {
    runId, status: "running", completed: [], approvedSteps: [],
  };

  for (let i = record.completed.length; i < steps.length; i++) {
    const step = steps[i];

    // Waiting on a human is a STATE, not a held-open socket.
    if (needsApproval(step) &&
        !record.approvedSteps.includes(step.name)) {
      record.status  = "awaiting_approval";
      record.pending = { name: step.name, effect: step.effect };
      await store.save(record);
      return record;              // come back tomorrow, different process
    }

    const output = await perform(step, record.completed);
    record.completed.push({ name: step.name, output, effect: step.effect });

    // AFTER every step. Not every five. Not at the end.
    await store.save(record);
  }

  record.status = "done";
  await store.save(record);
  return record;
}
Before you resume anything

Not every step is safe to redo.

This is the three-line table you need before a resume, a retry or a replay. It is why every tool declared an effect back on slide ten.

EffectExampleSafe to replayNeeds
readlookup_order, search_docsyes, freenothing
writeupdate_address, create_ticketyes, but only because of the keyan idempotency key derived from the intent
irreversibleissue_refund, send_email, delete_accountnoa key, an audit log entry, and a receipt a human can see
Session 5 gave you idempotency keys for retries. This is the same key, now load-bearing for an entire run.
the incident, as a four-line test → "a resume would re-fire every side effect"
The shape this gives you

A run is a state machine, so pausing is free.

Once the state lives in a store, "wait for a human to approve this refund" stops being hard. It is one more status. Nothing is holding a connection open, and a different process can pick the run up tomorrow.

stateDiagram-v2
  [*] --> running
  running --> done: no steps left
  running --> failed: a step threw
  failed --> running: resume
  running --> awaiting_approval: irreversible step
  awaiting_approval --> running: approved
  awaiting_approval --> cancelled: declined
  done --> [*]
  cancelled --> [*]
        
Thursday this becomes a security control. Tonight it is just good architecture that happens to be reusable.
Single vs multi-agent

One agent, until one agent is not enough.

Multi-agent means an orchestrator handing subtasks to specialists. It is drawn as an org chart and sold as a capability. It is really a trade: you buy parallelism and clean context, and you pay coordination, multiplied cost, and new failures.

flowchart TB
  O{"Orchestrator"}
  O --> A["Researcher
own tools, own history"] O --> B["Writer
own tools, own history"] O --> C["Checker
own tools, own history"] A --> S["Merge"] B --> S C --> S classDef o fill:#EEE6FF,stroke:#1F2937,color:#0E1726; class A,B,C o;
Default to one agent with good tools. Split only on a seam that is genuinely there.
The honest reason

You split for context, not for org charts.

The reason that survives contact with production is dull: each worker gets a clean history. One subtask's forty lines of tool output never lands in another's window. Everything else people claim is usually available from one agent with better tools.

A real seam
subtasks that are independent and each need a large, different context. Research three vendors, then compare.
A fake seam
a chain: fetch, then clean, then write. That is a pipeline. Splitting adds handoffs and buys no parallelism.
Also fake
same context for every subtask. You just paid coordination to send the same window three times.
shouldSplit, as a function you can run → s07/multi-agent/orchestrator.ts
If you do split

Four shapes, and only one of them is parallel.

ShapeHow it worksUse whenBreaks as
Orchestrator and workersone splits, several run at once, one mergesindependent subtasks, different context eachmerge quality: the orchestrator trusts every worker
Handoffone agent passes the whole conversation to anotherclear domains: billing, then technical supportping-pong between two agents that both decline
Pipelinefixed stages, output of one is input to the nexta known sequencethis is a workflow. Just write the workflow
Blackboardagents read and write one shared staterarely, in research settingsnobody can tell you who wrote what, or why
If your diagram is a straight line, you do not have a multi-agent system. You have a pipeline with extra bills.
The multi-agent tax

Measure the tax before you argue about the pattern.

Splitting costs a call to split and a call to merge, before any work happens. Divide the budget rather than sharing it, or the first worker eats the pot. And a failed worker must be named, not quietly missing from the answer.

Error propagation

One agent's confident guess becomes another agent's fact. In a single agent that is one mistake. Across three, it is a mistake that arrives with a second opinion attached.

// Divide, do not share. A shared pot is a first-come budget.
const perWorker = (budgetUsd - coordinationUsd) / subtasks.length;

const results = await Promise.all(subtasks.map(async (task) => {
  // A FRESH history per worker. This is the point of the pattern.
  const isolated = [`goal: ${task.goal}`];
  try {
    const { output, costUsd } = await worker(task, isolated);
    if (costUsd > perWorker)
      return { id: task.id, output, trusted: false, costUsd,
               note: "over its share" };
    return { id: task.id, output, trusted: true, costUsd };
  } catch (err) {
    // Named, not silently dropped from the merge.
    return { id: task.id, output: "", trusted: false,
             costUsd: 0, note: message(err) };
  }
}));

const merged   = merge(results.filter((r) => r.trusted));
const excluded = results.filter((r) => !r.trusted).map((r) => r.id);

// The number that ends the argument:
coordinationShare(result);  // 0.5 = half the bill was talking
The map to keep

Each pattern, and how it breaks.

PatternBuys youBreaks asGuardrail
The loopthe model can take stepsit never stopsfour stops: done, cap, budget, no progress
Tool usereach into real systemswrong tool, wrong args, over-reachfew tools, typed contract, server-side rules
Tool resultsthe model learns what happenedcontext bloat, re-sent every stepsummarise, bound, paginate
MCP and imported toolsreach without writing glueprivileges nobody reviewedgateway, allow-list, pin versions
Planninghandles the unknownwandering, error compoundingvalidate between steps, cap the replans
Memorycontinuityhistory growth, stale factstrim, expire, never treat as authority
Long runsreal multi-step workrestart from zero, double side effectscheckpoint each step, idempotency keys
Multi-agentparallel specialists, clean contextcoordination cost, error propagationsplit only on a real seam, divide the budget
The through-line

Five rules for an agent that holds.

1
Fewest tools that work
every tool is attack surface and a chance to pick wrong.
2
Tight contracts
the tool enforces the rules, in code. Not the prompt.
3
Always bounded
steps, money, repeats, time. Four stops, not one.
4
State outside the process
so a dead run resumes instead of restarting.
5
Observable
every step traced, so a run can be replayed (Week 5).
Every slide tonight is runnable

The whole session, as code that passes tests.

One folder per decision, all of it offline, no API key. If you only do one thing after tonight, run the tests and read the names: they are the failure modes in this deck, written as assertions.

FolderThe slides it makes runnable
tools/a tool as a typed contract, the registry as an allow-list, errors as observations, bounded results
loop/the loop, the four stops, repeat detection, parallel tool calls
planning/the four planner shapes, choosePlanner, the compounding math, plan with validation
durability/checkpoint and resume, pause for a human, what is safe to replay
multi-agent/shouldSplit, isolated worker context, divided budget, the coordination tax
real-world/an order support agent whose refund tool refuses the model
github.com/ehsangazar/maven-llms-and-agents-6-weeks → week-4 / s07-agent-architecture
npx vitest run weeks/week-4-agent-architecture-security  ·  npm run lab .../s07-agent-architecture/decide.ts
Your turn · ~10 min

Sketch your agent's loop.

Five boxes for your own system. If it earned an agent, draw it. If it did not, draw the workflow you chose instead: that is a full answer, and tonight's table still applies to it.

1
Tools
the fewest that do the job, with their effect marked
2
Planner
none, react, plan, or reflect, and why
3
Stops
all four numbers, written down
4
State
what survives a crash, and where it lives
5
Break
the one failure mode you are accepting
Post your step cap and your budget in the chat. I want to see that everybody has both.
Recap · then Thursday

A loop, four stops, and a failure mode per choice.

Tools are contracts your code enforces. Errors are messages, not exceptions. Steps multiply, so check between them. State belongs outside the process. Stay single until a real seam forces multi. Every pattern ships with a failure mode, name it before it names you.

Thursday · S8 workshop
Securing agents: prompt injection, tool poisoning, guardrails. Pick and threat-model the architecture you just sketched. Output: Project 3.
due Sun Aug 9
Bring
your five-box sketch. Thursday you attack it.
take-home