← Previous: Week 3b, Frameworks and Orchestration · Next: Week 4b, Agent Memory Architectures →
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.
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.
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.
After this week you should be able to:
| Time | What to do |
|---|---|
| 2.5 hours | Read the notes (section 2). Skim the figures first. |
| 3.5 hours | Read the three required readings with the reading guide questions, then the Reflexion and CodeAct papers. |
| 3 hours | Do the lab and at least five exercises. |
| 1 hour | Discussion and the quiz. |
| 2 hours | The graded assignment. |
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.
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.
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.
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].
prompt_chain takes a gate function and stops at the first failure.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].
route raises an error or uses a default, and it matches labels as whole words.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].
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].
orchestrate retries a bad plan once and cuts off a plan that is too long.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]
refine has max_rounds, and it returns the last draft with converged=False if the evaluator never accepts.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.
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.
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.
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.
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.
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.
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.
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]:
#E1 = search[who founded Harbor Labs], then #E2 = search[birth year of #E1].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.
| Pattern | Idea | Reported 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.
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.
| Pattern | Choose it when | What it costs | First thing to check |
|---|---|---|---|
| Workflow (2.2) | You know the steps | Little. Easy to test. | Do you really need an agent? |
| ReAct | Each result decides the next step | One call per step, and the transcript grows | Step limit and stuck detector |
| Reflexion | You can retry, and you have a grounded check | Whole trials, repeated | What is the evaluator in production? |
| CodeAct | Steps need loops, and results feed other tools | A sandbox to build and run | Is the code run in isolation? |
| ReWOO / LLMCompiler | Steps are predictable, and cost or latency matters | A plan that may be wrong | What happens when a step is a surprise? |
| ToT / LATS | A partial step can be scored, and the task is hard | Many calls per problem | Does 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.
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.
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].
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.
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.
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.
| Pattern | What it does | What you keep | What you risk |
|---|---|---|---|
| Persistent event log | Every 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 offloading | Move 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 reach | The model must remember to look. Stale notes. |
| Compaction | Replace old turns with a short summary, and keep the recent turns whole. | The gist, and the recent detail | A needed detail may be left out. A summary call costs money. |
| Observation masking | Replace old tool outputs with a short placeholder. Keep the model's own thoughts and actions. | The path of the reasoning | A fact that only appeared in a masked output |
| Context isolation | Give a sub-task its own window. Only a short report returns to the parent. | A clean parent window | Lost nuance in the report. Extra tokens overall. |
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]).
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.
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.
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].
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.
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].
| Finding | Evidence 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.
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 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).
| Strategy | Total input tokens | Saved vs full | Cached share | Cost (made-up $) | Needle still in context? |
|---|---|---|---|---|---|
| Full history | 419,960 | 0% | 95% | 0.182 | yes |
| Rolling mask, M = 10 | 199,195 | 53% | 20% | 0.492 | no |
| Mask M = 10, stride 10 | 266,000 | 37% | 89% | 0.162 | no |
| Compaction | 68,205 | 84% | 68% | 0.080 | no |
| Isolate 12 turns + compaction | 83,609 | 80% | 41% | 0.159 | no |
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 uncached | Full history | Rolling mask | Stride 10 | Compaction |
|---|---|---|---|---|
| 100% (no cache discount) | 1.260 | 0.598 | 0.798 | 0.205 |
| 50% | 0.661 | 0.539 | 0.445 | 0.135 |
| 25% | 0.362 | 0.509 | 0.268 | 0.101 |
| 10% (the 10× discount) | 0.182 | 0.492 | 0.162 | 0.080 |
| 5% | 0.122 | 0.486 | 0.126 | 0.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.
Context isolation is the pattern with the loudest argument around it. Two engineering posts from 2025 disagree, and both are worth reading in full.
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].
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.
| Anthropic [16] | Cognition [17] | |
|---|---|---|
| Task type | Breadth-first research that can be split into independent searches | Building software, where parts must fit together |
| What sub-agents return | Findings that the lead can merge | Decisions (code) that may conflict |
| Cost view | Pay about 15× tokens when the task is valuable enough | Pay for a compression model instead |
| Shared advice | Be 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].
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 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.
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.
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.
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.
| Choice | Reason given [28] | What it costs, and my view |
|---|---|---|
| Four tools | Models 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 mode | A 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-agents | Context 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 bash | Use 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 default | The 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.
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.
Yao, Zhao, Yu, Du, Shafran, Narasimhan, Cao. ICLR 2023. arXiv 2210.03629 · reference [1].
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.
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.
The whole post, about 25 minutes, including both appendices. Appendix 2 (on tools) is easy to skip and shouldn't be.
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".
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.
Shinn, Cassano, Berman, Gopinath, Narasimhan, Yao. NeurIPS 2023. arXiv 2303.11366 · reference [4]. Read it with Huang et al. [12].
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.
Wang, Chen, Yuan, Zhang, Li, Peng, Ji. ICML 2024. arXiv 2402.01030 · reference [5].
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.
| Source | Why 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. |
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.
patterns_toolkit.py. Or download it: patterns_toolkit.py.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.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.patterns_toolkit.pyThe 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())
$ 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.
ChainResult hold? What does a chain without a gate do with the bad output [2]?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?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]?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]?refine that accepts everything. What does converged say? Now write one that never accepts. Which of the two is harder to notice in production?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]?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]?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].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].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]?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]?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]?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].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].test_patterns_toolkit.pyThe 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
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.
Pick one answer for each question, then press Check answers.
#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 seenfirstKeptEntryId. 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 callD. 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
refine. Write at least five unit tests, including an edge case.Meter to report model calls and prompt tokens per task and per loop. Put them in a table and label them as mock numbers.reflexion on one task with a grounded checker and then with a checker that always says "fine". Report both, and connect the result to Huang et al. [4] [12].patterns_toolkit.py (or your own file names), your tasks and data, and the tests. All tests must pass.| Criterion | Full marks | Partial | Points |
|---|---|---|---|
| A. Workflow patterns | Own 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 compared | Five 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 context | A 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 memo | Clear 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 |
| Total | 100 | ||
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.
| Term | Meaning |
|---|---|
| Agent | A system where the model directs its own process and tool use. The model decides the next step [2]. |
| Augmented LLM | A 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]. |
| CodeAct | An agent design where the action is executable code in a persistent interpreter, not one JSON tool call per turn [5]. |
| Compaction | Replacing 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 engineering | Filling the context window with just the right information at each step [3]. |
| Context isolation | Giving a sub-task its own window, so that only a short report returns to the parent [3]. |
| Context rot | The fall in a model's ability to recall information as the number of tokens in the window grows [19]. |
| Evaluator-optimizer | A workflow in which one call generates and another gives feedback, in a loop with a round limit [2]. |
| Event log | An append-only record of everything that happened. The model's input is built from it as a view [24]. |
| Gate | A programmatic check between steps of a chain that decides whether to go on [2]. |
| Grounded feedback | A 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]. |
| Harness | The code around a model that manages its tools, context and task execution [27]. |
| Intrinsic self-correction | A model revising its own answer using only its own judgment, with no outside feedback [12]. |
| Observation masking | Replacing 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-workers | A workflow in which a central model creates subtasks at run time, delegates them to workers, and combines the results [2]. |
| Progressive disclosure | Loading information in levels: a short description first, the full body when needed, extra files only if needed [20]. |
| ReAct | A loop of Thought, Action and Observation, with the transcript sent back each turn [1]. |
| Reflexion | Retrying 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]. |
| Routing | Classifying an input, then sending it down a specialist path [2]. |
| Sectioning and voting | The 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]. |
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.