TwinEdgeAI

↑ Course home

← Previous: Week 3b, Frameworks and Orchestration  ·  Next: Week 4b, Agent Memory Architectures →

Engineering AI Agents · Week 4a

Agent Design Patterns and Scaffolds

Five patterns for workflows, four ways for an agent to reason and act, the main ways to manage context, and a close look at one small, real harness (Pi). The aim is to give you a short list of moves, and a way to measure whether a move helped.

Author: Grok Bot Version: 1.0, October 28, 2026 Study time: about 12 hours

CreditThis coursework is adapted from, and inspired by, Stanford CS329Z: Engineering AI Agents (Diyi Yang, Michael Ryan, John Yang), Week 4, "Agent Design Patterns & Scaffolds" (Monday, October 12). The topic list and the readings come from the course's public schedule [33]. I checked the topic order against the public lecture slides [34]. I used the slides for coverage only. The notes, lab, quiz and assignment are original writing, and the lab uses its own examples and code. This pack does not reproduce the slides. It is not an official Stanford document. I added extra sources of my own. Each is marked "added" in the references.

1. Week overview and learning objectives

Last week gave you frameworks. This week gives you the moves you make with them. A design pattern is a named, reusable solution to a problem that keeps coming back. The lecture opens with a warning from Martin Fowler that patterns are "half-baked": you always have to finish them yourself. So the goal is not to copy a diagram. It is to know the moves, their costs, and when each one fails.

The week has three parts. First, workflow patterns, where your code decides the steps: prompt chaining, routing, parallelization, orchestrator-workers and evaluator-optimizer [2]. Second, reasoning and acting patterns, where the model decides: ReAct, Reflexion, CodeAct, and plan-first designs such as ReWOO [1] [4] [5] [6]. Third, context management, which is how you decide what the model sees at each step: an event log, memory offloading, compaction, observation masking and context isolation [3]. A case study of the Pi harness ties the three together.

The lab builds each pattern with the standard library only. Mock models stand in for the LLM, so you need no API key. The mocks follow scripts and small rules that I wrote. They show how each pattern behaves. They say nothing about how well a real model does. You will also measure, on a made-up run, how many tokens each context strategy sends.

How this pack is builtRequired readings are marked required. Sources I found myself are marked added. When a number is my own arithmetic, or an example is made up, the text says so. Citations like [1] link to the references. This field moves fast and several sources are blog posts, so I note each one's date and what kind of source it is. I opened every source listed while writing, and I say where I read only an abstract.

Learning objectives

After this week you should be able to:

  1. Say where the control flow lives in a system, and use that to separate a workflow from an agent. [2.1]
  2. Describe the five workflow patterns, give a use for each, and name its main cost and failure. [2.2, 2.3]
  3. Explain ReAct, Reflexion, CodeAct and ReWOO in your own words, and say what each one changes about what the model writes and sees. [2.4 to 2.7]
  4. Explain why self-correction needs a grounded signal, using the evidence on both sides. [2.5]
  5. Choose a reasoning and acting pattern for a task and defend the choice. [2.9]
  6. Name the five context patterns, the two principles behind them, and the four LangChain buckets. [2.10, 2.11]
  7. Read the observation-masking results and say what they do and do not show. [2.12]
  8. Explain how a prompt cache changes the cost of masking and compaction. [2.13]
  9. Argue both sides of the multi-agent debate, using the Anthropic and Cognition posts. [2.14]
  10. Analyze the design decisions in the Pi harness. [2.15]
  11. Build and test the patterns in the lab: workflow patterns, four loops, and an event log with masking, compaction, branches and isolation. [Lab]

How to use this pack

TimeWhat to do
2.5 hoursRead the notes (section 2). Skim the figures first.
3.5 hoursRead the three required readings with the reading guide questions, then the Reflexion and CodeAct papers.
3 hoursDo the lab and at least five exercises.
1 hourDiscussion and the quiz.
2 hoursThe graded assignment.

2. Lecture notes

2.1 Workflows and agents: where the control flow lives

One question sorts most systems: who decides the next step? If your code decides, you have a workflow. If the model decides, you have an agent. Anthropic's post, a required reading, uses exactly this line. Workflows are "systems where LLMs and tools are orchestrated through predefined code paths". Agents are "systems where LLMs dynamically direct their own processes and tool usage" [2]. The lecture draws the same picture, and adds a third box at the left: a single model call, which is a small part of a larger program [34]. Figure 1 shows the three.

Where does the control flow live? Three columns. A single model call: the program calls the model once. A workflow: code decides the steps and the model fills in text. An agent: the model decides the next step in a loop and the code runs it. An arrow below shows predictability and ease of testing going down as flexibility goes up. Examples are listed under each column. Model call Code asks once, uses the text. Control flow: your code. Example: summarize one file. Workflow Code fixes the steps. The model fills in text. Example: classify, fetch, draft. Agent The model picks the next step. Code runs it and loops. Example: fix a bug in a repo. Patterns: none needed. Often enough. Try it first. Five patterns: chain, route, parallel, orchestrate, refine. Reason and act: ReAct, Reflexion, CodeAct, ReWOO. Plus context. more predictable, cheaper to test and debug more flexible, handles open-ended tasks Real systems mix them: a workflow whose steps are small agents, or an agent that calls a workflow as a tool. Start at the left. Move right only when the task needs it, and you can measure the gain.
Figure 1. Where the control flow lives. Moving right gives you flexibility and costs you predictability. The advice from [2] and the lecture is to start at the left and move right only when you can show a gain. The box at the bottom is my own gloss.

The trade is easy to state. A workflow is more predictable: you can read the code and know the paths. It is also easier to reproduce and debug. An agent is more adaptable: it can handle a case that you did not plan for. In exchange it is harder to test, and every extra step is a chance to go wrong. The lecture's table says the same thing in four rows: control flow (code or model), adaptation (fixed branches or a choice at each step), best fit (a clear repeatable structure or open exploration), and an example (classify a support ticket and draft a reply, versus investigate and resolve it with tools) [34].

The Anthropic post adds a rule that is worth keeping close. Find the simplest solution that works. That may mean no agentic system at all. For many applications, "optimizing single LLM calls with retrieval and in-context examples is usually enough" [2]. Note that the post carries a banner saying much of its tooling landscape has changed since December 2024. The patterns have aged better than the tool names.

Real systems mix bothThe lecture stresses that real systems often combine workflows and agents [34]. A support bot may route each ticket with a classifier (a workflow step), then hand a hard case to an agent that can look things up. Or an agent may call a fixed workflow as one of its tools. Do not ask "workflow or agent?" for a whole product. Ask it for each step.

2.2 The five workflow patterns

Anthropic names five patterns that repeat across customer projects [2]. The lecture teaches the same five. They are small. Most take a few lines of code, which is the point [2]. Figure 2 sketches them. Below, I describe each in my own words, with its cost and a way it fails. The lab has a function for each.

The five workflow patterns Five small diagrams. 1 Prompt chaining: input, step 1, a gate, step 2, output. 2 Routing: input, a classifier, then one of three specialist branches. 3 Parallelization: input goes to three calls at the same time, and an aggregator combines them by sectioning or voting. 4 Orchestrator-workers: an orchestrator makes a list of subtasks that is not fixed in advance, workers run them, and a synthesizer combines the results. 5 Evaluator-optimizer: a generator makes a draft, an evaluator returns feedback, and the loop repeats until accepted or the round limit is reached. 1 Prompt chaining 2 Routing 3 Parallelization 4 Orchestrator-workers 5 Evaluator-optimizer step 1 gate: check step 2 output classify reset refund other each branch has its own prompt, model or tools call call call aggregate sectioning: different jobs voting: same job, n times orchestrator worker worker worker synthesize how many workers? the model decides generate evaluate draft feedback stop: accepted or the round limit What each one costs you (my own summary): chain: latency. route: a classifier error. parallel: more calls. orchestrate: more tokens and an unknown count. refine: loops, so set a limit. Compose them. The STORM system mixes chaining, parallel chats and workers in one pipeline.
Figure 2. The five workflow patterns. Boxes with a white fill are model calls. The orange gate is code. The dashed worker in diagram 4 shows that the number of workers is not fixed. The cost line is my own summary.

1. Prompt chaining

Split a task into steps. Each model call works on the output of the one before. You may add a programmatic gate between steps: a check that the output is on track before you continue [2]. The lecture's summary is that chaining trades latency for accuracy, and helps when a task is complicated and gains from decomposition [34].

2. Routing

A first call classifies the input. The label picks a specialist path: a different prompt, a different tool set, or a different model [2]. The lecture notes it can also save money or time, by sending easy cases to a weaker model [34].

3. Parallelization

Several calls run at the same time and your code combines the results. There are two variants. Sectioning splits the work into independent parts, for example one call screens a query for safety while another answers it. Voting runs the same task several times and takes the majority, which is inference-time scaling [2] [34].

4. Orchestrator-workers

It looks like parallelization, with one key difference: the subtasks are not fixed in your code. A central model breaks the task down at run time, delegates to workers, and combines the results [2]. Anthropic's examples are coding changes across several files (you cannot know in advance how many) and search over several sources. The lecture's phrase is that the subtasks are "dynamically created" [34].

5. Evaluator-optimizer

One call generates. Another evaluates and gives feedback. The loop repeats [2]. The lecture states two conditions: the feedback must be clear and articulable, and a model must be able to give it. It also needs the generator to be able to improve from the feedback [34]. The Anthropic post gives an easy test: can a human improve the output by saying what is wrong, and can the model say it too? [2]

One thing the five patterns shareIn each, your code owns the control flow, so each is easy to unit test with a scripted model. That is what the lab does. You can check that a gate stops a chain, that a tie goes to the first-seen answer, and that a bad plan is retried once, without any real model.

2.3 Putting patterns together: STORM and Archon

Real systems are compositions of the five. The lecture shows two examples [34]. I read the abstracts of both papers. The pattern labels below are the lecture's, and the lecture says some are approximate.

Two lessons follow. First, a composition is only as good as its weakest gate, so test each part alone before you test the whole. Second, once you have a space of designs, you need an evaluation to choose between them. Archon's idea is to automate that choice. A budget and a metric are what make that possible. That is the "evaluation" pillar from last week again.

2.4 ReAct: think, then act

Now the model is in charge. In an agent loop, the model chooses an action, the harness runs it, and the result goes back to the model. The loops differ in what the model writes each turn and when it sees results. Figure 3 shows the four loops of this week side by side. I take them one at a time.

Four ways to act: ReAct, Reflexion, CodeAct and ReWOO Four panels. ReAct: a loop of thought, action and observation, and the whole transcript is sent again each turn. Reflexion: a trial of a ReAct actor, an external evaluator, a reflection written as a lesson, and a bounded memory that goes into the next trial. CodeAct: the model writes one block of code, a persistent interpreter runs it with tools as functions, and variables survive between turns. ReWOO: a planner writes a plan with placeholders, a worker runs the tools with no model, and a solver reads the evidence and answers, so there are two model calls. ReAct: think, act, observe Thought Action Observation append, then send everything again one tool call per turn; the model sees each result Reflexion: learn between trials trial (ReAct) evaluator reflect memory: last 1 to 3 lessons feedback must be grounded: tests or an environment CodeAct: the action is a code block model writes code for c in countries: usd[c] = ... interpreter + tools as functions variables stay in memory errors come back as text one turn can loop over many tool calls not a security boundary: run it in a container ReWOO: plan, run, solve Planner 1 model call Workers tools, no model Solver 1 model call #E1 = search[who founded X] #E2 = search[birth year of #E1] independent steps can run together (LLMCompiler) All four are loops or pipelines around the same model. They differ in WHAT the model writes and WHEN it sees results.
Figure 3. Four ways to act. ReAct interleaves thought and action. Reflexion adds a lesson between trials. CodeAct lets the action be a block of code. ReWOO plans first and lets tools run without the model. The bottom line is my own summary.

The ReAct paper (required reading 1) starts from a gap. Chain-of-thought prompting made models reason. Action-plan prompting made them act. They had mostly been studied apart. ReAct asks the model to produce reasoning traces and task actions "in an interleaved manner". Reasoning helps the model induce, track and update a plan and handle exceptions. Actions let it get information from outside, such as a knowledge base [1].

The format is simple. Each turn has a Thought (free text), an Action (a tool call, such as search[Harbor Labs]), and then the harness adds an Observation (the tool result). The whole transcript is sent back each turn, so the model can see what it has done. The lab's react function is about 30 lines. Its demo question takes 5 model calls and 483 prompt tokens (by the lab's word-count proxy), because each turn re-sends the earlier ones.

What the paper shows

The paper used prompted PaLM and GPT-3 models with a few examples in the prompt. Today a model has tool calling built in, and the "Thought" is often a hidden reasoning step. The loop is the lasting idea, and the lecture treats it as the base of the other three [34]. The Pi harness in 2.15 still runs it, with JSON tool calls.

Where ReAct breaksThree failures show up in the paper and in practice. (1) Looping: the model repeats a thought and action. Guard with a step limit, and with a stuck detector that stops after the same action three times. The lab's react does both. (2) A weak tool result: the paper found uninformative search in 23% of ReAct's error cases, and the model had a hard time recovering [1]. (3) Cost: because the transcript is re-sent, the input grows with every step. Section 2.10 is about this.

2.5 Reflexion and the limits of self-correction

A ReAct agent that fails starts the next attempt knowing nothing. Reflexion fixes this with verbal reinforcement: no weights change. After a failed trial, a reflection model reads the trajectory and the score, and writes a short lesson in words. The lesson goes into a memory that the actor reads on the next trial. The paper names three parts: an Actor, an Evaluator and a Self-Reflection model. Memory is bounded to a small number of stored lessons, "usually set to 1-3", to fit the context window [4].

The results are strong. On the AlfWorld tasks, ReAct plus Reflexion completed 130 of 134 tasks, after learning over 12 trials, and the ReAct-only run stopped improving between trials 6 and 7. The abstract reports 91% pass@1 on HumanEval, against 80% for GPT-4 at that time [4]. In AlfWorld the evaluator was a hand-written heuristic: reflect if the agent repeats the same action and gets the same response for more than 3 cycles, or takes more than 30 actions [4]. The lab has the same idea in a few lines: reflexion runs trials, and keeps the last k lessons in a bounded memory.

The question to ask: what is the feedback signal?Reflexion works when the evaluator is grounded: unit tests, an environment that says "done", a calculator. Huang et al. ran the other case. They tested "intrinsic" self-correction, where a model revises its own answer with no outside feedback. On reasoning tasks the models struggled to self-correct, and sometimes did worse after trying. They traced the gains in earlier papers, including Reflexion's reasoning setup, to oracle labels about whether the answer was correct. The gains vanished without the labels [12]. That does not make Reflexion wrong. Its own limitations section says the method depends on the strength of the model's self-evaluation (or the heuristics) and gives no formal guarantee of success [4]. It means that the value of a reflection loop is the value of its signal at deployment time. If you will have no tests or checks in production, do not expect the lab numbers.

CRITIC takes the grounded route in full. The model drafts an output, then uses tools, such as a search engine to check facts or a code interpreter to debug, to critique and revise it [13]. This is why the lecture pairs the two. A search call or a test run is real information. A model's "does this look right?" is only its opinion. The same lesson applies to the evaluator in the evaluator-optimizer pattern (2.2). Give it something real to check.

2.6 CodeAct: code as the action

Most agents emit a JSON object per tool call. The CodeAct paper argues that this action space is narrow: you can only call the tools you defined, one at a time, in a fixed format. CodeAct lets the model write executable Python as its action, with the tools available as functions [5]. The code runs in an interpreter, and the output (or the error) comes back as the observation. Because the interpreter persists, the model can revise earlier actions or emit new ones, and variables survive between turns.

The paper's argument is that code brings control flow (loops, conditions) and data flow (the output of one tool is the input to the next) for free. Their benchmark of multi-tool tasks, M3ToolEval, has 82 human-curated tasks. The abstract reports that, across 17 LLMs on API-Bank and a new benchmark, CodeAct beat the common alternatives by up to a 20% higher success rate. The introduction adds that on the multi-tool benchmark it gave up to a 20% absolute gain with up to 30% fewer actions [5]. The authors also built a fine-tuned agent (CodeActAgent, from Llama 2 and Mistral) on 7k multi-turn CodeAct interactions [5].

The lab shows why, on one task. The same lookup of prices for three countries takes 9 model calls and 1,838 prompt tokens with JSON actions (one tool call per turn), and 3 calls and 525 tokens with CodeAct. In turn 1 a mistake raised ValueError and the next turn fixed it. A variable stayed in memory, so the prices were not fetched twice. The mock models are scripted to make this point, so treat these as a demonstration of the mechanism, not as a measurement of real models.

Code is power. Run it somewhere safe.Letting a model run code means a mistake, or a prompt injection, can run code. The lab's CodeSession catches errors, limits output size, and stops a runaway loop after a line budget. It is not a security sandbox. Python's own namespace is not a boundary. For real use, run model-written code in a container or a similar sandbox with no secrets, limited network access and time limits. This is my own advice, and it is the first thing I would check in any code-acting agent.

A link to contextA code action can loop over a hundred files and print only the result. The model never sees the hundred intermediate outputs. So CodeAct is also a context-saving move. The Pi harness has an optional "codemode" tool built on this idea: it runs JavaScript that calls the other tools, and "only the script's output reaches the model" [31]. See 2.15.

2.7 Plan first: ReWOO and LLMCompiler

ReAct pays for its flexibility. After every tool result the model is called again, with the whole transcript. ReWOO ("Reasoning WithOut Observation") argues that for many tasks the model does not need to see results to know the steps. It splits the loop in three [6]:

  1. A Planner writes the whole plan in one call. Later steps refer to earlier results with placeholders: #E1 = search[who founded Harbor Labs], then #E2 = search[birth year of #E1].
  2. A Worker runs the tools in order and fills in the placeholders. No model call is needed here.
  3. A Solver reads the plan and the evidence and writes the answer, in one more call.

That is two model calls in total, instead of one per step. The paper reports 5× token efficiency and a 4% accuracy gain on HotpotQA, results over six public benchmarks and one curated dataset, robustness when tools fail, and an experiment that moved the planning skill from a 175B GPT-3.5 model into a 7B LLaMA model [6]. In the lab, the same four-step question takes 2 model calls and 138 prompt tokens with rewoo, against 5 calls and 483 tokens with react. The mock is scripted, so this shows the mechanism and not a general ratio. Steps that depend on nothing earlier can also run together. The lab's waves function groups the four steps into three waves: E1 and E3 first (they do not depend on each other), then E2, then E4.

LLMCompiler takes the same idea and makes it a compiler. A planner builds a graph of function calls with their dependencies, a task-fetching unit dispatches them, and an executor runs the ready ones in parallel. The abstract reports latency speedups of up to 3.7×, cost savings of up to 6.7× and accuracy gains of up to about 9% compared with ReAct. It also supports dynamic replanning, using a feedback loop, for tasks whose flow cannot be decided at the start [7]. Plan-and-Solve prompting is the lightweight cousin: ask the model to devise a plan, then carry it out, which fixes the "missing step" errors of zero-shot chain-of-thought [8].

The price of planning earlyA plan written before any observation cannot react to a surprise. The ReWOO paper's own limitation section gives an example. In an AlfWorld task ("put some vase in safe") the planner knows nothing about the room, so it would have to list every place a vase might be, which is as much work as the worst case of step-by-step search [6]. Use plan-first when the steps are mostly predictable, such as multi-hop lookups. Use ReAct when each result changes what you do next. LLMCompiler's replanning is one way to get both [7]. A tool error in the lab's ReWOO becomes evidence for the solver ("no result", "error: ...") and not a crash.

The lecture's list stops at four loops, but three other patterns are worth a short note. Each spends more model calls to get a better answer. I read the abstract of each paper.

PatternIdeaReported result (abstract)Watch for
Tree of Thoughts [9]Explore several reasoning paths. Evaluate partial "thoughts", look ahead, backtrack.Game of 24: GPT-4 with chain-of-thought solved 4%, ToT 74%.Many calls per problem. Best on tasks with a clear way to score a partial step.
LATS [10]Monte Carlo tree search over actions, with a model-based value function, reflections, and real environment feedback.92.7% pass@1 on HumanEval with GPT-4. 75.9 average score on WebShop with GPT-3.5.Needs an environment you can reset and replay. Cost grows with the number of rollouts.
Self-Refine [11]One model drafts, gives itself feedback, and revises, in a loop.About 20% absolute average gain over one-step generation, on 7 tasks with GPT-3.5 and GPT-4.This is the "intrinsic" kind of self-correction in 2.5. Compare against a same-budget baseline [12].

They are the same three ideas again: branch (try several paths), learn (feed a lesson back), and refine (loop on a draft). LATS combines the first two with a real environment [10]. The numbers in the table are what each abstract says about its own setup. The models and tasks are different, so do not compare them with each other.

2.9 Choosing a reasoning and acting pattern

There is no best pattern. There is a best pattern for a task, a budget, and a feedback signal. This table is my own summary of the sources in this section. Use it as a first guess, then measure.

PatternChoose it whenWhat it costsFirst thing to check
Workflow (2.2)You know the stepsLittle. Easy to test.Do you really need an agent?
ReActEach result decides the next stepOne call per step, and the transcript growsStep limit and stuck detector
ReflexionYou can retry, and you have a grounded checkWhole trials, repeatedWhat is the evaluator in production?
CodeActSteps need loops, and results feed other toolsA sandbox to build and runIs the code run in isolation?
ReWOO / LLMCompilerSteps are predictable, and cost or latency mattersA plan that may be wrongWhat happens when a step is a surprise?
ToT / LATSA partial step can be scored, and the task is hardMany calls per problemDoes a cheaper pattern with the same budget do as well?

Two rules help. First, match the pattern to the feedback you have. Grounded feedback (tests, a calculator, an environment) supports Reflexion, CRITIC and LATS. No feedback supports a plan-first design or a simple workflow. Second, patterns stack. A coding agent may be ReAct at the top, run CodeAct-style actions, keep a bounded memory of lessons, and use a workflow for the final check. Add one layer at a time, and keep the evaluation from last week to see if each layer helped.

2.10 Why context is the main job

Once a loop runs for dozens of steps, the main engineering question changes. It is no longer "what should the model do?" It is "what should the model see right now?" LangChain defines context engineering (required reading 3) as "the art and science of filling the context window with just the right information at each step of an agent's trajectory". It borrows a line from Andrej Karpathy: the model is like a CPU and its context window is like RAM [3]. The post also quotes Cognition, which calls context engineering "effectively the #1 job of engineers building AI agents" [3]. Three problems push this to the top of the list.

Problem 1: quality falls as context grows

Anthropic's guide says that as the number of tokens in the window grows, the model's ability to recall information from it goes down. It names this context rot, and it treats context as "a finite resource with diminishing marginal returns" and a limited "attention budget" [19]. Older research found a related pattern. In a study of long-context question answering, models did best when the relevant fact was at the beginning or end of the input, and worse when it was in the middle [22]. LangChain lists four named failures, taken from Drew Breunig: poisoning (a hallucination enters the context), distraction (too much context overwhelms what the model learned), confusion (extra context changes the answer), and clash (parts of the context disagree) [3]. A bigger window does not remove these problems. Anthropic says plainly that windows of all sizes will be subject to context pollution for the foreseeable future [19].

Problem 2: tokens add up fast

Each turn re-sends the whole history. The input of a loop therefore grows faster than the work done. Here is my own arithmetic for a simple case. Say the system prompt is s tokens and each turn adds r tokens of new content. The input of call k is s + (k−1)r. Over n calls the total input is n·s + r·n(n−1)/2. For s = 200, r = 1,000 and n = 40, that is 8,000 + 780,000 = 788,000 tokens. The run only produced 40,000 tokens of new content. The total grows with the square of the number of turns. Most of the bill is old content, read again.

Observations are the bulk of it. In the JetBrains study of a software engineering agent (SWE-agent), observation tokens made up about 84% of an average turn, in the authors' preliminary experiments on 50 SWE-bench Lite instances [14]. A single file read or test log can run to thousands of tokens.

Problem 3: the cache prices continuity

The Manus team (a company that builds a general agent) says that if they had to pick one metric, the KV-cache hit rate is "the single most important metric" for a production-stage agent. Their input-to-output ratio is around 100 to 1, and with Claude Sonnet, cached input cost $0.30 per million tokens against $3 uncached, a 10× difference [15]. Their advice follows from that. Keep the prompt prefix stable (no timestamp at the top). Make the context append-only. Do not edit earlier turns. Make serialization deterministic [15]. This sets up a tension for the rest of this section. Editing old context to save tokens (masking, compaction) can break the cached prefix and cost more than it saves. Section 2.13 measures this.

Two principlesThe lecture's context patterns serve two goals [34]. Focus the context: give the model the smallest set of high-signal tokens for this step [19]. Preserve continuity: do not lose what the agent needs later (a decision, an error, a file path), and do not break the cache without a reason. A strategy that only does the first one is a bug generator. A strategy that only does the second one is a slow, expensive agent.

2.11 The five context patterns

The lecture lists five patterns, and the LangChain post groups strategies into four buckets: write, select, compress and isolate [3] [34]. Figure 4 shows both lists and my own mapping between them. The mapping is not in either source.

Context management patterns: the lecture list and the LangChain four Left column lists the lecture patterns: persistent event log, memory offloading, compaction, observation masking, context isolation and progressive disclosure. Right column lists LangChain categories: write, select, compress, isolate. Arrows show my mapping: memory offloading maps to write, progressive disclosure to select, compaction and masking to compress, and context isolation to isolate. The event log sits underneath all of them as the record they work from. Two principles at the bottom: focus the context, and preserve continuity. The lecture: five patterns LangChain: four buckets Memory offloading write notes and facts to files or a store Progressive disclosure load skills and tools only when needed Compaction summarize old turns into a short text Observation masking hide or cut old tool outputs Context isolation give a sub-agent its own window Write save context outside the window Select pull the right context in Compress keep only the tokens you need Isolate split context across windows Event log: the record all of them read my mapping Principle 1: keep the context focused Principle 2: preserve continuity (and the cache)
Figure 4. Context management patterns. Left: the lecture's list. Right: LangChain's four buckets. The arrows are my own mapping. The event log is the record that all of the others read from.
PatternWhat it doesWhat you keepWhat you risk
Persistent event logEvery message, tool call and result is appended to a log. The model's prompt is built from the log. The log is never edited.Everything. You can replay, branch and audit.Disk space. And a temptation to send all of it.
Memory offloadingMove facts out of the window into files or a store. Pull them back by tool call when needed.Facts, in a place the model can reachThe model must remember to look. Stale notes.
CompactionReplace old turns with a short summary, and keep the recent turns whole.The gist, and the recent detailA needed detail may be left out. A summary call costs money.
Observation maskingReplace old tool outputs with a short placeholder. Keep the model's own thoughts and actions.The path of the reasoningA fact that only appeared in a masked output
Context isolationGive a sub-task its own window. Only a short report returns to the parent.A clean parent windowLost nuance in the report. Extra tokens overall.

Event log

OpenHands, an open-source agent platform, is the lecture's example [23] [34]. Its SDK documentation describes an "immutable, type-safe event framework", in which events "form an append-only log that serves as both the agent's memory and the integration point for auxiliary services" [24]. The design choice is the important part. Store everything. Send only a view. If the log is complete, compaction and masking cannot lose data for good, because they only change the view. In the lab, the Session class holds events, and build_context makes the view. The lab can also restore a masked output with recall (the Manus team also says compression should be "restorable", for example by keeping a URL or a file path [15]).

Memory offloading and progressive disclosure

MemGPT proposed "virtual context management", an idea taken from operating systems that page data between fast and slow memory, so a model can work with more than its window holds [21]. LangChain's "write" bucket covers the same ground: scratchpads inside a session, and memories across sessions [3]. Anthropic calls it structured note-taking [19]. Notes written to a file survive any compaction. The lecture also covers progressive disclosure, which is the "select" side. Anthropic's Agent Skills show it: at start-up, only each skill's name and description is loaded. The body of SKILL.md loads when the skill looks relevant. Linked files load only if needed [20]. Memory is a larger topic that the lecture hands to a later class, so this pack only touches it.

Compaction

When the context nears the limit, summarize the old part and continue from the summary. Claude Code does this with an auto-compact step after the window passes 95% [3]. Anthropic describes compaction as the first lever for long tasks, and says the art is in choosing what to keep: architecture decisions and unresolved bugs stay, and redundant tool output goes [19]. The hardest part is what the summary leaves out. Cognition says a summarizer for agent hand-offs is hard to get right, and that it fine-tuned a smaller model for the job [17]. In the lab's compact function, a trigger (should_compact) fires when the context passes the window minus a reserve. It then keeps a recent tail of tokens and summarizes the rest. The log gets a new entry, so nothing is deleted.

Observation masking

Old tool outputs are usually the largest part of the context and the least useful. Masking replaces each output older than the last M turns with a short placeholder, such as "output omitted". The reasoning and the actions stay. It costs no model call. The next section looks at the evidence. One warning on terms: in the Manus post, "mask" means something else. Manus masks token logits during decoding, to allow or block certain tool choices without removing tool definitions from the prompt [15]. Same word, different thing. In this pack, "masking" means hiding old observations [14].

Context isolation

Give a sub-task its own window. The parent sees only a report. LangChain lists multi-agent splits, sandboxes (code runs where its big outputs stay in variables), and state fields the model does not see [3]. See 2.14 for the argument about when this helps. The lab's isolate function runs a worker on a child Session and appends a bounded report to the parent.

2.12 Masking or summarizing? The Complexity Trap

Required reading 3 is a blog post that surveys strategies. The best evidence for one of its patterns is a short paper, "The Complexity Trap" by Lindenbauer and colleagues at JetBrains Research and TU Munich. It is an arXiv paper that was presented at a NeurIPS 2025 workshop on deep learning for code, so I treat it as a careful study of one domain, not a settled result [14]. The question: do software agents need LLM summaries of old context, or is it enough to hide old observations?

Setup. SWE-agent on SWE-bench Verified, with five model configurations. Three strategies: the raw agent (no management), observation masking with a rolling window of the last M observations (with M = 10 for SWE-agent), and LLM-Summary [14].

FindingEvidence from the paper
Masking halves the cost of the raw agent, and matches the solve rate of summarizing.The abstract says so. For Qwen3-Coder 480B: raw agent 53.4% solved at $1.29 per instance; masking 54.8% at $0.61 (−52.7%); LLM-Summary 53.8% at $0.64 (−50.4%) [14].
Summaries make trajectories longer.For Qwen3-Coder 480B the mean trajectory was 15% longer than the raw agent's and 13% longer than with masking. The authors call it "trajectory elongation" and say it explains part of the cost gap [14].
Summary calls cost money and cache poorly.The calls to write summaries were up to 7.2% of the total cost, and each one processes a unique stretch of turns, so little of it is cached [14].
A hybrid does a little better.Masking plus summarizing cut cost a further 7% against masking alone and 11% against summarizing alone, in one setup. With the wrong settings it made things worse, because of "compounding KV cache inefficiencies" [14].
Window size is scaffold-specific.On OpenHands, reusing SWE-agent's best M degraded masking "drastically". It had to be tuned again. The authors suspect that OpenHands keeps retry turns that SWE-agent drops [14].
One model went the other way.For Gemini 2.5 Flash with thinking, both strategies solved fewer instances than the raw agent: 36.4% and 31.4%, against 40.4% [14].

What the paper does not showThe authors list their own limits. They tested only software engineering, where tool outputs are long and noisy, a condition that "naturally favors" masking. The results may not carry over to tasks with short outputs. Their triggers are simple fixed rules, not adaptive ones [14]. And masking only works if the fact you need again is not only in an old observation. My reading: masking is a strong baseline to beat, before you pay for a summarizer. It is not a rule. Always run your own test with your own task and a needle test: plant a fact in an early observation, and check whether the agent can still use it at the end. The lab's context demo does this.

2.13 Cache and cost: a toy model

The lecture and the Complexity Trap paper both measure tokens saved or dollars saved [14] [34]. A prompt cache makes these two numbers differ. A provider can reuse the work for the part of a prompt that is an exact prefix of an earlier prompt, and charges less for it. The Manus team's numbers (10× cheaper) are the example above [15]. A strategy that edits old turns changes the prefix, and then the cache can no longer help. This section is my own analysis. It uses a toy model, so please read it as a way to think, not as a measurement of any real system.

The model

The lab's simulate function runs this model for five strategies. Figure 5 and the table below are its output (the numbers match the demo in the lab).

Input tokens per model call over a made-up 40-turn run A line chart. The x axis is the call number from 1 to 40. The y axis is input tokens for that call, up to 21000. Full history grows in a straight climb to about 20800. Masking with a window of 10 rises and flattens near 6000. Stepwise masking with stride 10 saw-tooths up to about 10500. Compaction rises and falls eight times, never above 3700. Isolating the first 12 turns and compacting stays low. A side table lists totals, cache share and cost. 0 5k 10k 15k 20k 1 10 20 30 40 model call number (parent calls only) input tokens per call full: 20.8k total input cache cost full history 420k (0% less) 95% $0.182 mask, M=10 199k (53% less) 20% $0.492 mask M=10, stride 10 266k (37% less) 89% $0.162 compaction 68k (84% less) 68% $0.080 isolate 12 + compaction 84k (80% less) 41% $0.158 Made-up trajectory, rough token proxy, made-up prices (cache hits 10 times cheaper). It shows shapes, not real costs.
Figure 5. Input tokens per model call in the toy run. Full history grows in a straight line. Rolling masking flattens out, but changes the prefix on every call. Stepwise masking changes it only every ten calls. Compaction falls each time it fires. The numbers are from a made-up run, a word-count proxy and made-up prices. They show shapes, not real costs.
StrategyTotal input tokensSaved vs fullCached shareCost (made-up $)Needle still in context?
Full history419,9600%95%0.182yes
Rolling mask, M = 10199,19553%20%0.492no
Mask M = 10, stride 10266,00037%89%0.162no
Compaction68,20584%68%0.080no
Isolate 12 turns + compaction83,60980%41%0.159no

What the numbers say, in this toy

  1. Saving tokens is not saving money. Rolling masking sends 53% fewer tokens than the full history and costs 2.7 times as much (0.492 against 0.182). The window moves on every call, so almost the whole prompt after the first masked turn is new text. Only 20% of the input stays cached.
  2. A stride fixes most of it. If you mask in steps of ten turns, the prefix stays the same for ten calls in a row. The cached share goes back to 89%, and the cost is 11% below the full history (0.162). The cost of the step is that the window holds between 10 and 19 observations instead of exactly 10, so more tokens are sent.
  3. Compaction wins here. It sends 84% fewer tokens. Between compactions the prefix is unchanged, so the cache works (68%), and the cost is 56% below the full history. The toy does not charge for the summarizing call, which the real cost model must. The paper found that such calls were up to 7.2% of real cost [14].
  4. The needle is lost in every strategy except full history. The turn-3 observation is masked, summarized away, or isolated. The lab's summarizer is a crude extractive one that I wrote, so a better summarizer might keep it. The point is that a saving which loses a needed fact is not a saving. Test for it.

The cost depends on the price ratio. This second table is my own arithmetic from the same token counts. It re-prices each run as the cached price changes. The unit is a made-up dollar.

Cached price as a share of uncachedFull historyRolling maskStride 10Compaction
100% (no cache discount)1.2600.5980.7980.205
50%0.6610.5390.4450.135
25%0.3620.5090.2680.101
10% (the 10× discount)0.1820.4920.1620.080
5%0.1220.4860.1260.073

With no cache discount, rolling masking halves the cost, as the paper found. I do not know how the paper's runs used a cache, so I do not compare the toy's absolute numbers with theirs. In this toy, rolling masking beats the full history only if cached tokens cost more than about 39% of uncached ones. That break-even is my arithmetic: it is the price share q at which (full input − mask input) = (full cached − mask cached)·(1 − q). At a 5% price, the full history even beats the stride run. The ranking flips with the price, so measure with your provider's real prices, your own traces and your real cache behaviour (LangChain's advice is the same: first track token use, then test whether each change helps) [3].

A related caution from the paperThe Complexity Trap authors say cache effects matter. They note summary calls limit cache reuse, and a hybrid with badly chosen settings got worse "due to compounding KV cache inefficiencies" [14]. The toy above is a simple way to see why.

2.14 Isolation and the multi-agent debate

Context isolation is the pattern with the loudest argument around it. Two engineering posts from 2025 disagree, and both are worth reading in full.

The case for splitting: Anthropic

Anthropic built a research system with a lead agent that plans, and sub-agents that search in parallel, each with its own window. In their internal research evaluation, a system with Claude Opus 4 as lead and Claude Sonnet 4 sub-agents beat single-agent Opus 4 by 90.2%. The post says why, with data from a browsing benchmark: token usage alone explained 80% of the performance variance, and it adds that these systems work "mainly because they help spend enough tokens to solve the problem" [16]. The price: agents use about 4× the tokens of a chat, and multi-agent systems about 15× [16]. The post also says what the lead must do. Each sub-agent needs an objective, an output format, guidance on tools and clear task boundaries, or the team duplicates work or leaves gaps. And it says where the design is a poor fit: domains where all agents must share the same context, or with many dependencies. "Most coding tasks involve fewer truly parallelizable tasks than research", the post says [16].

The case against: Cognition

Cognition, the company behind the Devin coding agent, argues the opposite for building software. Its post gives two principles: share context, and share full agent traces, not just individual messages, and actions carry implicit decisions, and conflicting decisions carry bad results. When two sub-agents work from partial context, they make incompatible choices. The post's advice is to use a single-threaded linear agent by default. For tasks longer than one window, add a model whose job is to compress the history into key details, events and decisions. It says Claude Code's sub-agents, as of June 2025, never work in parallel with the main agent and are usually asked only a well-defined question [17]. The post is an argument from experience, with no experiments.

Reading them together

Anthropic [16]Cognition [17]
Task typeBreadth-first research that can be split into independent searchesBuilding software, where parts must fit together
What sub-agents returnFindings that the lead can mergeDecisions (code) that may conflict
Cost viewPay about 15× tokens when the task is valuable enoughPay for a compression model instead
Shared adviceBe exact about what a sub-task gets and returns. Anthropic has the lead write a detailed brief for each sub-agent. Cognition shares the full trace. Both are efforts to stop information from being lost at the boundary.

My reading is that they do not clash as much as the titles suggest. The question is whether the sub-tasks are read-only and independent (research, search, exploration), or whether they write and must agree (code, plans). Isolation is cheap and safe in the first case. It is risky in the second. A sensible default for a new project: one agent, one context, strong compaction. Add isolation for read-only exploration (the lab's isolate does exactly that: the child runs 12 turns of exploration and the parent receives a 60-token report). Add writing sub-agents only when you can show a gain.

The 12-factor agents project makes a related point. Its factors include "Own your context window" (factor 3), "Compact Errors into Context Window" (factor 9) and "Small, Focused Agents" (factor 10). It is an opinionated README from the HumanLayer team, not a study [18].

2.15 Case study: the Pi harness

A good way to test what you learned is to read a real harness that makes few choices. Pi is a small open-source coding agent (an "agent harness"), and the lecture's case study [34]. Its README calls it "a minimal, extensible agent harness" [27]. It was written by Mario Zechner, who explained his choices in a long post in November 2025 [28]. The questions to ask are the ones from this week: where does the control flow live, what does the model see, and what does each missing feature cost?

A moving targetPi changes. The 2025 post says Pi "does not and will not support MCP". The current command-line docs describe an MCP extension and a pi mcp command [28] [31]. So I use the post for the reasoning behind the design, and the current docs for what the code does now. Where they differ, I say so. Check the docs again before you rely on a detail. The lecture's list of what is missing (sub-agents, permission checks, plan mode, to-do lists, background bash) matches the 2025 post [28] [34].

The loop and the tools

The lecture describes a ReAct-style loop that reasons before acting on JSON tool calls [34]. It is the loop from 2.4. By default only four tools are on: read, bash, edit and write. The docs list others (grep, find, ls) that are off by default [31]. Zechner's argument is that models already know bash, and have been trained on read, write and edit tools with similar schemas. He says the system prompt and tool definitions together come to less than 1,000 tokens [28]. Smaller tool definitions mean more context for the task, and fewer tools for the model to confuse.

Two optional extensions are worth a look. codemode runs JavaScript that calls the other tools, for example in parallel, and "only the script's output reaches the model". tool_search finds tools that are not declared to the model and declares the matches for the next call [31]. Both are off by default [31]. The first is the CodeAct idea (2.6) used as a context saver. The second is progressive disclosure (2.11) applied to tools.

Context management: three mechanisms

The lecture lists three: conversion from an event log to model input, progressive disclosure through skill files, and compaction [34]. Figure 6 draws the first and the third from the docs.

A Pi-style session: an append-only tree, a compaction entry and a branch summary Top: a row of eleven log entries: header, user, assistant, tool, user, assistant, tool, tool, assistant, tool, and a compaction entry. The first four entries before the first-kept marker are summarized and not sent to the model. Entries from the first-kept marker onward are kept. A line below says what the model sees: the system prompt, the summary, then the kept entries. Bottom: a tree where a leaf with entries B, C, D is abandoned and the new branch with E and F gets a summary of B, C and D attached. A. Compaction does not delete: it adds one entry and changes what is sent hdr 0 usr 1 ass 2 tool 3 usr 4 ass 5 tool 6 tool 7 ass 8 tool 9 cmp 10 summarized, not sent first kept = entry 4: sent The model sees: system prompt, then the summary (entry 10), then entries 4 to 9. The log still has all 11 entries. Compaction can run again. A tool result stays with its call. B. Branches: go back, try another path, keep what you learned A B C D E F sum old leaf new leaf: a summary of B, C, D is attached Why summarize the branch you leave? The new path keeps the facts found on the old one (files read, errors, decisions) without the long tool output. The old branch stays in the log. Names are my own; the idea (an entry tree, a compaction entry that points at the first kept entry, and a summary made when you leave a branch) follows the Pi docs. Pi also tracks files read and modified in each summary.
Figure 6. A Pi-style session, drawn after the compaction docs [29] [30]. Top: compaction adds an entry that points to the first entry that is kept. Bottom: leaving a branch can attach a summary to the new one. The names in the boxes are my own labels. The drawing is mine and is simplified.
  1. Session as an event tree. Pi saves a session, and "stores entries as a tree, so returning to an earlier point does not erase the branch you leave" [30]. The model gets only the active branch, plus the system prompt, context files, tool definitions and the descriptions of skills [30]. This is the "store everything, send a view" idea (2.11), with branches added.
  2. Progressive disclosure. At start-up, Pi puts each skill's name, description and path into the system prompt, and not the full instructions. The model loads a skill when the task calls for it [32]. Zechner makes the same argument against MCP: big tool descriptions are paid for on every session. He prefers small command-line tools with a README that the agent reads only when it needs the tool [28].
  3. Compaction. It starts when contextTokens > contextWindow − reserveTokens, with a default reserve of 16,384 tokens. It walks back from the newest message until it has kept about keepRecentTokens (default 20,000), asks a model for a structured summary, and appends a compaction entry with the summary and a firstKeptEntryId. The next request is built from the summary and the entries from that id onward. Valid cut points are user and assistant messages and a few others. It never cuts at a tool result, since a result must stay with its call [29]. The summary has fixed headings: Goal, Constraints & Preferences, Progress, Key Decisions, Next Steps and, for compaction, Critical Context. It also lists the files read and modified, and carries these lists forward [29]. When the model that writes the summary reads the old turns, tool results are cut to 2,000 characters first, so the summary request stays small [29].

The docs add two details that tie back to 2.13. Summary requests disable prompt-cache writes, because such one-off prompts are unlikely to be reused [29]. And compaction leaves the log intact: it adds one entry. When you leave a branch, Pi can summarize the abandoned work and attach it to the branch you enter, so what you learned on the dead end is not lost [29] [30]. In the lab, compact and switch_branch follow the same shape, with a simpler summarizer and my own names. The lab is not Pi's code.

Safety and the "no" list

Pi runs with the permissions of its process and has no permission pop-ups. The lecture says to consider it "always on auto-mode with no protections" [34]. The post gives the reason: the three capabilities of reading private data, running code and reaching the network cannot all be locked down in a useful agent, so Pi does not pretend to [28]. The post also says: if you are not comfortable, run Pi in a container. That is a bet, and you should know what it means before you copy it. Anyone who runs Pi in a place with secrets and a network connection takes on the risk. Section 2.6 already said: run it in a container.

ChoiceReason given [28]What it costs, and my view
Four toolsModels know them. Under 1,000 tokens in all.Everything else goes through bash. Fine for a coder. Less fit for a product that needs a narrow, safe tool set.
No to-do list, no plan modeA to-do list is state the model must track and can get wrong. Write a PLAN.md file instead and read or edit it.The file is a form of memory offloading (2.11). You gain visibility. You lose a built-in guardrail.
No sub-agentsContext handed to a hidden sub-agent is poor and you cannot see it. Gather context in a separate session first. Spawn pi through bash when you need to.This is Cognition's side of the debate (2.14), by choice. It leaves the work of splitting to the user.
No background bashUse tmux. It is observable, and you can join the session.More manual steps.
No MCP (2025 post)Tool descriptions take a large share of the context on every session. The post gives two examples, at 13.7k and 18k tokens.Now partly reversed by an extension, with deferred tools (tool_search) as the fix for the cost [31].
YOLO by defaultThe post calls the safety checks of other agents mostly "security theater": once an agent can write and run code, data theft is hard to stop unless you cut the network.No safety net. The post's own advice is to run Pi in a container if you want guardrails.

None of these is a law of agent design. Each is a trade of a feature against context, observability or simplicity, and each has a counter-argument. The lesson of the case study is the habit: for each feature, ask what it adds to the context, who can see what it did, and what you would do without it.

2.16 Limits, and from patterns to a project

What this week does not settle

A checklist for your project

  1. Write down, per step, who owns the control flow. Start with the simplest design that could work [2].
  2. If you use a loop, add a step limit and a stuck detector. Log every step.
  3. If you add a reflection or a critic, name the grounded signal it uses at deploy time.
  4. If the model writes code, run it in a sandbox. Do not rely on a language-level guard.
  5. Build the prompt from a log. Never edit the log.
  6. Measure tokens and cost with caching per run, before you choose a context strategy. Plant a needle, and test that the agent can still use it.
  7. Add isolation for read-only exploration first. Add writing sub-agents last.
  8. Change one pattern at a time, and keep an evaluation that tells you if it helped (last week's pillar).

2.17 Week 4a in one page

3. Reading guide

Three readings are required by the course. I also give a full guide to the Reflexion and CodeAct papers, because the lecture's reasoning section stands on them. The rest are optional. For each required one you get: why it matters, the key ideas, and questions to think about as you read. Read the questions first. Two of the five are blog posts and not papers, so read them like experience reports: look for what was measured, and what was only said.

Required 1 ReAct: Synergizing Reasoning and Acting in Language Models

Yao, Zhao, Yu, Du, Shafran, Narasimhan, Cao. ICLR 2023. arXiv 2210.03629 · reference [1].

Why read it

It is the base of nearly every tool-using agent loop. Read the abstract and introduction, section 2 (the method), section 3 (HotpotQA and Fever, with Tables 1 and 2), and section 4 (ALFWorld and WebShop). Look at the example trajectories in the appendix.

Key ideas

Questions to think about

  1. In Table 1, ReAct is slightly behind chain-of-thought on HotpotQA and ahead on Fever. Use Table 2 to explain both results.
  2. The paper reports "repeats earlier thoughts and actions" as a failure. How would you detect that in a harness, and what should the harness do?
  3. The tasks used a few examples in the prompt. What changes if the model has native tool calling and a hidden reasoning step? What stays the same?
  4. Each turn re-sends the transcript. Using the formula in 2.10, how many input tokens does a 20-step run use if each step adds 500 tokens and the system prompt is 300?
  5. Which of the paper's results would you most want to see repeated with a current model?

Required 2 Building Effective AI Agents

Erik S. and Barry Zhang (the page gives "Erik S."; the course lists the authors as Schluntz & Zhang). Anthropic engineering blog, December 19, 2024. anthropic.com/engineering/building-effective-agents · reference [2]. The page carries a note that much of its tooling landscape has changed since it was written.

What to read

The whole post, about 25 minutes, including both appendices. Appendix 2 (on tools) is easy to skip and shouldn't be.

Key ideas

Questions to think about

  1. For each of the five patterns, write one sentence on what would tell you it is the wrong choice.
  2. The post says to look at the "ground truth" an agent gets at each step. What is that signal for a coding agent, a support agent and a research agent? Which of the three has the weakest signal?
  3. The post is from December 2024, and carries a note that tools have changed. Which advice do you think has aged best? Which has aged worst?
  4. Pick one "poka-yoke" idea from Appendix 2 and apply it to a tool of your own.
  5. The post is written by a model provider that sells agents. How does that change how you read its advice about when not to use them?

Required 3 Context Engineering

The LangChain Team. LangChain Blog, July 2, 2025. langchain.com/blog/context-engineering-for-agents · reference [3]. The page headline is "Context Engineering". Its address ends in "context-engineering-for-agents".

What to read

The whole post, about 15 minutes. The second half is about how LangGraph supports each strategy. Read it as a product guide, and focus on the first half.

Key ideas

Questions to think about

  1. Place each of the lecture's five patterns in one of the four buckets. Which one fits two buckets?
  2. For each of the four named failures, give a case from an agent you have used.
  3. The post says summarization is hard when specific events or decisions must be captured. Write a rule for what a coding-agent summary must always keep.
  4. Which claims in the post come with data, and which with a quote or an example? How does that change how much you rely on each?
  5. What would you measure before choosing a strategy, according to the post? Add one measure the post does not mention.

Required list 4 Reflexion: Language Agents with Verbal Reinforcement Learning

Shinn, Cassano, Berman, Gopinath, Narasimhan, Yao. NeurIPS 2023. arXiv 2303.11366 · reference [4]. Read it with Huang et al. [12].

What to read

Sections 1 to 3 (the method and memory), the AlfWorld part of section 4 (including the heuristic and the learning curve), and the limitations. Then read the abstract and the section on oracle labels of the Huang paper.

Key ideas

Questions to think about

  1. For each of the three task types in Reflexion (decision-making, reasoning, programming), what is the evaluator? Which of them is grounded, and in what?
  2. If you deploy a coding agent, what plays the role of the evaluator in production? Would that signal have been there in the paper's setup?
  3. Why bound the memory to 1 to 3 lessons? What would go wrong with 50?
  4. Does Huang et al. contradict Reflexion? Write two sentences that make both papers true at once.

Required list 5 Executable Code Actions Elicit Better LLM Agents

Wang, Chen, Yuan, Zhang, Li, Peng, Ji. ICML 2024. arXiv 2402.01030 · reference [5].

What to read

The abstract, the introduction (the list of benefits), sections 2.2 and 2.3 (the comparison with JSON and text actions, and the M3ToolEval benchmark), and the discussion of the fine-tuned agent.

Key ideas

Questions to think about

  1. Which of the paper's benefits apply when the task needs only one tool call? Which only appear with many?
  2. What can a code action do that a JSON action cannot, and what does it add to your security review?
  3. The lab's CodeAct turn 1 hits an error and recovers. What does the paper say about using error messages as observations?
  4. If you had to choose between CodeAct and ReWOO for a task with ten independent lookups, what would you ask first?

Optional readings

SourceWhy it is worth your time
Xu et al., ReWOO [6]; Kim et al., LLMCompiler [7]Plan first, run tools without the model, and in parallel. Read the limitations section of ReWOO for the vase example.
Yao et al., Tree of Thoughts [9]; Zhou et al., LATS [10]; Madaan et al., Self-Refine [11]; Wang et al., Plan-and-Solve [8]Search, tree search, refinement and planning, with the reported results in 2.7 and 2.8.
Huang et al., "Large Language Models Cannot Self-Correct Reasoning Yet" [12]; Gou et al., CRITIC [13]The pair that sets the rule: self-correction needs a grounded signal.
Lindenbauer et al., "The Complexity Trap" [14]Read sections 3 to 5 and the limitations. Closest to the lecture's masking slide.
Manus, "Context Engineering for AI Agents: Lessons from Building Manus" [15]A practitioner's list on the cache, tool masking, files as memory and recitation. Not an experiment.
Anthropic, "How we built our multi-agent research system" [16]; Cognition, "Don't build multi-agents" [17]The two sides of the isolation debate in 2.14.
Anthropic, "Effective context engineering for AI agents" [19]; Anthropic, Agent Skills [20]Context rot, compaction, tool-result clearing, note-taking and progressive disclosure.
Packer et al., MemGPT [21]; Liu et al., Lost in the Middle [22]; the OpenHands paper [23] and event docs [24]Memory paging, position effects, and an event-log design.
STORM [25] and Archon [26]The two compositions in the lecture.
Pi: README [27], Zechner's post [28], compaction, sessions, CLI and skills docs [29] [30] [31] [32]The case study. The post is the reasoning, the docs are the current behavior.
HumanLayer, 12-factor agents [18]A checklist of opinions on owning your context and control flow.

4. Hands-on lab: the patterns with mock models

Time: about 3 hours. Goal: build small, testable versions of the patterns of this week, see how each one behaves, and measure what the context strategies send. You do not need an API key or any package. Every "language model" is a mock: a scripted list of replies, or a few lines of Python that act like a model.

Read this before you trust the numbersEverything in the lab is made up: the questions, the facts ("Harbor Labs"), the prices, the 40-turn run and the needle fact. The mocks follow scripts and rules that I wrote, so they show how each pattern behaves and say nothing about how a real model does. Token counts use a rough proxy (words and punctuation marks, not a real tokenizer). The JSON-versus-CodeAct and ReAct-versus-ReWOO counts hold for those scripts only. The cost model in section 6 is the toy model from 2.13. CodeSession is not a security sandbox: use a container for any real model-written code. The summarizer is a simple extractive one that I wrote. The lab is not Pi's code and not any framework's code.

What you will learn

The four parts of the lab Four boxes: workflow patterns, reasoning and acting loops, context management, and the demo with tests. All use mock models. The meter wraps any model to count calls and tokens. 1. Workflows prompt_chain, route, sectioning, voting, orchestrate, refine 2. Loops react, reflexion, CodeSession, codeact, json_actions, rewoo 3. Context Session (event log), build_context, compact, switch_branch, isolate, simulate 4. Demo and tests 7 demo sections 155 tests mock models only no network, no key Mock models: ScriptedLM (canned replies), RuleLM (a small rule). Meter wraps any model and counts calls and tokens. Run python patterns_toolkit.py for the demo. Run python test_patterns_toolkit.py for the tests. All numbers in the demo come from made-up data. They show how each pattern behaves, not how a real model scores.
Figure 7. The four parts of the lab. Parts 1 to 3 are independent. The Meter wraps any mock model to count calls and tokens. The names are the real function and class names in the code. The test count is from the run that built this page.

Setup

  1. You need a recent Python 3. There is nothing to install. The code uses only the standard library. I ran it on Python 3.13 only.
  2. Save the code below as patterns_toolkit.py. Or download it: patterns_toolkit.py.
  3. Save the tests as test_patterns_toolkit.py in the same folder. Or download: test_patterns_toolkit.py. The test code is not printed on this page, to keep the page short. It is in the download, and the names of all the tests are listed after the exercises.
  4. Run python patterns_toolkit.py for the seven-section demo (or python patterns_toolkit.py demo 6 for one section), and python test_patterns_toolkit.py for the tests. The demo and the tests are deterministic. The tests take a few seconds.

The code: patterns_toolkit.py

The code has three parts, matching Figure 7, and a demo at the end. The comments name each choice that I made and the sources did not. The file is long. Read the part you are working on, with the matching section of the notes open.

#!/usr/bin/env python3
"""patterns_toolkit.py: agent design patterns with scripted mock models. Standard library only.

Part 1  Five workflow patterns: prompt_chain, route, sectioning, voting, orchestrate, refine
Part 2  Reasoning and acting loops: react, reflexion, codeact (and a JSON-action loop), rewoo
Part 3  Context management: Session (an append-only event log with branches), build_context,
        observation masking, compaction, branch summaries, isolation, and a cost simulation
Part 4  A demo with seven sections. Run: python patterns_toolkit.py

Every "model" here is a mock: a list of canned replies, or a small rule that I wrote. The mocks
show how the patterns work. They say nothing about how well a real model does. All facts, prices
and tasks are made up. "Tokens" are a rough proxy (words and punctuation marks).
Run the tests with: python test_patterns_toolkit.py
"""
from __future__ import annotations

import ast
import builtins
import contextlib
import io
import json
import operator
import re
import sys
import threading
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple


def count_tokens(text: str) -> int:
    """A rough token proxy: words plus punctuation marks. Real tokenizers differ; only ratios matter here."""
    return len(re.findall(r"\w+|[^\w\s]", text))


# ----------------------------------------------------------------------------- mock models and a meter
class ScriptExhausted(RuntimeError):
    """The scripted model was called more times than it has replies."""


class ScriptedLM:
    """Returns canned replies in order and records every prompt. Thread-safe."""

    def __init__(self, replies: Sequence[str]):
        self.replies = list(replies)
        self.prompts: List[str] = []
        self._lock = threading.Lock()

    def __call__(self, prompt: str) -> str:
        with self._lock:
            self.prompts.append(prompt)
            i = len(self.prompts) - 1
        if i >= len(self.replies):
            raise ScriptExhausted("script has %d replies, call number %d" % (len(self.replies), i + 1))
        return self.replies[i]


class RuleLM:
    """Wraps a function prompt -> reply, and records every prompt. Thread-safe."""

    def __init__(self, fn: Callable[[str], str]):
        self.fn = fn
        self.prompts: List[str] = []
        self._lock = threading.Lock()

    def __call__(self, prompt: str) -> str:
        with self._lock:
            self.prompts.append(prompt)
        return self.fn(prompt)


class Meter:
    """Counts model calls and tokens. meter.wrap(llm) returns a model that reports to the meter."""

    def __init__(self):
        self.calls = 0
        self.tokens_in = 0
        self.tokens_out = 0
        self._lock = threading.Lock()

    def wrap(self, llm: Callable[[str], str]) -> Callable[[str], str]:
        def metered(prompt: str) -> str:
            out = llm(prompt)
            with self._lock:
                self.calls += 1
                self.tokens_in += count_tokens(prompt)
                self.tokens_out += count_tokens(out)
            return out
        return metered


# ----------------------------------------------------------------------------- tiny tools (all made up)
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv,
        ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod, ast.Pow: operator.pow,
        ast.USub: operator.neg, ast.UAdd: operator.pos}


def safe_eval(expr: str) -> float:
    """Evaluate plain arithmetic with ast. No names, calls or attributes."""
    def ev(n):
        if isinstance(n, ast.Expression):
            return ev(n.body)
        if isinstance(n, ast.Constant) and type(n.value) in (int, float):
            return n.value
        if isinstance(n, ast.BinOp) and type(n.op) in _OPS:
            a, b = ev(n.left), ev(n.right)
            if isinstance(n.op, ast.Pow) and abs(b) > 100:
                raise ValueError("exponent too large")
            return _OPS[type(n.op)](a, b)
        if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS:
            return _OPS[type(n.op)](ev(n.operand))
        raise ValueError("unsupported expression")
    return ev(ast.parse(expr.strip(), mode="eval"))


def calc(arg: str) -> str:
    """Tool: arithmetic. Returns text, never raises."""
    try:
        v = safe_eval(arg)
    except (ValueError, SyntaxError, ZeroDivisionError, OverflowError) as e:
        return "error: %s" % e
    return str(int(v)) if float(v).is_integer() else str(round(v, 6))


# A made-up knowledge base: (keywords, answer). search() picks the entry that shares most words with the query.
KB: List[Tuple[set, str]] = [
    ({"who", "founded", "harbor", "labs"}, "Mira Okoye"),
    ({"year", "founded", "harbor", "labs"}, "2009"),
    ({"birth", "year", "mira", "okoye"}, "1971"),
    ({"who", "founded", "ridge", "corp"}, "Jonas Weld"),
    ({"year", "founded", "ridge", "corp"}, "2004"),
    ({"birth", "year", "jonas", "weld"}, "1964"),
]


def search(query: str) -> str:
    """Tool: look up a fact in the made-up KB. Needs at least 3 shared words, else 'no result'."""
    words = set(re.findall(r"[a-z0-9]+", query.lower()))
    best, score = "no result", 0
    for kw, ans in KB:
        s = len(kw & words)
        if s > score:
            best, score = ans, s
    return best if score >= 3 else "no result"


TOOLS: Dict[str, Callable[[str], str]] = {"search": search, "calc": calc}


# ============================================================================= PART 1: workflow patterns
# In a workflow the control flow lives in this code. The model fills in text at each step.

@dataclass
class ChainResult:
    outputs: List[str]
    ok: bool
    stopped_at: Optional[int] = None      # index of the step whose gate failed


def prompt_chain(llm, steps: Sequence[str], task: str,
                 gate: Optional[Callable[[int, str], bool]] = None) -> ChainResult:
    """Prompt chaining. Each step is a template with {input}. The output of one step is the input of the next.
    gate(i, output) is a programmatic check after step i. If it returns False the chain stops there."""
    outputs: List[str] = []
    current = task
    for i, template in enumerate(steps):
        current = llm(template.format(input=current)).strip()
        outputs.append(current)
        if gate is not None and not gate(i, current):
            return ChainResult(outputs, False, i)
    return ChainResult(outputs, True)


class RoutingError(ValueError):
    pass


CLASSIFY = "Classify the input into one of: {labels}. Reply with the label only.\nInput: {input}"


def route(llm, text: str, routes: Dict[str, Tuple[Optional[Callable], str]],
          default: Optional[str] = None) -> Tuple[str, str]:
    """Routing. routes maps label -> (model or None, template with {input}). The classifier is `llm`.
    A route may name a different, cheaper or stronger model. An unknown label goes to `default`, or raises."""
    labels = list(routes)
    reply = llm(CLASSIFY.format(labels=", ".join(labels), input=text)).strip().lower()
    label = next((l for l in labels if re.search(r"\b%s\b" % re.escape(l.lower()), reply)), None)
    if label is None:
        if default is None:
            raise RoutingError("classifier said %r; known labels: %s" % (reply, labels))
        label = default
    model, template = routes[label]
    return label, (model or llm)(template.format(input=text))


def sectioning(llm, text: str, templates: Sequence[str], max_workers: int = 4) -> List[str]:
    """Parallelization, variant 1: independent subtasks run at the same time. Results keep the template order."""
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        return list(pool.map(lambda t: llm(t.format(input=text)), templates))


@dataclass
class Vote:
    winner: str
    counts: Dict[str, int]
    agreement: float


def voting(llm, prompt: str, n: int, parse: Callable[[str], str] = str.strip) -> Vote:
    """Parallelization, variant 2: ask the same thing n times and take the most common answer.
    Ties go to the answer seen first. (Run the calls in parallel in production; here they are in order.)"""
    if n < 1:
        raise ValueError("n must be at least 1")
    answers = [parse(llm(prompt)) for _ in range(n)]
    counts = Counter(answers)
    best = max(counts.values())
    winner = next(a for a in answers if counts[a] == best)
    return Vote(winner, dict(counts), best / n)


class PlanError(ValueError):
    pass


PLAN_PROMPT = ("Break the task into at most {k} independent subtasks. "
               "Reply with a JSON list of strings only.\nTask: {task}")


def parse_subtasks(reply: str) -> List[str]:
    """Accept a bare JSON list or one inside a code fence. Reject anything else."""
    m = re.search(r"\[.*\]", reply, re.S)
    if not m:
        raise PlanError("no JSON list in reply")
    try:
        items = json.loads(m.group(0))
    except json.JSONDecodeError as e:
        raise PlanError("bad JSON: %s" % e)
    if not isinstance(items, list) or not items or not all(isinstance(s, str) and s.strip() for s in items):
        raise PlanError("expected a non-empty list of non-empty strings")
    return [s.strip() for s in items]


@dataclass
class OrchestrationResult:
    subtasks: List[str]
    results: List[Tuple[str, str]]
    final: str
    truncated: bool = False
    plan_attempts: int = 1


def orchestrate(llm, task: str, worker: Optional[Callable[[str], str]] = None, max_subtasks: int = 5,
                synth_template: str = "Combine these results into one answer for: {task}\n{results}") -> OrchestrationResult:
    """Orchestrator-workers. One call plans the subtasks (the plan is NOT fixed in code), workers run them,
    one call combines the results. A bad plan is retried once. More than max_subtasks are cut off."""
    worker = worker or (lambda s: llm("Do this subtask and reply briefly: " + s))
    attempts, plan = 0, None
    for attempt in (PLAN_PROMPT, PLAN_PROMPT + "\nYour last reply was not valid. Reply with the JSON list only."):
        attempts += 1
        try:
            plan = parse_subtasks(llm(attempt.format(k=max_subtasks, task=task)))
            break
        except PlanError as e:
            last = e
    if plan is None:
        raise PlanError("no valid plan after %d tries: %s" % (attempts, last))
    truncated = len(plan) > max_subtasks
    plan = plan[:max_subtasks]
    results = [(s, worker(s)) for s in plan]
    final = llm(synth_template.format(task=task, results="\n".join("- %s: %s" % r for r in results)))
    return OrchestrationResult(plan, results, final.strip(), truncated, attempts)


@dataclass
class Round:
    number: int
    draft: str
    ok: bool
    feedback: str


@dataclass
class RefineResult:
    final: str
    converged: bool
    rounds: List[Round]


def refine(generate: Callable[[str, List[str]], str], evaluate: Callable[[str], Tuple[bool, str]],
           task: str, max_rounds: int = 3) -> RefineResult:
    """Evaluator-optimizer. generate(task, feedback_so_far) -> draft. evaluate(draft) -> (ok, feedback).
    It works only if the feedback is clear and the generator can use it. Always bounded by max_rounds."""
    if max_rounds < 1:
        raise ValueError("max_rounds must be at least 1")
    feedback: List[str] = []
    rounds: List[Round] = []
    for r in range(1, max_rounds + 1):
        draft = generate(task, list(feedback))
        ok, fb = evaluate(draft)
        rounds.append(Round(r, draft, ok, fb))
        if ok:
            return RefineResult(draft, True, rounds)
        feedback.append(fb)
    return RefineResult(rounds[-1].draft, False, rounds)


# ============================================================================= PART 2: reasoning and acting
# In an agent the model chooses the next step. The loops below differ in WHAT the model writes each turn.

@dataclass
class Step:
    thought: str
    tool: str
    arg: str
    observation: str


@dataclass
class AgentResult:
    answer: Optional[str]
    status: str                  # finished, step_limit, stuck
    steps: List[Step]


REACT_SYSTEM = ("Answer the question. Each turn write:\nThought: your reasoning\nAction: tool[argument]\n"
                "Tools: {tools}. To end, write Action: finish[answer].\n")
_ACTION = re.compile(r"^Action:\s*([A-Za-z_]\w*)\[(.*)\]\s*$", re.M)


def parse_react(reply: str) -> Tuple[str, Optional[str], str]:
    """Return (thought, tool, argument). tool is None if there is no well-formed Action line.
    Only the first Action line counts: one action per turn."""
    m = _ACTION.search(reply)
    t = re.search(r"^Thought:\s*(.*?)\s*(?=^Action:|\Z)", reply, re.M | re.S)
    thought = t.group(1).strip() if t else ""
    return (thought, m.group(1), m.group(2).strip()) if m else (thought, None, "")


def react(llm, question: str, tools: Dict[str, Callable[[str], str]], max_steps: int = 8,
          stuck_after: int = 3, memory: Sequence[str] = ()) -> AgentResult:
    """ReAct: the model writes a Thought and one Action. The harness runs the action and appends the
    Observation. The WHOLE transcript is sent again on every turn. memory holds lessons from earlier trials.
    stuck_after: stop if the same action gets the same observation this many times (Reflexion's heuristic)."""
    header = REACT_SYSTEM.format(tools=", ".join(sorted(tools)))
    if memory:
        header += "Lessons from earlier trials:\n" + "\n".join("- " + m for m in memory) + "\n"
    transcript = "Question: %s\n" % question
    steps: List[Step] = []
    seen: Counter = Counter()
    for _ in range(max_steps):
        reply = llm(header + transcript)
        thought, name, arg = parse_react(reply)
        if name is None:
            obs = "format error: write a Thought, then one line: Action: tool[argument]"
            steps.append(Step(thought, "", "", obs))
            transcript += reply.strip() + "\nObservation: " + obs + "\n"
            continue
        if name == "finish":
            steps.append(Step(thought, name, arg, ""))
            return AgentResult(arg, "finished", steps)
        fn = tools.get(name)
        if fn is None:
            obs = "unknown tool %r. Tools: %s" % (name, ", ".join(sorted(tools)))
        else:
            try:
                obs = str(fn(arg))
            except Exception as e:                                  # a tool bug becomes an observation
                obs = "error: %s: %s" % (type(e).__name__, e)
        steps.append(Step(thought, name, arg, obs))
        transcript += "Thought: %s\nAction: %s[%s]\nObservation: %s\n" % (thought, name, arg, obs)
        seen[(name, arg, obs)] += 1
        if seen[(name, arg, obs)] >= stuck_after:
            return AgentResult(None, "stuck", steps)
    return AgentResult(None, "step_limit", steps)


@dataclass
class Trial:
    number: int
    answer: Optional[str]
    ok: bool
    feedback: str
    memory_used: List[str]


@dataclass
class ReflexionResult:
    success: bool
    trials: List[Trial]
    memory: List[str]


def reflexion(run_trial: Callable[[List[str]], AgentResult],
              evaluate: Callable[[AgentResult], Tuple[bool, str]],
              reflect: Callable[[AgentResult, str], str],
              max_trials: int = 3, memory_size: int = 3) -> ReflexionResult:
    """Reflexion. After a failed trial, `reflect` writes a short lesson from the trajectory and the feedback.
    The lessons go into the next trial's prompt. Memory is a sliding window (the paper keeps 1 to 3).
    `evaluate` must be GROUNDED: tests, an environment signal, a checker. A model grading itself is weaker."""
    memory: List[str] = []
    trials: List[Trial] = []
    for n in range(1, max_trials + 1):
        used = list(memory)
        result = run_trial(used)
        ok, feedback = evaluate(result)
        trials.append(Trial(n, result.answer, ok, feedback, used))
        if ok:
            return ReflexionResult(True, trials, memory)
        memory.append(reflect(result, feedback))
        memory = memory[-memory_size:]
    return ReflexionResult(False, trials, memory)


# ----------------------------------------------------------------------------- CodeAct: actions are code
class StepLimit(Exception):
    """The code ran more lines than allowed."""


_SAFE_NAMES = ("abs all any bool dict enumerate filter float int isinstance len list map max min print range "
               "reversed round set sorted str sum tuple zip Exception ValueError KeyError TypeError "
               "IndexError ZeroDivisionError").split()
SAFE_BUILTINS = {n: getattr(builtins, n) for n in _SAFE_NAMES}


@dataclass
class Obs:
    ok: bool
    text: str


class CodeSession:
    """A persistent Python namespace. Variables survive between turns. Tools are plain functions in it.
    NOT A SECURITY SANDBOX: it only removes imports and file access from the builtins and stops runaway
    loops. Real systems run code in a container or a VM."""

    def __init__(self, tools: Optional[Dict[str, Callable]] = None, max_lines: int = 5000, max_output: int = 400):
        self.ns: Dict[str, Any] = {"__builtins__": SAFE_BUILTINS}
        self.ns.update(tools or {})
        self.max_lines, self.max_output = max_lines, max_output

    def run(self, code: str) -> Obs:
        try:
            compiled = compile(code, "<codeact>", "exec")
        except SyntaxError as e:
            return Obs(False, "SyntaxError: %s (line %s)" % (e.msg, e.lineno))
        count = [0]

        def tracer(frame, event, arg):
            if frame.f_code.co_filename != "<codeact>":
                return None

            def local(frame, event, arg):
                if event == "line":
                    count[0] += 1
                    if count[0] > self.max_lines:
                        raise StepLimit("more than %d lines executed" % self.max_lines)
                return local
            return local

        out, err, old = io.StringIO(), "", sys.gettrace()
        try:
            sys.settrace(tracer)
            with contextlib.redirect_stdout(out):
                exec(compiled, self.ns)
        except Exception as e:
            err = "%s: %s" % (type(e).__name__, e)
        finally:
            sys.settrace(old)
        text = out.getvalue()
        if len(text) > self.max_output:
            text = text[: self.max_output] + "\n[truncated %d characters]" % (len(text) - self.max_output)
        return Obs(not err, (text + err).strip())


CODEACT_SYSTEM = ("Solve the task. Each turn reply with ONE ```python block to run, or 'FINAL: answer' when done. "
                  "Tools are Python functions: {tools}. Variables stay between turns.\n")
_CODE = re.compile(r"```python\n(.*?)```", re.S)
_FINAL = re.compile(r"^FINAL:\s*(.*)$", re.M)


@dataclass
class CodeTurn:
    reply: str
    obs: Optional[Obs]


@dataclass
class CodeActResult:
    answer: Optional[str]
    status: str                  # finished, turn_limit
    turns: List[CodeTurn]


def codeact(llm, task: str, tools: Dict[str, Callable], max_turns: int = 6, max_lines: int = 5000) -> CodeActResult:
    """CodeAct: the action is a block of code. One block can loop, branch and keep results in variables."""
    session = CodeSession(tools, max_lines)
    header = CODEACT_SYSTEM.format(tools=", ".join(sorted(tools)))
    transcript = "Task: %s\n" % task
    turns: List[CodeTurn] = []
    for _ in range(max_turns):
        reply = llm(header + transcript)
        code, final = _CODE.search(reply), _FINAL.search(reply)
        if code is None and final:
            turns.append(CodeTurn(reply, None))
            return CodeActResult(final.group(1).strip(), "finished", turns)
        if code is None:
            obs = Obs(False, "format error: reply with one ```python block or FINAL: answer")
        else:
            obs = session.run(code.group(1))
        turns.append(CodeTurn(reply, obs))
        transcript += reply.strip() + "\nObservation: " + obs.text + "\n"
    return CodeActResult(None, "turn_limit", turns)


JSON_SYSTEM = ('Solve the task. Each turn reply with ONE JSON object: {{"tool": name, "args": {{...}}}} '
               'or {{"final": answer}}. Tools: {tools}.\n')


def json_actions(llm, task: str, tools: Dict[str, Callable], max_turns: int = 12) -> CodeActResult:
    """The usual tool-calling loop: one JSON call per turn. Same result type as codeact, to compare turns."""
    header = JSON_SYSTEM.format(tools=", ".join(sorted(tools)))
    transcript = "Task: %s\n" % task
    turns: List[CodeTurn] = []
    for _ in range(max_turns):
        reply = llm(header + transcript)
        try:
            call = json.loads(reply)
            if "final" in call:
                turns.append(CodeTurn(reply, None))
                return CodeActResult(str(call["final"]), "finished", turns)
            obs = Obs(True, str(tools[call["tool"]](**call.get("args", {}))))
        except Exception as e:
            obs = Obs(False, "%s: %s" % (type(e).__name__, e))
        turns.append(CodeTurn(reply, obs))
        transcript += reply.strip() + "\nObservation: " + obs.text + "\n"
    return CodeActResult(None, "turn_limit", turns)


# ----------------------------------------------------------------------------- ReWOO and LLMCompiler: plan first
@dataclass
class PlanStep:
    id: str                      # "E1"
    tool: str
    arg: str                     # may contain #E1 style references


class PlanParseError(ValueError):
    pass


_PLAN_LINE = re.compile(r"^#(E\d+)\s*=\s*([A-Za-z_]\w*)\[(.*)\]\s*$")
_REF = re.compile(r"#(E\d+)")


def parse_plan(text: str, tools: Sequence[str]) -> List[PlanStep]:
    """Lines like '#E2 = search[birth year of #E1]'. Other lines (a 'Plan:' sentence) are ignored.
    Checks: known tool, unique ids, and a reference may only point to an EARLIER step (so no cycles)."""
    steps: List[PlanStep] = []
    for line in text.splitlines():
        m = _PLAN_LINE.match(line.strip())
        if not m:
            continue
        sid, tool, arg = m.group(1), m.group(2), m.group(3).strip()
        if tool not in tools:
            raise PlanParseError("step %s uses unknown tool %r" % (sid, tool))
        if any(s.id == sid for s in steps):
            raise PlanParseError("duplicate step id %s" % sid)
        for ref in _REF.findall(arg):
            if ref not in {s.id for s in steps}:
                raise PlanParseError("step %s refers to %s, which is not an earlier step" % (sid, ref))
        steps.append(PlanStep(sid, tool, arg))
    if not steps:
        raise PlanParseError("no steps found")
    return steps


def waves(plan: Sequence[PlanStep]) -> List[List[PlanStep]]:
    """Group steps into levels. All steps in one level are independent and can run together
    (the LLMCompiler idea). Level = 1 + the deepest level among the steps it refers to."""
    level: Dict[str, int] = {}
    for s in plan:
        refs = _REF.findall(s.arg)
        level[s.id] = 1 + max((level[r] for r in refs), default=0)
    out: List[List[PlanStep]] = [[] for _ in range(max(level.values()))]
    for s in plan:
        out[level[s.id] - 1].append(s)
    return out


def execute_plan(plan: Sequence[PlanStep], tools: Dict[str, Callable[[str], str]],
                 parallel: bool = False) -> Dict[str, str]:
    """Run the plan with NO model calls. #E refs are replaced by earlier results. A failing tool
    gives an 'error:' string as evidence, so the solver can still answer or say what failed."""
    evidence: Dict[str, str] = {}

    def run_one(s: PlanStep) -> Tuple[str, str]:
        arg = _REF.sub(lambda m: evidence[m.group(1)], s.arg)
        try:
            return s.id, str(tools[s.tool](arg))
        except Exception as e:
            return s.id, "error: %s: %s" % (type(e).__name__, e)

    for level in waves(plan):
        if parallel and len(level) > 1:
            with ThreadPoolExecutor(max_workers=len(level)) as pool:
                results = list(pool.map(run_one, level))
        else:
            results = [run_one(s) for s in level]
        evidence.update(results)
    return evidence


REWOO_PLAN = ("Write a plan. One line per step: #E1 = tool[argument]. A later step may use #E1 in its argument.\n"
              "Tools: {tools}\nQuestion: {question}\n")
REWOO_SOLVE = "Use the plan and the evidence to answer.\nQuestion: {question}\n{plan}\nEvidence:\n{evidence}\nAnswer:"


@dataclass
class RewooResult:
    answer: str
    plan: List[PlanStep]
    evidence: Dict[str, str]
    waves: int


def rewoo(planner, solver, question: str, tools: Dict[str, Callable[[str], str]], parallel: bool = False) -> RewooResult:
    """ReWOO: Planner (one call) -> Worker (tools, no model) -> Solver (one call).
    Two model calls in total, however many tools run. The price: the plan is made before any observation."""
    plan_text = planner(REWOO_PLAN.format(tools=", ".join(sorted(tools)), question=question))
    plan = parse_plan(plan_text, list(tools))
    evidence = execute_plan(plan, tools, parallel=parallel)
    ev_text = "\n".join("#%s: %s" % (k, v) for k, v in evidence.items())
    plan_view = "\n".join("#%s = %s[%s]" % (s.id, s.tool, s.arg) for s in plan)
    answer = solver(REWOO_SOLVE.format(question=question, plan=plan_view, evidence=ev_text)).strip()
    return RewooResult(answer, plan, evidence, len(waves(plan)))


# ============================================================================= PART 3: context management
# One rule drives this part: keep the full record, and build each model input from it.

@dataclass(frozen=True)
class Event:
    id: int
    parent: Optional[int]
    kind: str                    # system, user, assistant, observation, compaction, branch_summary
    text: str
    first_kept: Optional[int] = None     # compaction only: the first event that stays in the context


_ROLE = {"system": "system", "user": "user", "assistant": "assistant", "observation": "tool",
         "compaction": "user", "branch_summary": "user"}


class Session:
    """An append-only event log with parent links. A linear chat is a chain. A branch is a second child of
    some event. `leaf` is the current position. Nothing is ever edited or removed, so the full record
    stays available for audit, replay and recall, whatever the model is shown."""

    def __init__(self):
        self.events: List[Event] = []
        self.leaf: Optional[int] = None
        self.observers: List[Callable[[Event], None]] = []

    def append(self, kind: str, text: str, parent: Optional[int] = -1, first_kept: Optional[int] = None) -> Event:
        if kind not in _ROLE:
            raise ValueError("unknown event kind %r" % kind)
        p = self.leaf if parent == -1 else parent
        if p is not None and not (0 <= p < len(self.events)):
            raise ValueError("no such parent %r" % p)
        e = Event(len(self.events), p, kind, text, first_kept)
        self.events.append(e)
        self.leaf = e.id
        for fn in self.observers:                      # hooks: monitors, loggers
            fn(e)
        return e

    def get(self, event_id: int) -> Event:
        return self.events[event_id]

    def path(self, leaf: Optional[int] = None) -> List[Event]:
        """Events from the root to `leaf` (default: the current leaf)."""
        cur = self.leaf if leaf is None else leaf
        out: List[Event] = []
        while cur is not None:
            e = self.events[cur]
            out.append(e)
            cur = e.parent
        return out[::-1]

    def to_jsonl(self) -> str:
        return "\n".join(json.dumps(e.__dict__, sort_keys=True) for e in self.events)

    @classmethod
    def from_jsonl(cls, text: str) -> "Session":
        s = cls()
        for line in text.splitlines():
            if line.strip():
                d = json.loads(line)
                if d["id"] != len(s.events):
                    raise ValueError("event ids must be 0, 1, 2, ... in order")
                s.events.append(Event(**d))
                s.leaf = d["id"]
        return s


def recall(session: Session, event_id: int) -> str:
    """Get the full text of any event, even one that was masked or compacted. This is what makes
    masking 'restorable': the record is still there, only the model's view of it is shorter."""
    return session.get(event_id).text


def _body(session: Session, leaf: Optional[int]) -> List[Event]:
    """The events the model would see before masking: system events, the latest compaction summary,
    the events from first_kept onward, and anything after the summary."""
    path = session.path(leaf)
    cidx = max((i for i, e in enumerate(path) if e.kind == "compaction"), default=None)
    if cidx is None:
        return path
    c = path[cidx]
    ids = [e.id for e in path]
    start = ids.index(c.first_kept) if c.first_kept in ids else cidx + 1
    head = [e for e in path[:cidx] if e.kind == "system"]
    kept = [e for e in path[start:cidx] if e.kind != "compaction"]
    return head + [c] + kept + path[cidx + 1:]


@dataclass(frozen=True)
class Message:
    role: str
    text: str
    src: int                     # id of the event this message came from


def mask_text(e: Event) -> str:
    return "[output omitted: %d tokens; recall id %d]" % (count_tokens(e.text), e.id)


def build_context(session: Session, leaf: Optional[int] = None, mask_window: Optional[int] = None,
                  mask_stride: int = 1) -> List[Message]:
    """Build the model input from the log. mask_window=M keeps the full text of the M most recent
    observations and replaces older ones with a short placeholder. Thoughts and actions are never touched.
    mask_stride=S makes the boundary move in steps of S observations, so the start of the masked region
    changes only every S turns and a prompt cache (see the simulation) stays valid in between.
    A placeholder is used only if it is shorter than the text it replaces."""
    if mask_stride < 1:
        raise ValueError("mask_stride must be at least 1")
    body = _body(session, leaf)
    obs_ids = [e.id for e in body if e.kind == "observation"]
    if mask_window is None:
        keep_full = set(obs_ids)
    else:
        n_old = max(0, len(obs_ids) - mask_window) // mask_stride * mask_stride if mask_window > 0 else len(obs_ids)
        keep_full = set(obs_ids[n_old:])
    out = []
    for e in body:
        text = e.text
        if e.kind == "observation" and e.id not in keep_full:
            ph = mask_text(e)
            if count_tokens(ph) < count_tokens(text):
                text = ph
        out.append(Message(_ROLE[e.kind], text, e.id))
    return out


def context_tokens(msgs: Sequence[Message]) -> int:
    return sum(count_tokens(m.text) for m in msgs)


def context_contains(msgs: Sequence[Message], needle: str) -> bool:
    return any(needle in m.text for m in msgs)


# ----------------------------------------------------------------------------- compaction
def extractive_summary(msgs: Sequence[Message], max_tokens: int = 150, keep_errors: bool = True) -> str:
    """A MOCK summarizer: it copies, it does not understand. A real one would be a model call.
    Format follows the idea of a structured summary: Goal, Progress, Files, Critical context."""
    goal = next((m.text for m in msgs if m.role == "user" and not m.text.startswith("## ")), "")
    goal = " ".join(goal.split()[:25])
    actions = [m.text.strip().splitlines()[0][:60] for m in msgs if m.role == "assistant"]
    read, modified = [], []
    for m in msgs:
        if m.role != "assistant":
            continue
        for f in re.findall(r"[\w./-]+\.\w{1,4}\b", m.text):
            target = modified if (">" in m.text or "sed -i" in m.text) else read
            if f not in target:
                target.append(f)
    errors = [ln.strip() for m in msgs if m.role == "tool" for ln in m.text.splitlines() if "error" in ln.lower()]
    prior = [m.text for m in msgs if m.text.startswith("## Goal")]
    for p in prior:                                   # carry files and errors across repeated compactions
        for ln in p.splitlines():
            if ln.startswith("read: "):
                read[:0] = [f for f in ln[6:].split(", ") if f and f not in read]
            elif ln.startswith("modified: "):
                modified[:0] = [f for f in ln[10:].split(", ") if f and f not in modified]
            elif ln.startswith("- ") and "error" in ln.lower():
                errors.append(ln[2:])
    for n in range(len(actions), -1, -1):
        shown = actions[len(actions) - n:] if n else []
        parts = ["## Goal\n" + goal, "## Progress\n" + "\n".join("- " + a for a in shown),
                 "## Files\nread: %s\nmodified: %s" % (", ".join(read[:6]), ", ".join(modified[:6]))]
        if keep_errors and errors:
            parts.append("## Critical context\n" + "\n".join("- " + e[:80] for e in errors[:2]))
        if prior:
            parts.insert(1, "## Earlier summary\n" + " ".join(prior[-1].split()[:20]) + " ...")
        text = "\n".join(parts)
        if count_tokens(text) <= max_tokens or n == 0:
            break
    return text


def should_compact(used_tokens: int, window: int, reserve: int) -> bool:
    """Compact when used > window - reserve. The reserve leaves room for the next reply and the summary."""
    return used_tokens > window - reserve


def compact(session: Session, summarizer: Callable[[Sequence[Message]], str] = extractive_summary,
            keep_recent_tokens: int = 800) -> Optional[Event]:
    """Summarize the old part of the current context and append a compaction event.
    Walk backwards from the newest event until keep_recent_tokens are covered. The cut point must be a
    user or assistant event, never an observation, so a tool result stays with the call that made it.
    Returns None if there is nothing old enough to summarize."""
    body = _body(session, None)
    head = [i for i, e in enumerate(body) if e.kind == "system"]
    first = (head[-1] + 1) if head else 0
    acc, cut = 0, None
    for i in range(len(body) - 1, first - 1, -1):
        acc += count_tokens(body[i].text)
        if acc >= keep_recent_tokens:
            cut = i
            break
    if cut is None:
        return None
    while cut < len(body) and body[cut].kind == "observation":      # move forward to a valid boundary
        cut += 1
    if cut >= len(body) or cut <= first:
        return None
    old = [Message(_ROLE[e.kind], e.text, e.id) for e in body[first:cut]]
    summary = summarizer(old)
    return session.append("compaction", summary, first_kept=body[cut].id)


def switch_branch(session: Session, new_leaf: int, summarizer: Callable[[Sequence[Message]], str] = extractive_summary) -> Event:
    """Move to another branch. The work left behind is summarized and attached to the new branch,
    so what was learned there is not lost. The old branch stays in the log."""
    old = session.path(session.leaf)
    new = session.path(new_leaf)
    common = 0
    while common < min(len(old), len(new)) and old[common].id == new[common].id:
        common += 1
    abandoned = [Message(_ROLE[e.kind], e.text, e.id) for e in old[common:]]
    session.leaf = new_leaf
    return session.append("branch_summary", summarizer(abandoned) if abandoned else "(nothing left behind)")


# ----------------------------------------------------------------------------- isolation
@dataclass
class IsolationResult:
    report: str
    child: Session
    child_tokens: int            # all text in the child's log
    parent_added_tokens: int     # what the parent gained: the call plus the report


def isolate(parent: Session, task: str, worker: Callable[[Session], str]) -> IsolationResult:
    """Context isolation. The worker runs in its OWN session. The parent log only gets the delegation
    and the report. The child's long exploration never enters the parent's context."""
    child = Session()
    child.append("system", "You are a worker. Do one task. Reply with a short report.")
    child.append("user", task)
    report = worker(child)
    call = parent.append("assistant", "delegate[%s]" % task)
    obs = parent.append("observation", report)
    return IsolationResult(report, child, sum(count_tokens(e.text) for e in child.events),
                           count_tokens(call.text) + count_tokens(obs.text))


# ----------------------------------------------------------------------------- a cost simulation
@dataclass
class Strategy:
    name: str
    mask_window: Optional[int] = None
    mask_stride: int = 1
    compact: bool = False
    isolate_first: int = 0       # run this many first turns in a sub-session


@dataclass
class SimResult:
    name: str
    calls: int
    input_tokens: int            # summed over every model call, parent and child
    peak_context: int
    final_context: int
    cached_tokens: int           # tokens that were an exact prefix of the previous call's input
    cost: float
    needle_in_context: bool
    compactions: int
    per_call: List[int] = field(default_factory=list)     # input tokens of each parent call, for plotting


PRICE_IN, PRICE_CACHED = 3.0, 0.30          # made-up dollars per million tokens (10 times cheaper when cached)
NEEDLE = "the release key is K-4471"


def _filler(n: int, tag: str) -> str:
    return " ".join("%s%d" % (tag, i) for i in range(n))


def make_trajectory(turns: int = 40, system_tokens: int = 200) -> Tuple[str, List[Tuple[str, str]]]:
    """A made-up coding-agent run: (assistant action, observation) pairs. Observation sizes cycle through
    a fixed list, so the run is the same every time. Turn 3 holds the needle fact."""
    sizes = [60, 900, 150, 1400, 80, 500, 300, 1100, 40, 700]
    traj = []
    for t in range(turns):
        action = "run: cat src/mod%d.py" % t if t % 3 else "run: pytest tests/test_%d.py" % t
        n = sizes[t % len(sizes)]
        obs = _filler(n, "t%d_" % t)
        if t == 3:
            obs = "NOTE: " + NEEDLE + ". " + obs
        if t == 4:
            obs = "Error: test_parse failed at line 12. " + obs
        traj.append((action, obs))
    return _filler(system_tokens, "sys"), traj


def _simulate_turns(sess: Session, traj, strat: Strategy, window: int, reserve: int, keep_recent: int):
    inputs, prev, cached, peak, compactions, calls, series = 0, [], 0, 0, 0, 0, []
    for action, obs in traj:
        ctx = build_context(sess, mask_window=strat.mask_window, mask_stride=strat.mask_stride)
        used = context_tokens(ctx)
        inputs += used
        series.append(used)
        calls += 1
        peak = max(peak, used)
        key = [(m.src, m.text) for m in ctx]
        n = 0
        while n < min(len(prev), len(key)) and prev[n] == key[n]:
            n += 1
        cached += sum(count_tokens(m.text) for m in ctx[:n])
        prev = key
        sess.append("assistant", action)
        sess.append("observation", obs)
        if strat.compact and should_compact(context_tokens(build_context(sess, mask_window=strat.mask_window, mask_stride=strat.mask_stride)), window, reserve):
            if compact(sess, keep_recent_tokens=keep_recent):
                compactions += 1
    return inputs, calls, cached, peak, compactions, series


def simulate(strat: Strategy, turns: int = 40, window: int = 4000, reserve: int = 1000, keep_recent: int = 900,
             report_tokens: int = 60) -> SimResult:
    """Run the made-up trajectory under one strategy and measure what the model would be sent."""
    system, traj = make_trajectory(turns)
    parent = Session()
    parent.append("system", system)
    parent.append("user", "Fix the failing parser test in the repository.")
    child_inputs = child_calls = 0
    if strat.isolate_first:
        first, traj = traj[: strat.isolate_first], traj[strat.isolate_first:]

        def worker(child: Session) -> str:
            nonlocal child_inputs, child_calls
            i, c, _, _, _, _ = _simulate_turns(child, first, Strategy("child"), window, reserve, keep_recent)
            child_inputs, child_calls = i, c
            return extractive_summary([Message(_ROLE[e.kind], e.text, e.id) for e in child.events], report_tokens)
        isolate(parent, "Explore the repository and report where the parser lives.", worker)
    inputs, calls, cached, peak, comps, series = _simulate_turns(parent, traj, strat, window, reserve, keep_recent)
    final = build_context(parent, mask_window=strat.mask_window, mask_stride=strat.mask_stride)
    total_in = inputs + child_inputs
    cost = ((total_in - cached) * PRICE_IN + cached * PRICE_CACHED) / 1e6
    return SimResult(strat.name, calls + child_calls, total_in, peak, context_tokens(final), cached, cost,
                     context_contains(final, NEEDLE), comps, series)


# ============================================================================= PART 4: the demo
QUESTION = "How old was the founder of Harbor Labs when Harbor Labs was founded?"


def age_policy(company: str, buggy: bool = False) -> RuleLM:
    """A scripted ReAct policy for the founder-age question. With buggy=True it subtracts in the wrong
    order, UNLESS a lesson that mentions 'order' is in the prompt header. A mock of an actor that can learn."""
    def fn(prompt: str) -> str:
        obs = re.findall(r"^Observation: (.*)$", prompt, re.M)
        fixed = "order" in prompt.split("Question:")[0]
        n = len(obs)
        if n == 0:
            return "Thought: I need the founder of %s.\nAction: search[who founded %s]" % (company, company)
        if n == 1:
            return "Thought: Now I need %s's birth year.\nAction: search[birth year of %s]" % (obs[0], obs[0])
        if n == 2:
            return "Thought: Now the year %s was founded.\nAction: search[year %s was founded]" % (company, company)
        if n == 3:
            a, b = obs[2], obs[1]
            if buggy and not fixed:
                a, b = b, a
            return "Thought: Subtract.\nAction: calc[%s - %s]" % (a, b)
        return "Thought: I have the answer.\nAction: finish[%s]" % obs[3]
    return RuleLM(fn)


def phone_tools() -> Dict[str, Callable]:
    """Made-up prices and exchange rates. India comes out cheapest by a small margin."""
    price = {"USA": 799.0, "Japan": 118000.0, "Germany": 749.0, "India": 66000.0}
    rate = {"USA": 1.0, "Japan": 0.0068, "Germany": 1.08, "India": 0.012}

    def get_price(model: str, country: str) -> float:
        if country not in price:
            raise ValueError("unknown country %r" % country)
        return price[country]

    def get_rate(country: str) -> float:
        if country not in rate:
            raise ValueError("unknown country %r" % country)
        return rate[country]
    return {"get_price": get_price, "get_rate": get_rate}


COUNTRIES = ["USA", "Japan", "Germany", "India"]
PHONE_TASK = "Which country is cheapest for the phone X-1, in USD? Countries: USA, Japan, Germany, India."


def json_phone_policy(prompt: str) -> str:
    obs = re.findall(r"^Observation: (.*)$", prompt, re.M)
    n = len(obs)
    if n < 8:
        c = COUNTRIES[n // 2]
        call = ({"tool": "get_price", "args": {"model": "X-1", "country": c}} if n % 2 == 0
                else {"tool": "get_rate", "args": {"country": c}})
        return json.dumps(call)
    usd = {c: float(obs[2 * i]) * float(obs[2 * i + 1]) for i, c in enumerate(COUNTRIES)}
    return json.dumps({"final": min(usd, key=usd.get)})


_BUGGY = '''Run it in one go.
```python
prices = {}
for c in ["USA", "Japan", "Germany", "India"]:
    prices[c] = get_price("X-1", c)
usd = {}
for c in ["USA", "Japan", "Deutschland", "India"]:
    usd[c] = prices.get(c, 0) * get_rate(c)
print(min(usd, key=usd.get))
```'''
_FIXED = '''The name was wrong. The prices are still in memory.
```python
usd = {c: prices[c] * get_rate(c) for c in prices}
print(min(usd, key=usd.get))
print(usd)
```'''


def codeact_phone_policy(prompt: str) -> str:
    n = len(re.findall(r"^Observation:", prompt, re.M))
    if n == 0:
        return _BUGGY
    if n == 1:
        return _FIXED
    last = prompt.rsplit("Observation: ", 1)[-1].strip()
    return "FINAL: " + last.splitlines()[0]


def _hdr(n: int, title: str) -> None:
    print("\n== %d. %s ==" % (n, title))


def demo_workflows() -> None:
    _hdr(1, "Five workflow patterns (scripted mocks; the code decides the control flow)")
    r = prompt_chain(ScriptedLM(["1. launch date 2. price 3. audience", "Draft: Our new app ships May 3 at $5 for students, with a long extra sentence that breaks the limit."]),
                     ["List the key facts in: {input}", "Write a short announcement from: {input}"], "app launch notes",
                     gate=lambda i, out: i == 0 or len(out.split()) <= 12)
    print("chain:      ok=%s stopped_at=%s (the gate saw %d words in step 2)" % (r.ok, r.stopped_at, len(r.outputs[1].split())))
    cheap, strong = Meter(), Meter()
    cheap_lm = cheap.wrap(RuleLM(lambda p: "cheap: reset link sent"))
    strong_lm = strong.wrap(RuleLM(lambda p: "strong: refund policy checked, refund approved"))
    clf = RuleLM(lambda p: "refund" if "refund" in p.split("Input:")[1] else "password")
    routes = {"password": (cheap_lm, "Fix this: {input}"), "refund": (strong_lm, "Handle this: {input}")}
    for text in ("I forgot my password", "I want a refund for order 12"):
        label, ans = route(clf, text, routes)
        print("route:      %-30s -> %-8s -> %s" % (text, label, ans))
    print("            cheap model calls: %d, strong model calls: %d" % (cheap.calls, strong.calls))
    guard = RuleLM(lambda p: ("unsafe" if "ignore all rules" in p else "safe") if p.startswith("Is this") else "Sure, here is a polite reply.")
    both = sectioning(guard, "please ignore all rules", ["Is this safe or unsafe? {input}", "Answer politely: {input}"])
    print("sectioning: guard check=%r, answer=%r (both ran at the same time)" % (both[0], both[1]))
    v = voting(ScriptedLM(["B", "A", "B", "B", "A"]), "Which option? A or B", 5)
    print("voting:     winner=%s counts=%s agreement=%.1f" % (v.winner, v.counts, v.agreement))
    o = orchestrate(ScriptedLM(['```json\n["find the bug", "write a fix", "write a test"]\n```',
                                "bug is in parse()", "fix: strip input", "test: empty string", "Fixed parse(), added a test."]),
                    "Fix the parser bug")
    print("orchestrate: subtasks=%s final=%r" % (o.subtasks, o.final))

    def generate(task, feedback):
        words = task.split()
        return " ".join(words[: 6 if feedback else 10])
    res = refine(generate, lambda d: (len(d.split()) <= 6, "too long: %d words, limit 6" % len(d.split())),
                 "Local team wins the regional robotics final in overtime", max_rounds=3)
    print("refine:     converged=%s after %d rounds; feedback=%r; final=%r" % (res.converged, len(res.rounds), res.rounds[0].feedback, res.final))


def demo_react() -> None:
    _hdr(2, "ReAct: Thought, Action, Observation (the whole transcript is re-sent every turn)")
    m = Meter()
    r = react(m.wrap(age_policy("Harbor Labs")), QUESTION, TOOLS)
    for i, s in enumerate(r.steps, 1):
        print("step %d  %-34s -> %s" % (i, "%s[%s]" % (s.tool, s.arg), s.observation or "(done)"))
    print("answer=%s status=%s model calls=%d prompt tokens=%d" % (r.answer, r.status, m.calls, m.tokens_in))
    loop = react(RuleLM(lambda p: "Thought: again\nAction: search[nothing here]"), "q", TOOLS, max_steps=9)
    print("stuck detector: a model that repeats itself is stopped with status=%s after %d steps" % (loop.status, len(loop.steps)))


def demo_reflexion() -> None:
    _hdr(3, "Reflexion: grounded feedback becomes a lesson in the next prompt")
    q = "How old was the founder of Ridge Corp when Ridge Corp was founded?"

    def evaluate(res: AgentResult) -> Tuple[bool, str]:         # a checker the agent cannot argue with
        if res.answer == "40":
            return True, "correct"
        return False, "Wrong: the answer %s is negative; an age cannot be." % res.answer if (res.answer or "").startswith("-") else "Wrong."

    def reflect(res: AgentResult, fb: str) -> str:               # mock of a model writing a lesson
        return ("My last calc was %s[%s]. %s I subtracted in the wrong order. Do founded year minus birth year."
                % (res.steps[-2].tool, res.steps[-2].arg, fb)) if "negative" in fb else "Try again."
    out = reflexion(lambda mem: react(age_policy("Ridge Corp", buggy=True), q, TOOLS, memory=mem), evaluate, reflect)
    for t in out.trials:
        print("trial %d: answer=%-4s ok=%-5s feedback=%r lessons in prompt=%d" % (t.number, t.answer, t.ok, t.feedback, len(t.memory_used)))
    print("lesson stored: %s" % out.memory[0])


def demo_codeact() -> None:
    _hdr(4, "CodeAct vs JSON actions: the same task, the same tools")
    mj, mc = Meter(), Meter()
    j = json_actions(mj.wrap(RuleLM(json_phone_policy)), PHONE_TASK, phone_tools())
    c = codeact(mc.wrap(RuleLM(codeact_phone_policy)), PHONE_TASK, phone_tools())
    print("JSON actions: answer=%s model calls=%d prompt tokens=%d" % (j.answer, mj.calls, mj.tokens_in))
    print("CodeAct:      answer=%s model calls=%d prompt tokens=%d" % (c.answer, mc.calls, mc.tokens_in))
    print("CodeAct turn 1 hit an error and recovered: %r" % c.turns[0].obs.text)
    print("(One bug cost one turn. The variable 'prices' stayed in memory, so turn 2 did not call get_price again.)")
    runaway = CodeSession({}, max_lines=100).run("while True:\n    pass")
    print("runaway loop is stopped: ok=%s text=%r" % (runaway.ok, runaway.text))


def demo_rewoo() -> None:
    _hdr(5, "ReWOO and LLMCompiler: plan first, then run the tools without the model")
    plan = ("Plan: find founder, birth year, founding year, then subtract.\n#E1 = search[who founded Harbor Labs]\n"
            "#E2 = search[birth year of #E1]\n#E3 = search[year Harbor Labs was founded]\n#E4 = calc[#E3 - #E2]")
    mr, ma = Meter(), Meter()
    solver = RuleLM(lambda p: "Answer: " + re.findall(r"^#E4: (.*)$", p, re.M)[0])
    rw = rewoo(mr.wrap(ScriptedLM([plan])), mr.wrap(solver), QUESTION, TOOLS)
    ra = react(ma.wrap(age_policy("Harbor Labs")), QUESTION, TOOLS)
    print("ReWOO: %s | model calls=%d prompt tokens=%d | tool waves=%d for %d steps" % (rw.answer, mr.calls, mr.tokens_in, rw.waves, len(rw.plan)))
    print("ReAct: %s | model calls=%d prompt tokens=%d" % (ra.answer, ma.calls, ma.tokens_in))
    print("waves: %s (E1 and E3 do not depend on each other, so they can run together)" % [[s.id for s in w] for w in waves(rw.plan)])
    bad = execute_plan(parse_plan("#E1 = search[nothing at all]\n#E2 = calc[#E1 + 1]", ["search", "calc"]), TOOLS)
    print("tool failure becomes evidence, not a crash: %s" % bad)


def demo_context() -> None:
    _hdr(6, "Context management on a made-up 40-turn run (my own token proxy; made-up prices)")
    strategies = [Strategy("full history"), Strategy("mask, M=10", mask_window=10),
                  Strategy("mask M=10, stride 10", mask_window=10, mask_stride=10), Strategy("compaction", compact=True),
                  Strategy("isolate first 12 turns", isolate_first=12),
                  Strategy("isolate 12 + compaction", compact=True, isolate_first=12)]
    base = None
    print("%-24s %6s %9s %7s %7s %6s %8s %s" % ("strategy", "calls", "input tok", "saved", "peak", "cached", "cost $", "needle kept"))
    for s in strategies:
        r = simulate(s)
        base = base or r.input_tokens
        print("%-24s %6d %9d %6.0f%% %7d %5.0f%% %8.4f %s" % (r.name, r.calls, r.input_tokens, 100 * (1 - r.input_tokens / base),
              r.peak_context, 100 * r.cached_tokens / r.input_tokens, r.cost, r.needle_in_context))
    print("The log is never edited. recall() can still restore a masked output (see section 7).")


def demo_session() -> None:
    _hdr(7, "A Pi-style session: an event log with branches, a compaction entry and a branch summary")
    s = Session()
    s.append("system", "You are a coding agent.")
    s.append("user", "Fix the failing parser test.")
    for t in range(6):
        s.append("assistant", "run: cat src/mod%d.py" % t)
        s.append("observation", _filler(300 if t != 1 else 40, "o%d_" % t) + (" Error: bad token" if t == 2 else ""))
    fork = 5                                              # the event after the first two turns
    before = context_tokens(build_context(s))
    c = compact(s, keep_recent_tokens=700)
    after = build_context(s)
    print("compaction: context %d -> %d tokens; entry %d keeps events from id %d; log still has %d events" % (before, context_tokens(after), c.id, c.first_kept, len(s.events)))
    print("what the model sees (event ids): %s" % [m.src for m in after])
    print("summary starts: %r" % c.text.splitlines()[0:2])
    masked = build_context(s, mask_window=1)
    print("masking the same view (M=1): %d tokens; recall(%d) restores %d tokens" % (context_tokens(masked), 11, count_tokens(recall(s, 11))))
    s.leaf = fork
    s.append("assistant", "run: git log -3")
    s.append("observation", "abc123 fix parser")
    side_leaf = s.leaf
    b = switch_branch(s, c.id)
    print("branch: left leaf %d for %d; the abandoned work was summarized in entry %d (%d tokens)" % (side_leaf, c.id, b.id, count_tokens(b.text)))
    print("log is append-only: %d events, %d JSON lines" % (len(s.events), len(s.to_jsonl().splitlines())))


def main(argv: Optional[List[str]] = None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    sections = [demo_workflows, demo_react, demo_reflexion, demo_codeact, demo_rewoo, demo_context, demo_session]
    if argv in ([], ["demo"]):
        for fn in sections:
            fn()
        return 0
    if len(argv) == 2 and argv[0] == "demo" and argv[1].isdigit() and 1 <= int(argv[1]) <= len(sections):
        sections[int(argv[1]) - 1]()
        return 0
    print("usage: python patterns_toolkit.py [demo [1-%d]]" % len(sections), file=sys.stderr)
    return 2


if __name__ == "__main__":
    sys.exit(main())

Run the demo

$ python patterns_toolkit.py
== 1. Five workflow patterns (scripted mocks; the code decides the control flow) ==
chain:      ok=False stopped_at=1 (the gate saw 20 words in step 2)
route:      I forgot my password           -> password -> cheap: reset link sent
route:      I want a refund for order 12   -> refund   -> strong: refund policy checked, refund approved
            cheap model calls: 1, strong model calls: 1
sectioning: guard check='unsafe', answer='Sure, here is a polite reply.' (both ran at the same time)
voting:     winner=B counts={'B': 3, 'A': 2} agreement=0.6
orchestrate: subtasks=['find the bug', 'write a fix', 'write a test'] final='Fixed parse(), added a test.'
refine:     converged=True after 2 rounds; feedback='too long: 9 words, limit 6'; final='Local team wins the regional robotics'

== 2. ReAct: Thought, Action, Observation (the whole transcript is re-sent every turn) ==
step 1  search[who founded Harbor Labs]    -> Mira Okoye
step 2  search[birth year of Mira Okoye]   -> 1971
step 3  search[year Harbor Labs was founded] -> 2009
step 4  calc[2009 - 1971]                  -> 38
step 5  finish[38]                         -> (done)
answer=38 status=finished model calls=5 prompt tokens=483
stuck detector: a model that repeats itself is stopped with status=stuck after 3 steps

== 3. Reflexion: grounded feedback becomes a lesson in the next prompt ==
trial 1: answer=-40  ok=False feedback='Wrong: the answer -40 is negative; an age cannot be.' lessons in prompt=0
trial 2: answer=40   ok=True  feedback='correct' lessons in prompt=1
lesson stored: My last calc was calc[1964 - 2004]. Wrong: the answer -40 is negative; an age cannot be. I subtracted in the wrong order. Do founded year minus birth year.

== 4. CodeAct vs JSON actions: the same task, the same tools ==
JSON actions: answer=India model calls=9 prompt tokens=1838
CodeAct:      answer=India model calls=3 prompt tokens=525
CodeAct turn 1 hit an error and recovered: "ValueError: unknown country 'Deutschland'"
(One bug cost one turn. The variable 'prices' stayed in memory, so turn 2 did not call get_price again.)
runaway loop is stopped: ok=False text='StepLimit: more than 100 lines executed'

== 5. ReWOO and LLMCompiler: plan first, then run the tools without the model ==
ReWOO: Answer: 38 | model calls=2 prompt tokens=138 | tool waves=3 for 4 steps
ReAct: 38 | model calls=5 prompt tokens=483
waves: [['E1', 'E3'], ['E2'], ['E4']] (E1 and E3 do not depend on each other, so they can run together)
tool failure becomes evidence, not a crash: {'E1': 'no result', 'E2': 'error: invalid syntax (<unknown>, line 1)'}

== 6. Context management on a made-up 40-turn run (my own token proxy; made-up prices) ==
strategy                  calls input tok   saved    peak cached   cost $ needle kept
full history                 40    419960      0%   20759    95%   0.1820 True
mask, M=10                   40    199195     53%    6070    20%   0.4915 False
mask M=10, stride 10         40    266000     37%   10501    89%   0.1617 False
compaction                   40     68205     84%    3644    68%   0.0797 False
isolate first 12 turns       40    243844     42%   14548    80%   0.2048 False
isolate 12 + compaction      40     83609     80%    3880    41%   0.1585 False
The log is never edited. recall() can still restore a masked output (see section 7).

== 7. A Pi-style session: an event log with branches, a compaction entry and a branch summary ==
compaction: context 1604 -> 720 tokens; entry 14 keeps events from id 10; log still has 15 events
what the model sees (event ids): [0, 14, 10, 11, 12, 13]
summary starts: ['## Goal', 'Fix the failing parser test.']
masking the same view (M=1): 431 tokens; recall(11) restores 300 tokens
branch: left leaf 16 for 14; the abandoned work was summarized in entry 17 (20 tokens)
log is append-only: 18 events, 18 JSON lines

How to read the demo1. Workflows. The chain stops at its gate in step 2, because the step produced 20 words. Routing sends the password question to the cheap path and the refund question to the strong one, so each model is called once. Sectioning runs the guard and the answer at the same time (a test checks this with a barrier that only opens if both are running). Voting returns B, 3 votes to 2. Orchestration plans three subtasks at run time. The refine loop stops after 2 rounds, when the draft fits the limit. 2. ReAct. Five model calls, 483 prompt tokens. A model that repeats itself is stopped as "stuck" after 3 steps. 3. Reflexion. Trial 1 subtracts in the wrong order and gets −40. The checker says an age cannot be negative. The lesson goes into trial 2, which gets 40. 4. CodeAct. The JSON loop needs 9 model calls and 1,838 prompt tokens. The CodeAct loop needs 3 calls and 525 tokens, even though turn 1 had a bug. A loop with no end is cut off by the line budget. 5. ReWOO. Two model calls and 138 tokens, against 483 for ReAct on the same question. The four steps run in three waves. A failed tool call becomes text for the solver. 6. Context. The table is the one in section 2.13: tokens, cache share, cost and whether the needle survived. 7. A Pi-style session. Compaction takes the context from 1,604 to 720 tokens. The log still has all 15 events. Masking the same view with M = 1 gives 431 tokens, and recall(11) restores the 300 tokens of a masked output. Leaving a branch adds a summary of the abandoned work.

Exercises

  1. A gate that means something. Write a chain of three steps with a gate that checks a rule (a word limit, a required word, a format). Make step 2 fail. What does ChainResult hold? What does a chain without a gate do with the bad output [2]?
  2. The unknown label. Call route with a text that matches no label, first without and then with a default. Then give it a text that contains two labels. Which wins, and is that the right rule? What would you log in production?
  3. Voting can fail. Make a mock that always gives the same wrong answer. Show that voting with n = 9 does no better than n = 1. Then read the part of Huang et al. on debate and self-consistency. Which result in this exercise matches theirs [12]?
  4. A bad plan. Feed orchestrate a plan that is malformed, empty or too long. Follow what each case does. Why is one retry a better limit than none, or than ten [16]?
  5. A lenient evaluator. Write an evaluator for refine that accepts everything. What does converged say? Now write one that never accepts. Which of the two is harder to notice in production?
  6. Stuck. Write a policy for react that repeats the same action. Change stuck_after from 3 to 2 and to 5. Find a legitimate case where repeating an action is correct (a poll). How would you change the detector for it [1]?
  7. Memory size. Run reflexion on a task that needs three different lessons in order. Set memory_size to 1, 2 and 3. At which size does it succeed? What does this say about the paper's choice of 1 to 3 [4]?
  8. An ungrounded evaluator. Replace the checker in the Reflexion demo with one that always says "looks fine". What happens to the trials and the answer? Write two sentences that connect this to the oracle-label finding [12].
  9. Token growth. Extend the scripted policies so the JSON and CodeAct loops handle 6 countries instead of 3. How do the prompt tokens grow in each? Fit a line or a curve through your counts. Compare with the formula in 2.10 [5].
  10. Break the guard. Find code that gets around CodeSession's restricted builtins (hint: Python lets you reach classes through attributes of ordinary objects). Write a test that shows it. Then write down why the real fix is a container with no secrets, not a longer block list [5].
  11. A dependency problem. Give parse_plan a plan that refers to a step that comes later, one that refers to itself, and one with an unknown tool. What does each raise? Then run execute_plan with parallel=True on tools that sleep, and time the waves [6] [7].
  12. Replan. The ReWOO limitation was a plan that needs facts from the world. Make the first step return "no result", and add a second planner call that sees the evidence and writes new steps. Count the model calls. Where does this land between ReWOO and ReAct? What does LLMCompiler add [7]?
  13. Find the needle. For the 40-turn run, search mask_window from 5 to 40 and the stride from 1 to 10. Find the smallest window that keeps the needle in context. Plot tokens against window size. What does the curve say about tuning M per task [14]?
  14. A better summarizer. Write a summarizer for compact that always keeps lines that start with "NOTE:" or "Error:". Re-run the compaction strategies. Does the needle survive? What did it cost in tokens? Where would a real summarizer fit in this picture [19]?
  15. Re-price it. Reproduce the second table of 2.13 from SimResult. Find the break-even price share for each pair of strategies. Add a cache-write fee of your choice to the model and say which ranking changes. What would you need to check with a real provider [15]?
  16. Branches. In a Session, make a branch, work, go back and call switch_branch. Show with build_context what the model sees on each branch. Check that the log has no deleted lines (to_jsonl). Compare with the Pi docs on /tree and branch summaries [30] [29].
  17. Isolation. Run isolate with a report limit of 20, 60 and 200 tokens. How many facts from the child survive in each case? Plot total tokens (parent and child) against facts kept. Relate it to the Anthropic and Cognition arguments [16] [17].

The tests: test_patterns_toolkit.py

The 155 tests check the mocks and the meter; the calculator and search tools; each workflow pattern (including real concurrency in sectioning, ties in voting, a retried plan and the round limit); ReAct (the parser, the stuck detector and the step limit); Reflexion (the memory window and the stop on success); CodeSession (errors, persistence, the runaway-loop guard and output limits); CodeAct and JSON actions; plan parsing, waves and execution; ReWOO; the event log (parents, JSON Lines round trip); masking, strides and recall; compaction and its cut points; branch summaries and isolation; the simulation (checked against hand-computed totals and a brute-force count of masked outputs); and the demo and command line. I also changed the code in ten ways, one at a time (for example breaking a tie-break or a cut point), and checked that at least one test failed each time.

$ python test_patterns_toolkit.py -v
test_both_branches_stay_in_the_log ... ok
test_isolation_gives_the_parent_only_the_report ... ok
test_switch_branch_summarizes_only_the_abandoned_part ... ok
test_switching_to_the_same_branch_leaves_nothing_behind ... ok
test_the_child_starts_clean ... ok
test_the_summary_is_part_of_the_new_branchs_context ... ok
test_bad_stride_is_rejected ... ok
test_building_a_context_never_changes_the_log ... ok
test_context_contains_and_tokens ... ok
test_context_follows_the_chosen_branch ... ok
test_mask_window_zero_masks_every_observation ... ok
test_masking_keeps_the_last_m_observations_in_full ... ok
test_masking_never_touches_actions_or_the_task ... ok
test_roles_follow_event_kinds ... ok
test_short_observations_are_not_replaced_by_a_longer_placeholder ... ok
test_stride_moves_the_masked_boundary_in_steps ... ok
test_stride_one_is_the_plain_rolling_window ... ok
test_the_placeholder_names_the_size_and_the_recall_id ... ok
test_window_larger_than_history_masks_nothing ... ok
test_without_masking_everything_is_shown ... ok
test_calc_returns_text_and_never_raises ... ok
test_safe_eval_arithmetic ... ok
test_safe_eval_blocks_huge_powers ... ok
test_safe_eval_rejects_names_calls_and_attributes ... ok
test_search_needs_three_shared_words ... ok
test_search_picks_best_overlap ... ok
test_code_wins_if_a_reply_has_both_code_and_final ... ok
test_codeact_recovers_from_a_tool_error ... ok
test_codeact_uses_fewer_turns_and_tokens_on_this_task ... ok
test_final_without_code_ends_the_run ... ok
test_json_actions_need_one_turn_per_tool_call ... ok
test_json_bad_reply_is_an_observation ... ok
test_json_unknown_tool_is_an_observation ... ok
test_reply_without_code_or_final_is_a_format_error ... ok
test_the_phone_prices_hand_check ... ok
test_turn_limit ... ok
test_errors_become_text_and_the_session_survives ... ok
test_imports_and_files_are_not_available ... ok
test_line_budget_counts_inside_functions_too ... ok
test_long_output_is_truncated ... ok
test_partial_output_is_kept_with_the_error ... ok
test_print_is_captured ... ok
test_runaway_loop_is_stopped ... ok
test_syntax_error ... ok
test_this_is_not_a_security_sandbox ... ok
test_tools_are_functions_in_the_namespace ... ok
test_tracing_is_restored_after_a_run ... ok
test_variables_persist_between_runs ... ok
test_a_second_compaction_carries_files_and_errors_forward ... ok
test_compaction_on_a_branch_uses_that_branchs_path ... ok
test_compaction_shrinks_the_context_and_keeps_the_log ... ok
test_errors_survive_in_the_summary_unless_dropped ... ok
test_kept_tail_has_at_least_the_requested_tokens_or_ends_at_a_boundary ... ok
test_nothing_to_compact_returns_none ... ok
test_should_compact_threshold ... ok
test_summary_has_goal_actions_and_files ... ok
test_summary_respects_its_token_budget ... ok
test_the_cut_never_falls_on_an_observation ... ok
test_the_view_is_system_then_summary_then_the_kept_tail ... ok
test_bad_usage_returns_2 ... ok
test_demo_is_deterministic ... ok
test_demo_numbers_match_the_notes ... ok
test_full_demo_runs_and_prints_seven_sections ... ok
test_one_section ... ok
test_a_failing_tool_becomes_evidence ... ok
test_independent_steps_really_run_together_in_parallel_mode ... ok
test_parallel_and_sequential_give_the_same_evidence ... ok
test_references_are_replaced_by_evidence_text ... ok
test_runs_the_plan_without_any_model ... ok
test_count_tokens_counts_words_and_punctuation ... ok
test_meter_counts_calls_and_tokens ... ok
test_meter_is_safe_across_threads ... ok
test_rule_lm_records_prompts ... ok
test_scripted_lm_raises_when_script_runs_out ... ok
test_scripted_lm_replays_in_order_and_records_prompts ... ok
test_a_bad_plan_is_retried_once ... ok
test_custom_worker_is_used ... ok
test_parse_subtasks_accepts_fenced_json ... ok
test_parse_subtasks_rejects_bad_input ... ok
test_plan_work_and_combine ... ok
test_too_many_subtasks_are_cut_and_flagged ... ok
test_two_bad_plans_raise ... ok
test_duplicate_id_is_rejected ... ok
test_empty_plan_is_rejected ... ok
test_parses_steps_and_ignores_other_lines ... ok
test_reference_to_a_later_or_missing_step_is_rejected ... ok
test_unknown_tool_is_rejected ... ok
test_waves_group_independent_steps ... ok
test_waves_of_a_straight_line_and_of_independent_steps ... ok
test_gate_stops_the_chain_at_the_failing_step ... ok
test_no_steps_is_an_ok_empty_chain ... ok
test_output_of_one_step_feeds_the_next ... ok
test_outputs_are_stripped ... ok
test_a_changed_observation_is_not_stuck ... ok
test_format_error_is_reported_and_the_loop_continues ... ok
test_happy_path ... ok
test_lessons_go_into_the_header_before_the_question ... ok
test_parse_react ... ok
test_parse_react_allows_brackets_inside_the_argument ... ok
test_step_limit ... ok
test_stuck_detector_stops_exact_repeats ... ok
test_the_prompt_grows_every_turn ... ok
test_tool_exception_becomes_an_observation ... ok
test_tool_names_are_listed_in_the_header ... ok
test_unknown_tool_becomes_an_observation ... ok
test_feedback_is_passed_to_the_generator ... ok
test_gives_up_after_max_rounds_and_returns_last_draft ... ok
test_max_rounds_must_be_positive ... ok
test_stops_when_the_evaluator_accepts ... ok
test_a_lesson_fixes_the_second_trial ... ok
test_gives_up_after_max_trials ... ok
test_lessons_reach_the_actors_prompt ... ok
test_memory_is_a_sliding_window ... ok
test_no_reflection_is_needed_when_the_first_trial_passes ... ok
test_reflect_gets_the_trajectory_and_the_feedback ... ok
test_a_bad_plan_raises_before_any_tool_runs ... ok
test_the_plan_cannot_adapt_to_a_surprise ... ok
test_the_solver_sees_the_plan_and_the_evidence ... ok
test_two_model_calls_however_many_tools_run ... ok
test_uses_fewer_prompt_tokens_than_react_on_the_same_question ... ok
test_a_route_can_use_a_different_model ... ok
test_label_match_is_case_insensitive_and_tolerates_extra_words ... ok
test_label_must_match_a_whole_word ... ok
test_routes_to_the_matching_label ... ok
test_unknown_label_goes_to_default ... ok
test_unknown_label_without_default_raises ... ok
test_buggy_actor_subtracts_in_the_wrong_order_until_it_sees_the_lesson ... ok
test_phone_tools_reject_unknown_countries ... ok
test_sectioning_keeps_template_order ... ok
test_sectioning_really_runs_calls_at_the_same_time ... ok
test_voting_applies_parse ... ok
test_voting_majority ... ok
test_voting_needs_at_least_one_sample ... ok
test_voting_tie_goes_to_first_seen ... ok
test_a_branch_is_a_second_child ... ok
test_append_links_events_into_a_chain ... ok
test_bad_parent_and_bad_kind_are_rejected ... ok
test_events_are_immutable ... ok
test_jsonl_round_trip ... ok
test_jsonl_with_out_of_order_ids_is_rejected ... ok
test_observers_see_every_event ... ok
test_path_returns_root_to_leaf ... ok
test_a_masked_needle_can_be_recalled_from_the_log ... ok
test_compaction_keeps_the_context_under_the_window ... ok
test_cost_formula ... ok
test_fewer_turns_fewer_tokens ... ok
test_full_history_is_append_only_so_each_call_reuses_the_previous_input ... ok
test_full_history_matches_hand_arithmetic ... ok
test_isolation_cuts_what_the_parent_pays_and_loses_what_the_report_omits ... ok
test_masking_matches_a_brute_force_count ... ok
test_masking_saves_input_tokens_but_loses_the_needle_from_view ... ok
test_per_call_series_adds_up_and_matches_the_hand_count ... ok
test_simulation_is_deterministic ... ok
test_toy_cache_model_a_rolling_mask_breaks_the_prefix_every_turn ... ok
test_trajectory_is_deterministic_and_has_the_needle_and_an_error ... ok

----------------------------------------------------------------------
Ran 155 tests in 4.213s

OK

5. Discussion questions

Use these in a study group or a class. Try to answer with an example and a source. Give each about 5 to 10 minutes.

  1. Take a product you use that calls a model. For each step, say whether code or the model owns the next step. Where would moving a step from the model to code make the product more reliable, and where would it make it worse [2]?
  2. Anthropic says to find the simplest solution and add complexity only when it shows a gain [2]. What evidence would you need to move from a single call to a chain, and from a chain to an agent? Who collects it?
  3. Voting multiplies cost by n. Evaluator-optimizer multiplies it by the number of rounds. For a task of your choice, which gives more value per dollar, and how would you test that against a baseline with the same budget [12]?
  4. ReAct beat chain-of-thought on Fever and trailed it on HotpotQA [1]. Think of a task in your own work that looks like each. What does the difference tell you about when to let the model act?
  5. Reflexion works when the evaluator is grounded [4] [12]. You are building a customer-support agent. What is the grounded signal in production, if any? What would you do if there is none?
  6. A CodeAct agent can do a lot in one turn [5]. List three things you would want in a sandbox for it. Which of them is hardest to get right?
  7. ReWOO plans before it sees any result [6]. Give one task where that is fine and one where it fails. How would LLMCompiler's replanning change the second one [7]?
  8. The lecture's two context principles are "focus the context" and "preserve continuity". Find a strategy in this week's notes that serves one and hurts the other. What would you add so it serves both [19]?
  9. The Complexity Trap found that masking matched summarizing in software engineering tasks [14]. Name a domain where tool outputs are short and a domain where the history is mostly reasoning. Would you expect the same result? How would you test it?
  10. In the toy model, rolling masking saved tokens and cost more (2.13). Your provider gives a 90% discount on cached input. Your teammate wants to mask old outputs to save money. What do you ask them to measure first?
  11. Anthropic's research system and Cognition's coding agent made opposite choices about sub-agents [16] [17]. Describe a task where you would side with each. What makes a task read-only and independent?
  12. Pi has four tools and no permission checks, and the author says the checks of other agents are mostly theater [28]. You are asked to deploy a Pi-like agent in a company. Which of its choices would you keep, which would you change, and what would you add around it?

6. Quiz (10 questions)

Pick one answer for each question, then press Check answers. 0 of 10 answered.

  1. In Anthropic's wording, what separates a workflow from an agent [2]?
    • A workflow uses one model call and an agent always uses at least two
    • A workflow has no tools and an agent has tools
    • A workflow runs offline and an agent runs only in a browser
    • In a workflow, code sets the path through the model calls and tools. In an agent, the model directs its own process and tool use, and decides the next step
  2. What is the main difference between parallelization (sectioning) and orchestrator-workers [2]?
    • Orchestrator-workers uses a weaker model for the workers
    • In orchestrator-workers, the subtasks are not fixed in the code: a central model decides at run time how many there are and what they are. In sectioning, the parts are known in advance
    • Parallelization is always cheaper because it uses fewer tokens
    • Orchestrator-workers cannot use tools
  3. Which result matches the ReAct paper [1]?
    • ReAct was ahead of chain-of-thought on Fever (60.9 against 56.3) and slightly behind on HotpotQA (27.4 against 29.4). Its answers were better grounded, but it sometimes repeated earlier thoughts and actions in a loop
    • ReAct beat chain-of-thought on both HotpotQA and Fever by a wide margin, and had no failure modes of its own
    • ReAct needed fine-tuning on a huge dataset to work at all
    • ReAct was evaluated only on programming tasks
  4. Huang et al. studied self-correction in reasoning. What was their main finding about earlier reports of gains, including those that used oracle labels [12]?
    • Self-correction always works when the model is large enough
    • Self-correction works only for code
    • Without external feedback, models struggled to correct their own reasoning and sometimes got worse. The gains in earlier work came from oracle labels about whether the answer was right, and vanished without them
    • Multi-agent debate is always much better than voting
  5. In the lab, CodeAct needed 3 model calls where the JSON loop needed 9 on the same task. Which explanation is the best, and what is the caveat [5]?
    • One code block can loop over several tool calls and keep results in variables, so one turn can do the work of several. The caveat: the models and scripts are mocks I wrote, so this shows the mechanism and not a real ratio, and the code must run in a real sandbox
    • CodeAct uses a smaller model. The caveat is that it is less accurate
    • JSON actions are not allowed to call tools more than once. There is no caveat
    • The CodeAct loop skips the model and calls tools directly
  6. How does ReWOO get away with only two model calls on a multi-step question [6]?
    • It guesses the answer and never uses tools
    • It stores all possible answers in a table
    • It runs the model twice in parallel and takes the better answer
    • A planner writes the whole plan once, with placeholders such as #E1 for earlier results. Tools run without the model and fill the placeholders in. A solver then reads the evidence in one call. The price is that the plan is fixed before any result is seen
  7. A run has a 200-token system prompt, and each turn adds 1,000 tokens. The full history is re-sent on every one of 40 calls. What is the total input over the run (my own arithmetic from 2.10)?
    • 40,000 tokens, because only 40,000 new tokens were added
    • 8,000 tokens, because the system prompt is only counted once
    • 788,000 tokens: 40 × 200 plus 1,000 × (40 × 39 / 2)
    • 1,600,000 tokens, because each call sends everything twice
  8. Which statement fits the Complexity Trap paper [14]?
    • LLM summaries beat observation masking on every model and cost less
    • In SWE-agent on SWE-bench Verified, simple observation masking about halved cost against the raw agent while matching the solve rate of LLM summaries. The study is limited to software engineering, and the window size had to be tuned again for a different scaffold
    • Masking removes the model's own reasoning from the history
    • The paper shows that masking is best for every kind of agent
  9. In the toy model of 2.13, rolling masking sends 53% fewer tokens than the full history but costs more. Why [15]?
    • The window moves on each call, so most of the prompt after the first masked turn is different text, and the prompt cache can no longer reuse it. Masking in steps keeps the prefix stable for several calls. The result depends on the real price ratio
    • Masking adds new tokens to the prompt
    • Cached tokens cost more than uncached tokens
    • The cost is the same for all strategies, and the table is a mistake
  10. In Pi, what does compaction do to the session, and what is one rule for where it cuts [29]?
    • It deletes the old entries from the file and cuts anywhere
    • It restarts the process and loses the branch tree
    • It appends a compaction entry that holds a summary and a firstKeptEntryId. The log is not edited, and the next request is built from the summary plus entries from that id on. It never cuts at a tool result, because the result must stay with its call
    • It sends the whole log to a second agent and discards the first

Answer key

Question 1

D. Anthropic defines workflows as systems where LLMs and tools are orchestrated through predefined code paths, and agents as systems where LLMs dynamically direct their own processes and tool usage [2]. Both may use tools. See 2.1.

Question 2

B. Anthropic says the key difference from parallelization is flexibility: the subtasks are not pre-defined, but determined by the orchestrator based on the specific input [2]. See 2.2.

Question 3

A. The paper reports 60.9 against 56.3 on Fever and 27.4 against 29.4 on HotpotQA. Its manual study found that hallucination was a major problem for chain-of-thought, and that ReAct repeated earlier thoughts and actions in a loop as a pattern of its own [1]. See 2.4.

Question 4

C. The paper says the improvements in earlier studies result from using oracle labels to guide self-correction, and that the improvements vanish when oracle labels are not available [12]. See 2.5.

Question 5

A. The demo shows 3 calls and 525 prompt tokens against 9 calls and 1,838 tokens, with scripted mocks. The paper reports a gain in success rate and fewer actions on its benchmark, with real models [5]. The code session in the lab is not a security sandbox. See 2.6.

Question 6

D. ReWOO has a Planner, a Worker and a Solver [6]. The lab's demo shows 2 calls and 138 tokens against 483 for ReAct on the same scripted question. The limitation is the one in the paper's own limitations section [6]. See 2.7.

Question 7

C. The input of call k is 200 + (k − 1) × 1,000. The sum over 40 calls is 40 × 200 + 1,000 × (0 + 1 + … + 39) = 8,000 + 780,000 = 788,000. This is my own arithmetic. See 2.10.

Question 8

B. The abstract says masking halves cost relative to the raw agent while matching, and sometimes slightly exceeding, the solve rate of LLM summarization. The limitations section says the study is only in software engineering. The paper also says the window size had to be tuned for OpenHands [14]. See 2.12.

Question 9

A. In the toy model, rolling masking has a 20% cached share and costs 0.492 against 0.182 for the full history. A stride of 10 brings the cached share to 89% and the cost to 0.162. The prices and the data are made up. See 2.13.

Question 10

C. The compaction reference says that Pi appends a compaction entry with a summary and a firstKeptEntryId, rebuilds the context from the summary and the entries from that id, and never cuts at tool results [29]. See 2.15.

7. Graded assignment and rubric

Title: Compose, measure and defend: build with the patterns, price the context, and justify a design.
Time: 3 to 4 hours. Total: 100 points. Work: solo.

The task

Extend the lab code and write a short memo (three pages at most). Mock models are fine. You may use a real model if you have access, but say clearly which parts used it. Build your own small tasks and data for each part, and say how you made them.

Part A. Workflow patterns (25 points)

Part B. Loops compared (25 points)

Part C. Price the context (25 points)

Part D. A design memo (25 points)

What to hand in

Rubric

CriterionFull marksPartialPoints
A. Workflow patternsOwn task built from two patterns. A guard test for each. One new feature with five or more passing tests, including an edge case. Honest note on mock versus real.One pattern, or missing guard tests, or fewer than five tests.25
B. Loops comparedFive or more tasks with checks. Calls and tokens from Meter for each loop, labelled as mock numbers. A ReWOO failure shown with a fix. Reflexion with a grounded and an ungrounded checker, and a correct reading of the result.Fewer tasks, or no failure case, or the Reflexion comparison is missing.25
C. Price the contextA 60-turn run with two needles. Five or more strategies measured. Three price ratios and a break-even with the arithmetic shown. A summarizer that keeps a needle, and its cost.Fewer strategies, or one price only, or no needle check.25
D. Design memoClear choices for pattern, context and safety. Three Pi choices copied and one rejected. A reasoned side on sub-agents. A list of measures and three honest limits.Choices without reasons, or sources missing, or no limits.25
Total100

Grade bands: 90 to 100 excellent. 75 to 89 good. 60 to 74 needs work. Under 60: redo and resubmit. Plain writing is rewarded: short sentences, no filler. Label your own arithmetic as your own.

8. Glossary

TermMeaning
AgentA system where the model directs its own process and tool use. The model decides the next step [2].
Augmented LLMA model with retrieval, tools and memory. The building block of Anthropic's patterns [2].
Cache hit (prompt cache)Reuse of work for the part of a prompt that is an exact prefix of an earlier prompt. It is billed at a lower price. Editing earlier turns breaks it [15].
CodeActAn agent design where the action is executable code in a persistent interpreter, not one JSON tool call per turn [5].
CompactionReplacing old turns with a summary and continuing from it. In Pi, it adds a log entry with a pointer to the first kept entry [19] [29].
Context engineeringFilling the context window with just the right information at each step [3].
Context isolationGiving a sub-task its own window, so that only a short report returns to the parent [3].
Context rotThe fall in a model's ability to recall information as the number of tokens in the window grows [19].
Evaluator-optimizerA workflow in which one call generates and another gives feedback, in a loop with a round limit [2].
Event logAn append-only record of everything that happened. The model's input is built from it as a view [24].
GateA programmatic check between steps of a chain that decides whether to go on [2].
Grounded feedbackA signal from outside the model, such as a test, a tool result or an environment state, as opposed to the model's own opinion [4] [13].
HarnessThe code around a model that manages its tools, context and task execution [27].
Intrinsic self-correctionA model revising its own answer using only its own judgment, with no outside feedback [12].
Observation maskingReplacing old tool outputs with a short placeholder and keeping the model's own thoughts and actions. Not the same as Manus's token-logit masking [14] [15].
Orchestrator-workersA workflow in which a central model creates subtasks at run time, delegates them to workers, and combines the results [2].
Progressive disclosureLoading information in levels: a short description first, the full body when needed, extra files only if needed [20].
ReActA loop of Thought, Action and Observation, with the transcript sent back each turn [1].
ReflexionRetrying after a failure with a short written lesson, kept in a bounded memory, from a grounded evaluator [4].
ReWOO"Reasoning WithOut Observation". A planner writes the plan with placeholders, tools run without the model, and a solver answers [6].
RoutingClassifying an input, then sending it down a specialist path [2].
Sectioning and votingThe two forms of parallelization: split independent parts, or repeat a task and take the majority [2].
Stride (masking)Moving the mask boundary in steps of several turns, so the prompt prefix stays stable between steps. My own term for the lab.
Wave (plan)A group of plan steps that depend only on earlier steps, and so can run together. My own term for the lab; compare LLMCompiler's parallel execution [7].

9. References

Entries marked (required) are the course's required readings. Entries marked (added) are my own additions. The course site and slides, [33] and [34], are the course's own pages. "Abstract level" means that I read the abstract and not the whole paper.

About the sourcesEvery claim about a source in these notes comes from that source's own page, which I opened while writing. I read the parts that I cite of the ReAct, Reflexion, CodeAct, ReWOO, LLMCompiler and Huang et al. papers, and of the Complexity Trap paper. I read the Anthropic, LangChain, Manus and Cognition posts and the Pi pages listed, and I quote only what they say. For Plan-and-Solve, Tree of Thoughts, LATS, Self-Refine, CRITIC, MemGPT, Lost in the Middle, OpenHands, STORM and Archon I read only the abstract, and I quote only what the abstract says. I did not check any result by running the authors' code. Where a number is mine, it is marked as my own arithmetic or as coming from the lab's made-up data. Blog posts and docs change. I note the date of each, and I flag where a newer page disagrees with an older one. Venues (ICLR, NeurIPS, ICML) are given as the course lists them. The first pages of the ReAct and Huang et al. papers confirm ICLR 2023 and ICLR 2024, and the Complexity Trap paper's own citation of CodeAct confirms ICML 2024. If you spot an error, please tell me and I will fix it.