The tutorial is in English by default, and you can switch to Chinese through the language link at the top. Source code comments, docstrings, and CLI prompts are all in English; model responses follow the user language.
This tutorial draws on the source code of pi and Codex.
Start with a useful simplification: a model request does not execute actions or retain your conversation for you. You provide input and receive output. The client maintains the history and supplies the tools.
So "agent" is not the ability of the model, it is the loop you wrote. The only responsibility of the loop is to translate the "I want to do something" output by the model into real actions, and then translate the results of the actions back into text to feed it. The intelligence is all in the model, and your code is only responsible for transportation and accounting.
A tool call does not execute anything by itself. The model returns a tool_calls array with entries such as {id: "call_01...", function: {name: "read", arguments: "{...}"}}. The arguments field contains JSON encoded as a string. Your program decides whether and how to execute the request.
Understanding this, the whole structure falls into place: The loop is the hands. The model is responsible for thinking, you are responsible for doing it, and you will tell it the results after it is done.
Before you write a loop, you must first know what a request looks like. The entire protocol actually has only one entry: send a POST to {base_url}/chat/completions. The request body is divided into three parts——
POST {base_url}/chat/completions
{
"model": "gpt-4o-mini",
"messages": [ ← entire conversation, a list
{"role": "system", "content": "You are a coding agent..."},
{"role": "user", "content": "Help me take a look at config.py"},
...
],
"tools": [ ...tool declaration... ] ← The "hands" that the model can use
}
messages is a list, with each entry having the shape {"role": ..., "content": ...}. There are four types of roles in total. Remember their division of labor and use them in every subsequent chapter:
system: System prompt to define the identity and rules of the model. Only put it in the first one (Chapter 7). user: What the user said. assistant: What the model says; when it wants to call the tool, this message will have an additional tool_calls array. tool: The result of tool execution, single one, relies on tool_call_id to claim which call it belongs to. The response is also a JSON. There is only one path that really matters: choices[0].message - This is the model's reply in this turn. In the next turn, you have to stuff it back into messages as it is:
{
"choices": [
{
"message": { "role": "assistant", "content": null,
"tool_calls": [ {"id": ..., "function": {"name": ..., "arguments": ...}} ] },
"finish_reason": "tool_calls" ← Another field on the same level
}
]
}
Note that finish_reason and message are sibling fields at the same level: one contains what the model says, and the other contains the description of "why it stopped." This distinction will come into use shortly.
The requests and responses drawn above are not made up by me, they are OpenAI's Chat Completions interface - the entire interface has only this entry: send a POST to {base_url}/chat/completions and return a JSON. To check the exact meaning of any field, look no further than these pages:
model / messages / tools / max_tokens...): api-reference/chat/createchoices → message → content / tool_calls, and finish_reason at the same layer): api-reference/chat/objectsystem / user / assistant / tool) and complete dialogue examples: api-reference/chattools declaration, tool_calls return, tool_call_id pairing): guides/function-callingCompared with the document, you will find that only four fields are used in the request body - model, max_tokens, messages, tools; in the response, only choices[0].message is read (the text is in content, the tool call is in tool_calls), and the other fields are ignored. The protocol is very large, and the agent needs only a small piece - streaming, multi-modal, sampling parameters, logprobs, etc., just pretend they don’t exist for now.
OpenAI style (chat/completions) has mandatory requirements for history structure. This constraint is the source of all the trouble in the following chapters and must be remembered from the beginning:
user "Help me take a look at config.py"
assistant {content: ..., tool_calls: [{id: "call_A", function: {name: read, ...}}]} ← Intent only
tool {tool_call_id: "call_A", content: "..."} ← Must match
assistant "This file defines a CONFIG dictionary..." ← Call it a day
Each tool_call must have a message with the same tool_call_id and the same role as tool, there can be no less or more. This rule will also be used later: in Chapter 4, the pairs must be supplemented when interrupted, and in Chapter 6, the cut points cannot break up the pairs during compaction, all because of it.
Also note the usage of role: the tool result is a separate role="tool" message, the content is plain text (mostly stringified JSON), not assistant. This leads to a very hidden pit in Chapter 6.
The response contains sibling fields finish_reason and message. Typically, finish_reason is "tool_calls" when the model requests tools and "stop" when it finishes speaking. That makes it tempting to use this metadata as the loop condition.
The reason is that the nature of these two fields is completely different:
finish_reason is metadata of the service provider: it only describes "how the generation stopped" and is not part of the conversation itself. The values of different manufacturers and different compatible endpoints are not completely consistent, and there are differences in actual measurements. message.tool_calls is a fact written into history by yourself: whether the model has given a tool call or not, just check whether the array is there and whether it is empty. It will always be reliable. So the criterion in this chapter is "whether there is a non-empty tool_calls in the assistant message" instead of looking at finish_reason. Use facts, not metadata - This is an engineering principle that can be extended to many places.
Note that messages in the request body is complete history, not this sentence. Because the model is stateless, you won’t know what you talked about before if you don’t send it.
So there is an account to be settled: assuming that 1 message is generated in each turn, and N messages are sent in the Nth turn. The cumulative amount sent after N turns is 1+2+...+N ≈ N²/2. The cost increases quadratically as conversation length increases.
This is the fundamental reason for the existence of compaction in Chapter 6 - it is not the icing on the cake, but if it is not compressed, the context window will be hit after chatting for a long time, and the API will directly report an error.
tool_calls array in this turn of responses. Don't look at finish_reason - it is the metadata of the service provider, and the naming is not uniform; and "Whether there is tool_calls in the assistant message" is a fact constructed by you, and it is always reliable. content, it also has an array of tool_calls. The next turn depends on the id inside to match the result of role="tool". Saving only text will cause the next turn of requests to fail. This chapter introduces the mini_agent/ package: config.py collects settings, llm.py handles model requests, agent.py runs the loop, and cli.py provides the terminal interface. The result is an agent that can repeatedly call tools to complete a task. Later chapters extend the same structure.
call_llm() sends a request; the extraction helpers read the assistant message, text, and tool calls.agent_loop() requests a response, records it unchanged, executes tools, appends results, and repeats.-v enables tool output.mini_agent as an importable Python package.from .cli import main entry point enables python -m mini_agent.mini_agent/config.py, the model address, key and model name are read from environment variables. 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | # Configurable settings. # Use environment variables to change providers without editing code. CONFIG = { # Any compatible Chat Completions endpoint. "base_url": os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"), "api_key": os.environ.get("OPENAI_API_KEY", ""), "model": os.environ.get("MODEL_ID", "gpt-4o-mini"), # Request parameters. "max_tokens": 8000, "request_timeout_s": 300, # Tool execution limits. "bash_timeout_s": 120, "max_output_chars": 50_000, # File reading. "max_read_lines": 2000, # Session persistence (chapter 5). # One JSONL file per session under the working directory by default. "session_dir": os.environ.get("MINI_AGENT_SESSION_DIR", ".mini_agent/sessions"), # Context compaction (chapter 6). # Approximate model context limit used to trigger summarization. "max_context_tokens": int(os.environ.get("MINI_AGENT_MAX_CONTEXT_TOKENS", "60000")), # Reserve room for the next request and response. "compaction_reserve_tokens": 8000, # Keep these recent user turns verbatim. "compaction_keep_recent_turns": 3, } # Literal shell-command blacklist. # This demonstrates the idea of execution guardrails. # Literal matching is easy to bypass; it is not a sandbox. BLOCKED_PATTERNS = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] def require_api_key(): """Require an API key before starting an agent session.""" if not CONFIG["api_key"]: raise SystemExit( 'Missing OPENAI_API_KEY.\nSet the environment variables, for example:\n $env:OPENAI_BASE_URL="https://api.openai.com/v1"\n $env:OPENAI_API_KEY="sk-..."\n $env:MODEL_ID="gpt-4o-mini"\n' ) |
call_llm of mini_agent/llm.py combines system and history into a request and sends it to /chat/completions. Request module import and default prompt (mini_agent/llm.py lines 9–21)
import json
import urllib.error
import urllib.request
from .config import CONFIG
# Fallback system prompt.
# Chapter 7 assembles behavior, environment information, and Markdown skills.
# This constant remains a minimal fallback for direct callers.
SYSTEM_PROMPT = (
"You are a coding agent. "
"Use the provided tools to solve the task. Act, don't explain."
)
The system parameter of call_llm defaults to SYSTEM_PROMPT. The CLI will pass in the full system prompt assembled in Chapter 7; passing None will also use this default constant.
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | def call_llm(messages, tools, system=SYSTEM_PROMPT, signal=None): """Send one Chat Completions request and return the response dictionary. messages is the full conversation history; the model does not retain it between requests. tools contains function schemas. system=None uses the fallback prompt. signal performs cooperative cancellation checks.""" # Chapter 4: check cancellation before sending. # Avoid sending a request after the user has already cancelled. if signal is not None: signal.check() # The protocol represents system instructions as a message. # Prepend it without changing the stored conversation history. system_text = SYSTEM_PROMPT if system is None else system full_messages = [{"role": "system", "content": system_text}] + messages payload = json.dumps( { "model": CONFIG["model"], "max_tokens": CONFIG["max_tokens"], "messages": full_messages, "tools": tools, } ).encode("utf-8") req = urllib.request.Request( f"{CONFIG['base_url']}/chat/completions", data=payload, method="POST", headers={ "Content-Type": "application/json", "Authorization": f"Bearer {CONFIG['api_key']}", }, ) try: with urllib.request.urlopen(req, timeout=CONFIG["request_timeout_s"]) as resp: data = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: detail = e.read().decode("utf-8", errors="replace")[:500] raise RuntimeError('LLM request failed {0}: {1}'.format(e.code, detail)) from e except urllib.error.URLError as e: raise RuntimeError('LLM connection failed: {0}'.format(e.reason)) from e # Chapter 4: check cancellation again after receiving the response. # Discard the response if cancellation occurred while waiting. # urllib's blocking read cannot be interrupted by this check. # Immediate transport cancellation would require a different request mechanism. if signal is not None: signal.check() return data |
extract_message, extract_text, extract_tool_calls; the message retains content and tool call information. ① The model just wants to talk: tool_calls does not exist at all, content is text
{
"id": "a5d97c0de647ad8fd550f43b9cd98d52",
"object": "chat.completion",
"model": "deepseek/deepseek-v4.1-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello",
"reasoning_content": "...(Here lies the thinking process of the model, which is not read here)"
},
"finish_reason": "stop"
}
]
}
② Model requests a tool: There is an extra tool_calls array, content is empty
{
"id": "31627bb7940ad33d1b3bba47858e514b",
"object": "chat.completion",
"model": "deepseek/deepseek-v4.1-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_00_wo91vwl3s4wkiiidgyvi9fln",
"type": "function",
"function": {
"name": "bash",
"arguments": "{\"command\": \"ls -la\"}"
}
}
],
"reasoning_content": "......(same as above, don't read)"
},
"finish_reason": "tool_calls"
}
]
}
These two captured responses omit bookkeeping fields such as usage and created. Notice three details: the response is inside the choices array; finish_reason is a sibling of message; and arguments is a JSON string that needs json.loads(). A tool-calling response may have content set to null or "", so extract text with content or "".
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | def extract_message(response): """Return the complete assistant message, including content and tool_calls.""" return response["choices"][0]["message"] def extract_text(response): """Return assistant text, using an empty string when content is null.""" return extract_message(response).get("content") or "" def extract_tool_calls(response): """Return tool calls, or an empty list when the assistant requests none. The loop uses these calls to decide whether to continue, rather than depending on provider-specific finish_reason metadata.""" return extract_message(response).get("tool_calls") or [] |
agent_loop of mini_agent/agent.py: request model, save reply, execute tool, and refill results. mini_agent/tools.py; here we take the shell tool as an example, and other tools are placed in Chapter 2. Import of the main loop module (mini_agent/agent.py lines 10–17)
from concurrent.futures import ThreadPoolExecutor import json from . import llm from . import tools as toolbox from .control import Aborted, Signal
Default event callback (mini_agent/agent.py lines 30–54)
def default_emit(kind, **kw):
"""Print basic loop events; the CLI supplies its own renderer."""
if kind == "text":
print(kw["text"])
elif kind == "tool_start":
print(f" → {kw['name']}({_brief(kw.get('input'))})")
elif kind == "tool_end":
pass # Let the CLI decide whether to show long tool output.
elif kind == "aborted":
print('[Aborted] {0}'.format(kw.get('reason')))
elif kind == "max_turns":
print('[Turn limit reached: {0}]'.format(kw['limit']))
def _brief(inp, limit=70):
"""Summarize tool arguments on one line for terminal output."""
if not isinstance(inp, dict):
return repr(inp)[:limit]
parts = []
for k, v in inp.items():
s = str(v).replace("\n", "\\n")
if len(s) > 30:
s = s[:30] + "…"
parts.append(f"{k}={s}")
return ", ".join(parts)[:limit]
Shell tool declaration (mini_agent/tools.py lines 36–53)
TOOLS = [
_function(
"bash",
"Run a shell command.",
{"command": {"type": "string"}},
["command"],
),
_function(
"read",
"Read a file's text content.",
{
"path": {"type": "string"},
"offset": {"type": "integer", "description": "1-indexed start line"},
"limit": {"type": "integer", "description": "max lines to read"},
},
["path"],
),
The above are from agent.py and tools.py respectively; the tool implementation is no longer placed in agent.py.
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | def tool_bash(inp): """Run a shell command with a literal blacklist, timeout, and output limit.""" command = str(inp.get("command", "")) if any(pattern in command for pattern in BLOCKED_PATTERNS): return "Error: Dangerous command blocked" try: proc = subprocess.run( command, shell=True, cwd=os.getcwd(), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=CONFIG["bash_timeout_s"], ) except subprocess.TimeoutExpired: return f"Error: command timed out after {CONFIG['bash_timeout_s']}s" output = f"{proc.stdout or ''}{proc.stderr or ''}".strip() if not output: return "(no output)" return output[: CONFIG["max_output_chars"]] |
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | # Step 5: ask the model. emit("request", turn=turn, history_len=len(messages)) response = llm.call_llm(messages, tools, system=system, signal=signal) # Step 6: save the complete assistant message. # Keep tool_calls as well as content. # Their IDs pair with tool results in the next request. _append(messages, llm.extract_message(response), on_append) text = llm.extract_text(response) if text: emit("text", text=text) calls = llm.extract_tool_calls(response) # Step 7a: no tool calls means the task is done. if not calls: emit("done", turns=turn) break # Step 7b: check cancellation after the assistant declares tools. # # Do not simply break: the assistant call is already in history. # Each declared tool call must receive a matching result. # Otherwise the next request has invalid message ordering. # Providers may report an unmatched tool_call_id. # # Append an interrupted result for every pending call before exiting. # One call requires one matching tool message. if signal.aborted: for c in calls: _append( messages, { "role": "tool", "tool_call_id": c.get("id"), "content": f"Error: aborted ({signal.reason})", }, on_append, ) emit("aborted", reason=signal.reason) break # Step 8: execute tools and append one message per result. # Multiple calls need separate tool messages with their own IDs. # Keep their order consistent with the declared tool calls. results = execute_tool_calls(calls, signal=signal, emit=emit, executor=executor) for r in results: _append(messages, {"role": "tool", **r}, on_append) return messages |
build_parser and main of mini_agent/cli.py are responsible for startup; interactive input is handled by App.repl. main directly calls the loop, and the interactive mode enters the App. CLI import (mini_agent/cli.py lines 10–17)
import argparse import os import sys import threading from . import agent as agent_mod from . import compaction, prompt, session as session_mod from .config import CONFIG, require_api_key, setup_console
These imports correspond to parameter parsing, background threads, sessions, prompts and loop calls, all from the current cli.py.
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 | def build_parser(): p = argparse.ArgumentParser( prog="mini_agent", add_help=False, description='A minimal coding agent (educational project)', ) p.add_argument("-h", "--help", action="help", help='Show this help message and exit') p.add_argument("-c", "--continue", dest="continue_", action="store_true", help='Continue the most recent session') p.add_argument("--resume", metavar="SESSION_ID", help='Resume a saved session') p.add_argument("--max-turns", type=int, default=None, metavar="N", help='Maximum model calls per conversation turn') p.add_argument("-v", "--verbose", action="store_true", help='Show tool output') p.add_argument("-p", "--print", dest="oneshot", metavar="PROMPT", help='Run a task once and exit without entering interactive mode') return p def main(argv=None): setup_console() args = build_parser().parse_args(argv) require_api_key() if args.oneshot: # One-shot mode runs the task and exits for script use. app = App(args) app.session.append("user", args.oneshot) agent_mod.agent_loop( app.session.messages, system=app.system_prompt, signal=app.signal, max_turns=args.max_turns, emit=app.render, executor=app.executor, on_append=app.session.persist, before_request=app.before_request, ) return 0 App(args).repl() return 0 |
In the loop in Chapter 1, the execution tool section probably looks like this:
if name == "bash": result = run_bash(inp) elif name == "read": result = read_file(inp) elif name == "write": result = write_file(inp) else: result = "unknown tool"
This can run. But it has two problems, both of which worsen as the project gets larger.
Every time you add a tool, you need to change the main loop. The main loop is the file that should be least touched in the entire project - it carries the functions of all subsequent chapters (interruption, compaction, persistence), and if you touch it once, you must re-verify all behaviors.
The solution is the classic table driver: save "Name → Implementation" as a dictionary, and write only one line in the loop: submit(name, input). Adding tools = changing the table, not the logic. Here, tool calls are separated from specific implementations.
File does not exist, command timeout, path out of bounds - these are normal in the agent scenario, not abnormal situations. They can happen at any time because the caller is a fallible model.
If an exception is thrown directly, the entire agent will die and the user will only see a stack. And if the error message is returned to the model as a tool result, it will understand it and change its approach - for example, if it sees "the file does not exist", it will list what is in the directory.
Here is a counter-intuitive but important judgment: The responsibility for error handling is transferred. In traditional programs, errors are handled by the caller; in agents, errors are input to the model. So the tool always returns a string and never throws an exception.
The tools parameter of the API requires each tool to provide: name, description, and a JSON Schema description parameter. This statement will be serialized into the request body as it is, and the model relies entirely on it to determine "how should I fill in the parameters".
So there must be two tables in your code:
TOOLS - a statement for the model to see. If it is written vaguely, the model will fill in the wrong parameters. HANDLERS - Implementation dictionary for yourself. Both tables must have the same name. If the name doesn't match, the model will see a tool that is "declared to exist, but must fail when called" - this kind of bug is very hidden, because the model will not say "this tool is not implemented", it will only try again and again.
A natural idea is: to read a file, execute cat xxx, and to write a file, execute echo ... > xxx. Don't do this for three reasons:
cat *.py will be expanded into a bunch of files by the shell, and the original intention of the model may be a file called *.py. open(), OSError will clearly tell you the reason. A path is a path and content is content. The shell is only reserved for scenarios where you really need to run commands (calling git, running tests).
The model may give ../../etc/passwd, or C:\Windows\System32\.... It's not malicious, it's just guessing. But your tool can't really read it.
The solution is to resolve the path into an absolute path and then confirm that it falls within the subtree of the working directory. The key is to use os.path.realpath instead of abspath - the former will expand the symbolic link. Otherwise a soft link pointing to the outside can bypass the check.
TOOLS is a statement (name, description, parameter schema) for model; HANDLERS is an implementation table for self. The two must have the same name, otherwise the model will see a tool that "exists but cannot be called". (input: dict) -> str so that all tools fit into the same table. "Error: ..." is returned to the model, allowing it to think of other solutions - this is much more useful than crashing the program. cat xxx: there is no quotation mark escaping problem, no glob expansion, the path is the path. This chapter introduces tools.py and routes execution through submit(). The agent can read, write, edit, and run commands; later tools can be added without adding branches to the loop.
TOOLS declares the interface for the model; HANDLERS and submit() implement dispatch. safe_path() constrains file-tool paths.tools.submit(name, args), so the loop does not need a branch for every tool._function and TOOLS of mini_agent/tools.py, and declare the four tools bash, read, write, and edit. Import of tool module (mini_agent/tools.py lines 8–12)
import os import subprocess from .config import BLOCKED_PATTERNS, CONFIG from .mutation_queue import mutation_queue
parameters of read: three keys, each responsible for one thing
{
"type": "object", ← The whole is an object (= Python's dict)
"properties": { ← Field by field: key = parameter name, value = field's own schema
"path": {"type": "string", "description": "The path of the file to be read"},
"offset": {"type": "integer", "description": "Start reading from which line, counting from 1"},
"limit": {"type": "integer", "description": "Maximum number of lines to read"}
},
"required": ["path"] ← Only path is required; offset / limit are not included in this array = optional
}
The dependency of the file header comes from the current tools.py. The following schema example is used to explain the parameter structure.
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | def _function(name, description, properties, required): """Build an OpenAI-style function tool schema.""" return { "type": "function", "function": { "name": name, "description": description, "parameters": { "type": "object", "properties": properties, "required": required, }, }, } TOOLS = [ _function( "bash", "Run a shell command.", {"command": {"type": "string"}}, ["command"], ), _function( "read", "Read a file's text content.", { "path": {"type": "string"}, "offset": {"type": "integer", "description": "1-indexed start line"}, "limit": {"type": "integer", "description": "max lines to read"}, }, ["path"], ), _function( "write", "Write content to a file (overwrites the whole file).", { "path": {"type": "string"}, "content": {"type": "string"}, }, ["path", "content"], ), _function( "edit", "Replace old_string with new_string in a file. " "old_string must appear exactly once in the file.", { "path": {"type": "string"}, "old_string": {"type": "string"}, "new_string": {"type": "string"}, }, ["path", "old_string", "new_string"], ), ] |
safe_path uses realpath to resolve the path and then checks whether the target is in the working directory. 81 82 83 84 85 86 87 88 89 90 | def safe_path(raw): """Resolve a path and reject targets outside the working directory. realpath also resolves symbolic links. Raise ValueError for an outside path. This check applies to file tools; it does not sandbox shell commands.""" workdir = os.path.realpath(os.getcwd()) target = os.path.realpath(os.path.join(workdir, raw or ".")) if target != workdir and not target.startswith(workdir + os.sep): raise ValueError(f"path outside workdir: {raw}") return target |
tool_bash, tool_read, and tool_write. The input parameters are all dictionaries and the return values are text. Fill in this key in config.py in Chapter 1 (required for read)
CONFIG = {
#...the keys for Chapter 1 are retained, and only one line is added here.
"max_read_lines": 2000,
}
tool_read reads the key in CONFIG. There is no config.py in the version of Chapter 1. If it is omitted, KeyError will be thrown directly when reading the file. 2000 means "read up to 2000 lines at a time". For longer files, the model uses offset / limit to paginate by itself.
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | def tool_bash(inp): """Run a shell command with a literal blacklist, timeout, and output limit.""" command = str(inp.get("command", "")) if any(pattern in command for pattern in BLOCKED_PATTERNS): return "Error: Dangerous command blocked" try: proc = subprocess.run( command, shell=True, cwd=os.getcwd(), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=CONFIG["bash_timeout_s"], ) except subprocess.TimeoutExpired: return f"Error: command timed out after {CONFIG['bash_timeout_s']}s" output = f"{proc.stdout or ''}{proc.stderr or ''}".strip() if not output: return "(no output)" return output[: CONFIG["max_output_chars"]] def tool_read(inp): """Read numbered UTF-8 text with offset/limit pagination, without a shell.""" try: abs_path = safe_path(str(inp.get("path", ""))) except ValueError as e: return f"Error: {e}" try: with open(abs_path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() except OSError as e: return f"Error: {e}" offset = int(inp.get("offset") or 1) # 1-indexed limit = int(inp.get("limit") or CONFIG["max_read_lines"]) start = max(0, offset - 1) selected = lines[start : start + limit] # Include line numbers so the model can reference exact locations. numbered = "".join( f"{start + i + 1:>6}\t{line}" for i, line in enumerate(selected) ) remaining = len(lines) - (start + len(selected)) if remaining > 0: numbered += f"\n... ({remaining} more lines)" return numbered or "(empty file)" |
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | def tool_write(inp): """Overwrite a whole file under the shared per-path mutation lock.""" path = str(inp.get("path", "")) content = str(inp.get("content", "")) try: abs_path = safe_path(path) except ValueError as e: return f"Error: {e}" def mutate(): try: os.makedirs(os.path.dirname(abs_path) or ".", exist_ok=True) with open(abs_path, "w", encoding="utf-8", newline="") as f: f.write(content) except OSError as e: return f"Error: {e}" return f"Wrote {len(content)} bytes to {path}" return mutation_queue.run(abs_path, mutate) |
tool_edit reads the content first, then checks the number of matches in old_string, and replaces only the unique match. 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | def tool_edit(inp): """Replace one exact match under the shared per-path mutation lock. Read, count matches, and write within the same protected operation. Require exactly one old_string match to avoid changing an unintended occurrence.""" path = str(inp.get("path", "")) old_string = str(inp.get("old_string", "")) new_string = str(inp.get("new_string", "")) if not old_string: return "Error: old_string cannot be empty" try: abs_path = safe_path(path) except ValueError as e: return f"Error: {e}" def mutate(): try: with open(abs_path, "r", encoding="utf-8", errors="replace") as f: content = f.read() except OSError as e: return f"Error: {e}" count = content.count(old_string) if count == 0: return f"Error: old_string not found in {path}" if count > 1: return ( f"Error: old_string appears {count} times in {path}, must be unique. " "Include more surrounding context to make it unique." ) try: with open(abs_path, "w", encoding="utf-8", newline="") as f: f.write(content.replace(old_string, new_string, 1)) except OSError as e: return f"Error: {e}" return f"Edited {path}" # Protect the entire read, match, and write operation. return mutation_queue.run(abs_path, mutate) |
submit(name, inp) finds the handler function from HANDLERS; tool errors are converted into text and returned. _run_one(call, signal, emit) of mini_agent/agent.py parses the arguments and then calls toolbox.submit. Loop through default configuration and tool table (mini_agent/agent.py lines 164–166)
tools = toolbox.TOOLS if tools is None else tools
emit = emit or default_emit
signal = signal or Signal()
When tools are not passed in explicitly, toolbox.TOOLS is used, consistent with the declaration of the tool module.
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | HANDLERS = { "bash": tool_bash, "read": tool_read, "write": tool_write, "edit": tool_edit, } def submit(name, inp): """Dispatch a tool by name and return its result as text. Return errors to the model so it can correct a call instead of crashing the agent. Unexpected handler exceptions are also converted to result text.""" handler = HANDLERS.get(name) if handler is None: known = ", ".join(sorted(HANDLERS)) return f"Error: unknown tool '{name}'. Available tools: {known}" try: return handler(inp) except Exception as e: # Convert unexpected handler failures into model-visible errors. return f"Error: {type(e).__name__}: {e}" |
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | def _run_one(call, signal, emit): """Parse one call's JSON arguments, execute it, and return its result. Convert cancellation and tool exceptions to result text so every recorded assistant tool call can still receive its matching tool message.""" fn = call.get("function") or {} name = fn.get("name") raw_args = fn.get("arguments") if isinstance(raw_args, str): try: inp = json.loads(raw_args) except json.JSONDecodeError: inp = {} else: inp = raw_args or {} if not isinstance(inp, dict): inp = {} emit("tool_start", name=name, input=inp) try: signal.check() # Chapter 4: check cancellation before execution too. output = toolbox.submit(name, inp) except Aborted as e: # Convert cancellation to a tool result rather than propagating it here. # Every assistant tool call must have a corresponding tool message. # A missing result would make the next request invalid. output = f"Error: aborted ({e})" except Exception as e: # Convert unexpected tool failures to result text. output = f"Error: {type(e).__name__}: {e}" emit("tool_end", name=name, output=output) return {"tool_call_id": call.get("id"), "content": output} |
edit#1: Read "A" ────────────┐ edit#2: Reads "A" ─────┼── Both reads A edit#1: Write "A1" ─────┘ edit#2: writing "A2" ← overwrites edit#1
The tools in the first two chapters are serial executed: if the model reads 5 files in one turn, it has to wait for 5 disk IOs. Changing to concurrency is natural—but once you change it, something goes wrong.
Suppose the model issues two edits in one turn, both modifying the same file:
edit#1: Read "A" ──────────────┐ edit#2: Read "A" ───────┼── Both read A edit#1: Write "A1" ──────┘ edit#2: writing "A2" ← overwrites edit#1 and its changes disappear
This is not as simple as "whoever writes last wins" - edit#1's changes were completely lost, and no errors were reported. The model thought that both places had been changed, but in fact only one had been changed. This type of bug is called "lost update" and is the most classic problem in concurrent programming.
Note that the root cause is "read-modify-write" and is not atomic. Edit is not a one-time write, but a three-step process of "reading in → calculating new content → writing back". The two edits are executed in an interleaved manner, and the second step is calculated based on expired content.
This order can easily be reversed. There is no conflict at all when executing serially, and this level of coordination is usually not needed when there is no concurrency; the same implementation here already includes a queue, and Chapter 3 focuses on its role.
If you write the queue first and then concurrency, you will get a piece of code that "seems to be protecting something, but in fact there is no concurrency, so you can never detect the problem" - that is a very bad state: you don't know whether it is correct or not.
The easiest way is to add a global lock and queue all disk writes. It can solve the problem, but it also erases the benefits of concurrency - changing a.py and changing b.py are obviously unrelated to each other, but they have to wait for each other.
The correct approach is to bucket by file path:
The implementation is a dictionary {path: lock}. When using it, take a handful of setdefault. The relevant implementation is concentrated in FileMutationQueue, but it has three places where it is easy to make mistakes. The following "Implementation Guide" explains each one.
After bucketing by path, there is a more hidden problem: The action of "getting the lock" itself must be serialized. Look at the two lines of code lock = locks.get(key), if lock is None: locks[key] = Lock() - there is a gap in the middle:
Thread A: get(key) → None ──────┐ Thread B: get(key) → None ──┼── Both found "no lock yet" Thread A: Create lock A and store it in ────────┘ Thread B: Create lock B and save it ← overwrite lock A Result: Two threads each take a different lock to modify the same file
This is why there are two locks in the module: _registry to protect the dictionary itself, and _locks[key] to protect a certain file. The lock that protects the "lock" and the lock itself are two different things - without the former, the queue does not exist.
These races can be intermittent and appear only under particular timing. Reason about the complete critical section instead of relying on one successful run.
By the way: the corresponding file-mutation-queue.ts in the real project pi only has 61 lines. This kind of "few but critical" concurrency primitives are short in length but have a reason for each line, and are worth reading line by line.
./a.txt, a.txt, and symbolic links will be treated as three different keys, and a lock will be built for each, and the queue will be useless. This chapter introduces mutation_queue.py, wraps file mutations in tools.py, and executes tools with a thread pool in agent.py. Different files can be processed concurrently while same-file read–modify–write operations remain serialized.
run(path, fn) coordinates mutations of the same file.write and edit pass the complete operation to mutation_queue.run(); the lock covers reading and checking as well as writing.ThreadPoolExecutor runs tools concurrently and collects results in the original call order.FileMutationQueue._key_for and use the normalized absolute path as the bucket key. 13 14 15 16 17 18 19 20 21 22 23 24 25 | class FileMutationQueue: """Serialize file mutations using a lock per normalized absolute path.""" def __init__(self): # Normalized path key -> lock. self._locks = {} # Protect the lock registry itself. self._registry = threading.Lock() @staticmethod def _key_for(path): """Normalize existing or new file paths with abspath and realpath.""" return os.path.realpath(os.path.abspath(path)) |
_registry protection lock table: the same key shares the same lock. 29 30 31 32 33 34 35 36 | key = self._key_for(path) # Serialize lock creation so same-path callers share one lock. with self._registry: lock = self._locks.get(key) if lock is None: lock = threading.Lock() self._locks[key] = lock |
run(path, fn) calls fn after obtaining the lock of the target file; different keys use different locks. 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | def run(self, path, fn): """Run the no-argument mutation fn under the target path's lock.""" key = self._key_for(path) # Serialize lock creation so same-path callers share one lock. with self._registry: lock = self._locks.get(key) if lock is None: lock = threading.Lock() self._locks[key] = lock # Serialize operations per path; different paths use different locks. with lock: return fn() |
tool_edit and tool_write hand over the actual change action to mutation_queue.run. 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | def tool_edit(inp): """Replace one exact match under the shared per-path mutation lock. Read, count matches, and write within the same protected operation. Require exactly one old_string match to avoid changing an unintended occurrence.""" path = str(inp.get("path", "")) old_string = str(inp.get("old_string", "")) new_string = str(inp.get("new_string", "")) if not old_string: return "Error: old_string cannot be empty" try: abs_path = safe_path(path) except ValueError as e: return f"Error: {e}" def mutate(): try: with open(abs_path, "r", encoding="utf-8", errors="replace") as f: content = f.read() except OSError as e: return f"Error: {e}" count = content.count(old_string) if count == 0: return f"Error: old_string not found in {path}" if count > 1: return ( f"Error: old_string appears {count} times in {path}, must be unique. " "Include more surrounding context to make it unique." ) try: with open(abs_path, "w", encoding="utf-8", newline="") as f: f.write(content.replace(old_string, new_string, 1)) except OSError as e: return f"Error: {e}" return f"Edited {path}" # Protect the entire read, match, and write operation. return mutation_queue.run(abs_path, mutate) |
execute_tool_calls(calls, *, signal, emit, executor=None) receives the thread pool; multiple tasks are executed concurrently and the results are collected according to the submission list. make_executor creates a thread pool, which is created when the CLI is initialized and passed in when the loop is called. Create thread pool (mini_agent/agent.py lines 248–250)
def make_executor(max_workers=MAX_PARALLEL_TOOLS):
"""Create a tool thread pool that the CLI reuses for the session."""
return ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="tool")
Execute and refill tool results (mini_agent/agent.py lines 241–243)
results = execute_tool_calls(calls, signal=signal, emit=emit, executor=executor)
for r in results:
_append(messages, {"role": "tool", **r}, on_append)
CLI initialization thread pool and other status (mini_agent/cli.py lines 74–83)
def __init__(self, args):
self.args = args
self.session = self._open_session()
self.signal = Signal()
self.steering = SteeringQueue()
self.executor = agent_mod.make_executor()
self.render = make_renderer(args.verbose)
self.skills = prompt.load_skills()
self.system_prompt = prompt.build_system_prompt(self.skills)
self.before_request = self._make_before_request()
worker passes thread pool to loop (mini_agent/cli.py lines 142–154)
def worker():
try:
agent_mod.agent_loop(
self.session.messages,
system=self.system_prompt,
signal=self.signal,
steering=self.steering,
max_turns=self.args.max_turns,
emit=self.render,
executor=self.executor,
on_append=self.session.persist, # Chapter 5: persist only; the loop already appended in memory.
before_request=self.before_request, # Chapter 6: compact before requests.
)
Crap at the end of interaction (mini_agent/cli.py lines 262–263)
self.executor.shutdown(wait=False)
print('{0}Session saved: {1}{2}'.format(DIM, self.session.path, RESET))
These are the actual call points of the current implementation, and the parameter names remain consistent with the source files.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | def execute_tool_calls(calls, *, signal, emit, executor=None): """Execute tool calls and return result dictionaries in call order. Each result contains tool_call_id and content. The caller adds role='tool'. The executor allows concurrent work; results retain the original call order.""" if not calls: return [] # A single call or no executor: execute sequentially. if len(calls) == 1 or executor is None: return [_run_one(c, signal, emit) for c in calls] # Run concurrently, but collect results in the original call order. futures = [executor.submit(_run_one, c, signal, emit) for c in calls] return [f.result() for f in futures] |
tool_calls and no corresponding role="tool" message will be left in the history - The next request will be directly rejected by the API. A turn can take tens of seconds while the model and tools run. If the main thread waits for the entire turn, it cannot read new instructions. Running the agent in a worker thread leaves the main thread available for interruption and steering.
To solve this, you need two different things: Interrupt (I don’t want you to continue) and Turn (I want you to change direction). They look like the same thing, but the mechanics are completely different, hence the two classes.
Interruptions may occur deeply: in a utility function that is waiting for an HTTP response at urlopen, waiting for a child process to end, or recursively.
If propagated by return value, the signature of each function must become -> tuple[result, aborted], and each layer must be checked and passed up. This will pollute the code beyond sight, and as long as one layer is missed, the interrupt will fail.
Anomalies are designed to "jump out directly across many levels". Therefore, exceptions are used for interruption: caught at the outermost layer and thrown at the checkpoint.
The cost is that exceptions will skip the normal cleanup logic, so the location of the checkpoint must be carefully selected - it cannot be left in an intermediate state such as "the memory has been changed and the disk has not been written yet."
In Chapter 4, you must also put the agent in a background thread to run (the main thread can continue to read your input). So the interrupt flag becomes a mutable state shared across threads.
A plain self.flag = True is dangerous here: Python has the GIL, and simple assignments generally won't read torn values, but there are no guarantees about memory visibility and instruction reordering. threading.Event has a lock inside, which is standard practice and also expresses the intention of "this is a synchronization point".
You may type two or three sentences in a row while the agent is running. If stored in a single variable, the latter sentence will overwrite the previous sentence and the previous sentence will be lost. So use queue.
What’s more critical is the timing of taking away: the request has already been sent, and your sentence cannot be inserted into the current turn. You can only wait until the next turn begins to be inserted into history. So the queue must be cleared once at the beginning of each turn of the loop.
The action of "clearing" must be atomic: if len() is judged first and then pop is passed one by one, and the main thread pushes another one in the middle, it may be missed or repeated. Therefore, we need to use the method of "taking it all at once and replacing it with an empty list" instead of popping in a loop.
Here you need to pay attention to the pairing of tool calls and results, which can be checked with tests.
Consider this sequence: the model requests the read tool, the loop records the assistant message unchanged, and then you press Ctrl+C before a corresponding result is recorded.
Historically it is now:
assistant {tool_calls: [{id: "call_A", ...}]} ← Saved
(There is no corresponding role="tool" message) ← The tool did not run because it was interrupted.
Next request → API reject:
Assistant message with tool_calls must be followed
by tool messages for each tool_call_id
The naive way to write it is to write break directly at the interrupt check point. But then the orphan tool_call will be left in history - the next request will be completely rejected by the API, and the error message will not point to the real reason at all (the user will think it is a problem with the model or network).
Correct approach: When interrupted, add a role="tool" message to each unexecuted call. Use tool_call_id for the content and "Error: aborted" for the text, and then exit. In this way history is always legitimate.
The important invariant is the same as in Chapter 1: every recorded tool call needs a matching result. Adding interruption must preserve that pairing even when normal execution stops.
role="tool" message after interruption. Here you need to ensure that tool calls and results are completely matched. This chapter introduces control.py, adds cancellation checks to the loop and model requests, and organizes the CLI around App. The agent runs in a worker thread while the main thread accepts steering input and Ctrl+C.
Aborted, Signal, and SteeringQueue provide cooperative interruption and atomic draining of queued input.role="tool" result for every interrupted call already present in history.App manages state, input, and slash commands. The worker runs the agent while the main thread reads input or signals interruption.mini_agent/control.py to store interruption status and supplementary input respectively. 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | class Aborted(Exception): """Raise at cancellation checkpoints to unwind nested operations.""" def __init__(self, reason="aborted"): super().__init__(reason) self.reason = reason class Signal: """Share cancellation state across threads with threading.Event.""" def __init__(self): self._event = threading.Event() self.reason = None def abort(self, reason="user aborted"): """Request cancellation; preserve the first recorded reason.""" if not self._event.is_set(): self.reason = reason self._event.set() @property def aborted(self): """Return whether cancellation has been requested, without raising.""" return self._event.is_set() def check(self): """Raise Aborted if cancellation has been requested.""" if self._event.is_set(): raise Aborted(self.reason) |
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | class SteeringQueue: """Store follow-up instructions until the next model request. The main thread pushes input while the agent thread drains the queue. A list preserves multiple instructions instead of overwriting older input.""" def __init__(self): self._items = [] self._lock = threading.Lock() def push(self, text): """Enqueue one instruction from the input thread.""" text = (text or "").strip() if not text: return with self._lock: self._items.append(text) def drain(self): """Atomically take all instructions and clear the queue.""" with self._lock: items, self._items = self._items, [] return items |
agent_loop checks the interrupt at the beginning, reads the steering queue and determines the upper limit of the number of turns. _run_one will also check the signal. 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | turn = 0 while True: # Step 1: cancellation check (chapter 4). if signal.aborted: emit("aborted", reason=signal.reason) break # Step 2: drain steering instructions (chapter 4). # Add follow-ups before asking the model so the next request includes them. if steering is not None: for text in steering.drain(): _append(messages, {"role": "user", "content": text}, on_append) emit("steering", text=text) # Step 3: model-call limit (chapter 4). turn += 1 if max_turns is not None and turn > max_turns: emit("max_turns", limit=max_turns) break |
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | # Step 7b: check cancellation after the assistant declares tools. # # Do not simply break: the assistant call is already in history. # Each declared tool call must receive a matching result. # Otherwise the next request has invalid message ordering. # Providers may report an unmatched tool_call_id. # # Append an interrupted result for every pending call before exiting. # One call requires one matching tool message. if signal.aborted: for c in calls: _append( messages, { "role": "tool", "tool_call_id": c.get("id"), "content": f"Error: aborted ({signal.reason})", }, on_append, ) emit("aborted", reason=signal.reason) break |
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | emit("tool_start", name=name, input=inp) try: signal.check() # Chapter 4: check cancellation before execution too. output = toolbox.submit(name, inp) except Aborted as e: # Convert cancellation to a tool result rather than propagating it here. # Every assistant tool call must have a corresponding tool message. # A missing result would make the next request invalid. output = f"Error: aborted ({e})" except Exception as e: # Convert unexpected tool failures to result text. output = f"Error: {type(e).__name__}: {e}" emit("tool_end", name=name, output=output) return {"tool_call_id": call.get("id"), "content": output} |
call_llm Check signal before sending request and after receiving response. 31 32 33 34 35 36 37 38 | # Avoid sending a request after the user has already cancelled. if signal is not None: signal.check() # The protocol represents system instructions as a message. # Prepend it without changing the stored conversation history. system_text = SYSTEM_PROMPT if system is None else system full_messages = [{"role": "system", "content": system_text}] + messages |
68 69 70 71 72 73 74 75 | # Chapter 4: check cancellation again after receiving the response. # Discard the response if cancellation occurred while waiting. # urllib's blocking read cannot be interrupted by this check. # Immediate transport cancellation would require a different request mechanism. if signal is not None: signal.check() return data |
App.__init__ saves sessions, signals, queues, thread pools and renderers; App.repl reads ordinary input and commands. App.run_turn starts the background worker, and the main thread continues to read supplementary input. App initialization state (mini_agent/cli.py lines 74–83)
def __init__(self, args):
self.args = args
self.session = self._open_session()
self.signal = Signal()
self.steering = SteeringQueue()
self.executor = agent_mod.make_executor()
self.render = make_renderer(args.verbose)
self.skills = prompt.load_skills()
self.system_prompt = prompt.build_system_prompt(self.skills)
self.before_request = self._make_before_request()
Conversation, compaction, and prompt wiring explained in Chapters 5–7 are also included here.
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | def repl(self): print('{0}mini_agent{1} {2}(/help for commands, Ctrl+C to interrupt, /exit to quit){3}'.format(BOLD, RESET, DIM, RESET)) print('{0}Working directory: {1}{2}'.format(DIM, os.getcwd(), RESET)) print('{0}Model: {1} Session: {2}{3}'.format(DIM, CONFIG['model'], self.session.id, RESET)) while True: try: user_text = input('\n{0}{1}You >{2} '.format(BOLD, CYAN, RESET)).strip() except (EOFError, KeyboardInterrupt): print() break if not user_text: continue if user_text.startswith("/"): try: self.handle_command(user_text) except SystemExit: break continue self.run_turn(user_text) self.executor.shutdown(wait=False) print('{0}Session saved: {1}{2}'.format(DIM, self.session.path, RESET)) |
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | def run_turn(self, user_text): """Run user input in a background thread while accepting follow-ups.""" self.signal.reset() self.session.append("user", user_text) done = threading.Event() def worker(): try: agent_mod.agent_loop( self.session.messages, system=self.system_prompt, signal=self.signal, steering=self.steering, max_turns=self.args.max_turns, emit=self.render, executor=self.executor, on_append=self.session.persist, # Chapter 5: persist only; the loop already appended in memory. before_request=self.before_request, # Chapter 6: compact before requests. ) except Exception as e: print('{0}[Error] {1}: {2}{3}'.format(RED, type(e).__name__, e, RESET)) finally: done.set() thread = threading.Thread(target=worker, daemon=True, name="agent") thread.start() # Keep reading input while the background agent runs. while not done.is_set(): try: line = input('{0}(Running… add instructions, Ctrl+C to interrupt){1} '.format(DIM, RESET)).strip() except EOFError: self.signal.abort("stdin closed") break except KeyboardInterrupt: print('\n{0}Interrupting…{1}'.format(YELLOW, RESET)) self.signal.abort("user pressed Ctrl+C") break if not line: continue if line.startswith("/"): self.handle_command(line) # Handle slash commands immediately. else: self.steering.push(line) # Queue other input for the next model request. print('{0} ↳ Queued for the next model call{1}'.format(YELLOW, RESET)) thread.join(timeout=1) |
The agents in the first four chapters are forgotten as soon as they pass. The context is precisely the most expensive thing of the agent - it contains the tokens you spent, the files the agent read, and the decisions it made. Throwing it away is a waste of all your money and time.
The most intuitive approach is to serialize the entire messages list into a .json file. But every time you append a message, you have to: read the entire file → parse → push → re-serialize → overwrite the entire file.
After changing to JSONL (one JSON per line), the append is just f.write(line + "\n"). Three reasons, in order of importance:
This idea has a name called Event log (event log): only appending, not modifying, the current status is calculated from the replay log. The WAL of the database and the commit history of Git are all the same routine.
Appending cannot express every history update. Chapter 6 replaces older messages with one summary, so the session needs a method that rewrites the complete history.
If you directly open the original file and overwrite it, a crash in the middle of writing will directly destroy the entire session. So write xxx.jsonl.tmp first, and then os.replace(tmp, path) after writing.
os.replace is atomic on both Windows and POSIX: either you see the old file or you see the new file, there is no intermediate state. This is a standard way to ensure that data is not damaged in the event of a crash.
Persistence involves two distinct actions: adding a message to the in-memory history and writing its JSONL record to disk. The loop owns the first action; the session hook owns the second. Connecting the wrong method performs the in-memory append twice.
First look at the calling timing of the hook. It is in the _append indirection layer in the loop, with four lines in total:
def _append(messages, msg, on_append):
messages.append(msg) ← ① The loop itself has put the messages into memory
if on_append: on_append(msg) ← ② The hook is just a "notification": I have finished changing the memory
The key is ②: when the hook is called, the message is already in memory. So the only thing the hook should do is "write it to the disk" - this is exactly the semantics of persist (only writes to the disk, does not touch the memory). If it is connected to append_message (its semantics is "add an item": both memory + disk are moved), the same action will be done twice:
Loop: messages.append(msg) ← Loop plus the first pass
→ on_append(msg)
→ session.append_message(msg)
→ self.messages.append(msg) ← session added a second time (repeat!)
self._write_line(msg) ← Only one line is written to the disk
Follow one turn containing a user message, an assistant tool call, and a tool result. When the result has been recorded, the difference between the correct and incorrect hooks is visible below:
memory disk
user 1 item 1 line (CLI uses session.append, the same as the starting point)
assistant 2 items ← repeated 1 line
tool 2 items ← duplicated 1 line
───── ─────
5 items 3 lines ← From here on the two sides do not match up
Notice which side is wrong: the three records on disk are correct, but memory contains duplicates. The next model request sends the duplicated assistant call and tool result. A strict endpoint may reject their ordering, while a permissive endpoint receives polluted context. Using persist keeps three in-memory messages aligned with three disk records.
Omitting on_append causes the opposite problem: memory contains the whole turn, but disk contains only the user message written by the CLI. Restoring with --continue then loses the assistant and tool messages. Both failures come from an unclear division of responsibilities.
Keep in mind the division of labor among the three methods. If you choose the wrong step, you will fall into the trap above:
session.append(role, content) - "I want to add a new one": Memory + disk both move. Used by the CLI when storing user input into the session. session.append_message(msg) - Same as above, except that the complete dict is collected (used when restoring history from disk). Cannot be used as a hook: This is stated in its docstring. session.persist(msg) - "This item is already in the memory, I am only responsible for writing to the disk": Only touch the disk. This is the only way to use hooks. The root cause is: Both objects think they should change the memory. The solution is to write down the responsibilities into a contract - The loop is responsible for modifying the memory, and the session is only responsible for placing disks. No one will repeat the work of the other. What falls on the CLI is the line on_append=self.session.persist.
This mistake can quietly corrupt history without an immediate error. The useful invariant is simple: after a completed turn, the in-memory messages and the JSONL records should describe the same history.
There is a similar trap. replace_messages To replace the history as a whole, the intuitive way to write it is self.messages = messages. It's also wrong.
Because both agent_loop and CLI hold the original list object. You let self.messages point to a new list, and the old list is still held by them - the subsequent append will be written into a list that no one has read, and the memory and disk will be permanently decoupled.
The correct way to write it is self.messages[:] = messages - the slice assignment is modified in place, and all holders can see the new content. This difference is very common in Python and is worth remembering separately: = changes the object, [:]= changes the content.
append is "add a message", replace_messages is "overall rewriting" (used for compaction in Chapter 6). This chapter introduces session.py and connects it through _append() and on_append. Conversation history survives closing the terminal and can be resumed with --continue. A replacement method also supports later compaction.
_append() owns in-memory history changes; on_append delegates persistence to the session.Session, and how create determines the session file. 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | class Session: """A conversation with an in-memory message list and a JSONL file.""" def __init__(self, session_id, messages, path): self.id = session_id self.messages = messages # The same message list used by agent_loop. self.path = path # Construction. @classmethod def create(cls, session_id=None, session_dir=None): """Create a session, generating an ID when none is provided.""" session_id = session_id or time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6] directory = session_dir or CONFIG["session_dir"] os.makedirs(directory, exist_ok=True) path = os.path.join(directory, f"{session_id}.jsonl") return cls(session_id, [], path) |
Session.load reads JSONL line by line. The current basic implementation will skip and prompt if it encounters lines that cannot be parsed. 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | @classmethod def load(cls, path): """Reload JSONL messages, warning and skipping lines with invalid JSON.""" if not os.path.exists(path): raise FileNotFoundError('Session file not found: {0}'.format(path)) messages = [] skipped = 0 with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: messages.append(json.loads(line)) except json.JSONDecodeError: skipped += 1 session_id = os.path.splitext(os.path.basename(path))[0] if skipped: print('[Warning] Skipped {0} corrupt lines'.format(skipped)) return cls(session_id, messages, path) |
append and append_message modify the memory and write to the disk at the same time; persist only writes to the disk. 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | def append(self, role, content): """Append a role/content message to memory and disk, then return it.""" msg = {"role": role, "content": content} self.messages.append(msg) self._write_line(msg) return msg def append_message(self, msg): """Append a complete message to memory and disk, then return it. Do not use this as the loop's on_append hook: the loop has already appended the message, so doing it here would duplicate the in-memory entry.""" self.messages.append(msg) self._write_line(msg) return msg def persist(self, msg): """Save a message that the loop has already appended to memory. Use this as on_append: the loop changes memory; this method writes to disk.""" self._write_line(msg) return msg |
replace_messages Use slice replacement to keep the list object unchanged, and then rewrite the history through a temporary file. 93 94 95 96 97 98 99 100 101 102 103 104 105 106 | # The loop and caller still hold references to this list object. # Rebinding would leave later appends on an outdated list. # Slice assignment keeps memory and persistence connected. self.messages[:] = messages tmp = self.path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: for msg in messages: f.write(json.dumps(msg, ensure_ascii=False) + "\n") os.replace(tmp, self.path) # Replace the file atomically after writing the temporary copy. # Session inventory. def stats(self): return { |
_append of mini_agent/agent.py is responsible for notifying the persistence hook; App.run_turn is passed into session.persist. App._open_session handles recovery parameters, and /new, /list, /resume in App.handle_command manage sessions. 133 134 135 136 137 | def _append(messages, msg, on_append): messages.append(msg) if on_append is not None: on_append(msg) return msg |
119 120 121 122 123 124 125 126 127 128 129 130 131 | files = [ os.path.join(directory, n) for n in os.listdir(directory) if n.endswith(".jsonl") ] files.sort(key=lambda p: os.path.getmtime(p), reverse=True) return files def latest_session(session_dir=None): """Return the most recently modified session path, or None.""" files = list_sessions(session_dir) return files[0] if files else None |
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | def __init__(self, args): self.args = args self.session = self._open_session() self.signal = Signal() self.steering = SteeringQueue() self.executor = agent_mod.make_executor() self.render = make_renderer(args.verbose) self.skills = prompt.load_skills() self.system_prompt = prompt.build_system_prompt(self.skills) self.before_request = self._make_before_request() # Assembly. def _open_session(self): """Start a new session, continue the latest, or resume a selected ID.""" if self.args.resume: path = self._find_session_path(self.args.resume) loaded = session_mod.Session.load(path) print('{0}Resumed session {1} ({2} messages){3}'.format(DIM, loaded.id, len(loaded.messages), RESET)) return loaded if self.args.continue_: path = session_mod.latest_session() if path: loaded = session_mod.Session.load(path) print('{0}Continuing session {1} ({2} messages){3}'.format(DIM, loaded.id, len(loaded.messages), RESET)) return loaded print('{0}No previous session; starting a new one{1}'.format(DIM, RESET)) return session_mod.Session.create() def _find_session_path(self, session_id): directory = CONFIG["session_dir"] path = os.path.join(directory, f"{session_id}.jsonl") if os.path.exists(path): return path # Allow a session ID prefix as a convenience. for candidate in session_mod.list_sessions(directory): if os.path.basename(candidate).startswith(session_id): return candidate raise SystemExit('Session not found: {0} (use /list to see available sessions)'.format(session_id)) |
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | def run_turn(self, user_text): """Run user input in a background thread while accepting follow-ups.""" self.signal.reset() self.session.append("user", user_text) done = threading.Event() def worker(): try: agent_mod.agent_loop( self.session.messages, system=self.system_prompt, signal=self.signal, steering=self.steering, max_turns=self.args.max_turns, emit=self.render, executor=self.executor, on_append=self.session.persist, # Chapter 5: persist only; the loop already appended in memory. before_request=self.before_request, # Chapter 6: compact before requests. ) except Exception as e: print('{0}[Error] {1}: {2}{3}'.format(RED, type(e).__name__, e, RESET)) finally: done.set() |
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | def handle_command(self, line): parts = line.split(maxsplit=1) cmd = parts[0].lower() arg = parts[1].strip() if len(parts) > 1 else "" if cmd in ("/exit", "/quit", "/q"): raise SystemExit(0) if cmd == "/help": print(HELP_TEXT) elif cmd == "/new": self.session = session_mod.Session.create() print('{0}New session: {1}{2}'.format(DIM, self.session.id, RESET)) elif cmd == "/list": files = session_mod.list_sessions() if not files: print('(No sessions yet)') for path in files[:20]: name = os.path.basename(path) size = os.path.getsize(path) print(f" {name} ({size} bytes)") elif cmd == "/resume": if not arg: print('Usage: /resume <session-id>') else: try: self.session = session_mod.Session.load(self._find_session_path(arg)) print('{0}Switched to {1}{2}'.format(DIM, self.session.id, RESET)) except SystemExit as e: print(e) |
role="tool" messages will be left in the second half, which will also cause the request to be rejected. Chapter 1 settled the account: history can only increase but not decrease, and every turn must send out all history. The cost increases by N²/2, and sooner or later it hits the context window of the model, and the API directly reports an error.
The idea of compaction is very straightforward: Give the oldest piece of history to the model, let it write a summary, and then replace the original text with the summary. The complexity is reduced from O(N) to O(1).
Compaction should retain the original words of recent turns (the details are important), and only compress the older ones. Where should the cutting point be? The intuition is to "cut before a certain message in the middle", but this will cause trouble:
If you cut here ↓
assistant {tool_calls:[{id: "call_A"}]} ← was suppressed
tool {tool_call_id: "call_A", ...} ← Keep
A message with role="tool" without assistant matching tool_calls appears in the history → API rejection
It’s that agreement restriction from Chapter 1 again. So the cut-off point must fall at the beginning of a turn.
How do you define "turn"? One turn = a user question + all assistant tool calls it triggers + the corresponding role="tool" result + the closing assistant text. The criteria must be looked at carefully - the tool result in OpenAI is independent role="tool" message, not user:
{"role": "user", "content": "Help me change the config"} ← The beginning of a new round ✓
{"role": "assistant", "content": ..., "tool_calls": [{...}]}
{"role": "tool", "tool_call_id": "call_A", ...} ← Independent tool role
{"role": "assistant", "content": "Changed"}
Fortunately, the criterion is still very simple: The new question is role == "user" and content is a plain string. The tool result is role="tool", which naturally will not be regarded as the beginning of a turn - so the criterion role == "user" and isinstance(content, str) is clean and reliable, and the trouble of "distinguishing by content type" no longer exists in the OpenAI protocol.
This is_turn_start is the basis of all compaction logic - no error will be reported if it is written incorrectly, it will only be cut in places that should not be cut, and then explode on the next request.
After the cut-off point is determined, there is one parameter left to decide: How much to keep. Keeping less will save you a lot, but also leave less of the original text in the context - this section explains how to choose this number.
This tutorial keeps a fixed number of recent turns: compaction_keep_recent_turns, defaulting to three. find_cut_index collects turn-start positions, counts backward to the third-most-recent start, and cuts immediately before it.
turn_starts = [0, 5, 11, 18, 24] # 5 “beginnings of turns” in history keep=3 cut = turn_starts[len(turn_starts) - keep] = turn_starts[2] = 11 messages[:11] is used to write summary, messages[11:] is kept as it is. Not enough rounds (len(turn_starts) <= keep) → Return None: nothing to stress
Why 3? There is no derivation, it is just a shot - just enough to contain "what I am doing". What is really worth talking about is how to choose this number.
Compacting only enough to fit just below the context limit leaves too little room for the next response and tool output. The next turn can trigger compaction again, repeatedly summarizing summaries and losing detail.
Ask how much useful work can happen before another compaction is needed. Keeping recent complete turns preserves a working area of original messages while creating room for new work.
What unit should be used to measure this work area? All three units have real products in use, and the choices are different:
No matter which unit is used, three principles are common:
tool message (the constraint in the previous section). The real implementations of both sides are below. Click to see the source code and extracted strings - their choices are more sophisticated than the tutorial version, but they are also more complicated.
// 1) Default value: packages/coding-agent/src/core/settings-manager.ts:18-28
const DEFAULT_COMPACTION_TOKEN_SETTINGS: Required<CompactionModelOverride> = {
reserveTokens: 16384,
keepRecentTokens: 20000,
};
export interface CompactionSettings {
enabled?: boolean; // default: true
reserveTokens?: number; // default: 16384
keepRecentTokens?: number; // default: 20000
modelOverrides?: Record<string, CompactionModelOverride>; // exact "provider/modelId" keys
}
// 2) When to compact: packages/agent/src/harness/compaction/compaction.ts:245-249
/** Return whether context usage exceeds the configured compaction threshold. */
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
// 3) What does the compaction point look like: compaction.ts:359-367 - it allows "cutting a round"
/** Cut point selected for compaction. */
export interface CutPointResult {
/** Index of the first entry retained after compaction. */
firstKeptEntryIndex: number;
/** Index of the turn-start entry when the cut splits a turn, otherwise -1. */
turnStartIndex: number;
/** Whether the selected cut point splits an in-progress turn. */
isSplitTurn: boolean;
}
// 4) How much to leave: compaction.ts:369-418
// Accumulate "estimated tokens" from the end forward. Once keepRecentTokens is reached,
// Select the first legal cut point "not less than the current subscript"
/** Find the compaction cut point that keeps approximately the requested recent-token budget. */
export function findCutPoint(
entries: Entry[],
startIndex: number,
endIndex: number,
keepRecentTokens: number,
): CutPointResult {
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
if (cutPoints.length === 0) {
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
let accumulatedTokens = 0;
let cutIndex = cutPoints[0];
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") continue;
const messageTokens = estimateTokens(entry.message as AgentMessage);
accumulatedTokens += messageTokens;
if (accumulatedTokens >= keepRecentTokens) {
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) {
cutIndex = cutPoints[c];
break;
}
}
break;
}
}
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
if (prevEntry.type === "compaction") {
break;
}
if (prevEntry.type === "message") {
break;
}
cutIndex--;
}
const cutEntry = entries[cutIndex];
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
return {
firstKeptEntryIndex: cutIndex,
turnStartIndex,
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
};
}// Knobs that can be adjusted in the configuration (source code paths appearing in the same place: core/src/compact.rs, core/src/compact_token_budget.rs) model_context_window model_auto_compact_token_limit model_auto_compact_token_limit_scope //The value of the previous item: total | body_after_prefix model_post_turn_compact_threshold_percent compact_prompt // When the summary is injected into a new window, the fixed prefix (full text) is set at the front Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis: // When the window is still exceeded after pressing, discard the oldest historical items one by one (the original sentence ends with "Error: " plus a space, followed by a specific error report) Context window exceeded while compacting; removing oldest history item. Error: // Reminder after multiple compressions, and placeholder when the summary cannot be obtained Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted. Post-turn compaction completed without an assistant summary (no summary available) // Tip before the window is completely exhausted (TokenBudgetConfigToml.auto_compact_fallback_prompt, full text) <context_window_reminder> The current context window is exhausted. Do not continue the task or give a final answer in this window. The next window will not automatically include this conversation. Make exactly one write or append call to `notes` now to save a concise checkpoint with the goal, decisions, progress, learnings, next steps, and the window ID and item ID of every relevant user request still being solved, as well as important actions/tool calls for future reference. Note that every non-assistant item, such as user, developer, tool response, has an item id `[id: ...]` that is immediately after its item content. After the notes result returns, call `functions.new_context`; do not use any tools other than `notes` and `functions.new_context`. </context_window_reminder> // Matching default value "auto_compact_fallback_buffer_tokens": 16384 // The first two paragraphs of the permanent prompt guidance_message (there is also a paragraph later about context reset, which is omitted here) For tasks that may span context windows, use `notes` to maintain a concise checkpoint of the goal, decisions, progress, learnings and next steps. Include the window ID and item ID for every relevant user request you are currently solving as well as important actions/tool calls. You can use `history` tool to look up details with the references later. Note that every non-assistant item, such as user, developer, tool response, has an item id `[id: ...]` that is immediately after its item content. Relative note paths belong to the current thread; absolute paths may read other threads' notes, but writes are limited to the current thread. It is a good idea to take incremental notes while you work so that you do not miss any important info. You can also use `get_context_remaining` tool to find the remaining token budget for better planning. Once the token budget is exhausted, you will lose access to the current window and continue in a fresh context window and you can only recover through `notes` and `history` tools. So be careful not to over-run the context window without any documentation.
The three approaches make different trade-offs. This tutorial retains a fixed number of complete turns. pi walks backward using a token budget and handles cuts within a turn. The cited Codex snapshot replaces history with a handoff summary and uses a fixed introduction; its fallback can discard older items if needed. These are historical source comparisons, not promises about current upstream behavior.
A reminder: Compaction is ultimately an operation of "losing information", but it needs to be done smartly. Constraints, paths, and conclusions that are used across many turns are best placed in the files (AGENTS.md, notes, tests) in the project in the turn they appear. Don't expect the summary to remember them for you.
To know exactly how many tokens there are, you have to calculate them literally using the tokenizer of the corresponding model. That means introducing dependencies, and changing the model requires changing the tokenizer.
This tutorial uses character_count / 3 as a rough estimate for mixed English and Chinese. Leave a reserve: an overestimate compacts early, while an underestimate can exceed the context window and fail the request.
But the "number of characters" only counts content. A place that will be missed: tool_calls of assistant (function name + arguments of each call) and tool_call_id of role="tool" message, they also occupy tokens. Without them, the actual capacity of the history will be underestimated and the compaction timing will be delayed.
So there must be a compaction_reserve_tokens (reservation) in the configuration, and the trigger condition is estimated_tokens > context_window - reserve_tokens, not > context_window.
If you just say "summarize this conversation", the model is likely to be written in human-readable prose, leaving out file paths, function names, and reasons for decisions - and those are exactly what will be used in subsequent conversations.
So the summary instruction (SUMMARY_INSTRUCTION) must clearly require:
The summary instructions directly affect which details the agent retains after compaction. Refine them around exact paths, identifiers, decisions, and unfinished work.
Compare the three prompt excerpts below: this tutorial, pi, and the cited Codex version. They make the intended audience and the information to preserve explicit, with different levels of formatting constraints.
SUMMARY_INSTRUCTION (mini_agent/compaction.py)Click to expand the original textYou are compacting a coding session so it can continue in a fresh context. Write a dense handoff summary of the conversation below. You MUST preserve, verbatim where possible: - file paths that were read or modified - names of functions, classes, variables that were changed - decisions made and the reason for them - what is still unfinished Do NOT include chit-chat. Do NOT invent anything not present below. Write it as notes to your future self, not as prose for a human.
SUMMARIZATION_SYSTEM_PROMPT + SUMMARIZATION_PROMPT (packages/agent/src/harness/compaction/compaction.ts)Click to expand the original text── system ── You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary. ── user ── The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. Use this EXACT format: ## Goal [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] ## Constraints & Preferences - [Any constraints, preferences, or requirements mentioned by user] - [Or "(none)" if none were mentioned] ## Progress ### Done - [x] [Completed tasks/changes] ### In Progress - [ ] [Current work] ### Blocked - [Issues preventing progress, if any] ## Key Decisions - **[Decision]**: [Brief rationale] ## Next Steps 1. [Ordered list of what should happen next] ## Critical Context - [Any data, examples, or references needed to continue] - [Or "(none)" if not applicable] Keep each section concise. Preserve exact file paths, function names, and error messages.
codex.exe. The source code path that appears at the same place is core/src/compact.rsClick to expand the original textYou are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. Include: - Current progress and key decisions made - Important context, constraints, or user preferences - What remains to be done (clear next steps) - Any critical data, examples, or references needed to continue Be concise, structured, and focused on helping the next LLM seamlessly continue the work.
Put the three copies together and the difference can be seen at a glance:
Do NOT continue the conversation - to prevent the model from treating the "summary request" as a new task and continue talking. CONTEXT CHECKPOINT COMPACTION), and the second paragraph gives four Include. It writes "who is the reader" directly into the first sentence - this is a handoff summary for another LLM who will continue to work. UPDATE_SUMMARIZATION_PROMPT): when compressing after the second time, new messages will be merged into the previous version of the summary, instead of compressing the old and new history all over again - its first rule is PRESERVE all existing information from the previous summary. If you want to save tokens, this trick is more effective than any wording optimization. Use role="user" for the summary, not assistant. For the model, "this is background information" is more like a premise explained by the user, rather than what it itself said. Using assistant will make it think that is its own conclusion.
The summary request is an independent one-time request and is not appended to the current conversation. Otherwise, your very act of compressing history is increasing history, which is ridiculous.
tool_calls) and role="tool" results it triggers. The criteria are: role == "user" and content is plain string (the tool result has its own role="tool", so there will be no misjudgment; only user + string is considered the beginning of a new turn). This chapter introduces compaction.py and connects it through before_request. Older history becomes a summary while recent complete turns remain unchanged. The CLI also exposes /compact for manual compaction.
estimate_tokens() estimates size; is_turn_start() and find_cut_index() locate a safe boundary; compact() and summarize() build the new history.make_compaction_hook() checks before requests and replaces history in place with messages[:] = ...._make_before_request() persists compacted history; /compact triggers compaction manually.estimate_tokens summarizes the number of characters in messages, tool names, and parameters to make approximate token estimates. 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | def estimate_tokens(messages): """Estimate tokens from content, tool names, arguments, and tool-call IDs. Use characters / 3 plus fixed message overhead. This is a rough heuristic for mixed English and Chinese text, not an exact model-specific count.""" total_chars = 0 for msg in messages: content = msg.get("content") if isinstance(content, str): total_chars += len(content) elif content: # Fallback for uncommon historical content shapes. total_chars += len(str(content)) for tc in msg.get("tool_calls") or []: fn = tc.get("function") or {} total_chars += len(str(fn.get("name", ""))) total_chars += len(str(fn.get("arguments", ""))) if msg.get("role") == "tool": total_chars += len(str(msg.get("tool_call_id", ""))) # Allow fixed overhead for roles and message structure. overhead = len(messages) * 12 return total_chars // 3 + overhead def needs_compaction(messages, limit=None, reserve=None): """Decide whether estimated tokens have reached the reserved threshold.""" limit = limit if limit is not None else CONFIG["max_context_tokens"] reserve = reserve if reserve is not None else CONFIG["compaction_reserve_tokens"] return estimate_tokens(messages) >= (limit - reserve) |
is_turn_start uses the user role and string content to identify the new turn; tool results use the tool role. 66 67 68 69 70 71 | def is_turn_start(msg): """Identify a user text message that starts a conversation turn.""" if msg.get("role") != "user": return False return isinstance(msg.get("content"), str) |
find_cut_index Select the cutting point from the beginning of the turn and keep the most recent turns. 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | def find_cut_index(messages, keep_recent_turns=None): """Find a complete-turn boundary before the retained recent turns. messages[:cut] will be summarized; messages[cut:] stays unchanged. Return None when history is too short for a useful cut.""" keep = ( keep_recent_turns if keep_recent_turns is not None else CONFIG["compaction_keep_recent_turns"] ) # Count recent turn starts backward and cut before them. turn_starts = [i for i, m in enumerate(messages) if is_turn_start(m)] if len(turn_starts) <= keep: return None # Too few turns to compact. cut = turn_starts[len(turn_starts) - keep] return cut if cut > 0 else None # A cut at zero would summarize nothing. |
compact Hand over the old history to summarize, keep the recent original text, and then return to the new list. summarize uses a separate request to generate the digest, and _render is responsible for organizing the input text. _render for rendering summary input (mini_agent/compaction.py lines 138–157)
def _render(messages):
"""Render messages and tool calls as text for the summary request."""
lines = []
for msg in messages:
role = msg.get("role", "?")
content = msg.get("content")
if isinstance(content, str) and content:
lines.append(f"[{role}] {content}")
# Assistant tool calls.
for tc in msg.get("tool_calls") or []:
fn = tc.get("function") or {}
lines.append(f"[assistant] called {fn.get('name')} with {fn.get('arguments')}")
# Tool-result messages.
if role == "tool":
body = str(content or "")
if len(body) > 500:
body = body[:500] + " …(truncated)"
lines.append(f"[tool] result: {body}")
return "\n".join(lines)
This paragraph comes directly from the _render function of the current compaction.py.
13 14 15 16 17 18 19 20 21 22 23 24 25 | SUMMARY_INSTRUCTION = """\ You are compacting a coding session so it can continue in a fresh context. Write a dense handoff summary of the conversation below. You MUST preserve, verbatim where possible: - file paths that were read or modified - names of functions, classes, variables that were changed - decisions made and the reason for them - what is still unfinished Do NOT include chit-chat. Do NOT invent anything not present below. Write it as notes to your future self, not as prose for a human. """ |
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | def compact(messages, *, signal=None, keep_recent_turns=None): """Return a summary followed by recent history without mutating the input. Use a user-role summary to present background information, rather than making it appear to be the assistant's own previous answer.""" cut = find_cut_index(messages, keep_recent_turns) if cut is None: return messages # Nothing to compact; return the original list. older = messages[:cut] recent = messages[cut:] summary = summarize(older, signal=signal) header = "Here is a summary of the earlier part of this session:\n\n" summary_msg = {"role": "user", "content": header + summary} return [summary_msg] + recent def summarize(messages, *, signal=None): """Generate handoff notes in a separate model request with no tools.""" # Render older history as plain text for the model. # This is a separate summary request. # It does not append to the ongoing conversation. transcript = _render(messages) prompt = [ { "role": "user", "content": f"{SUMMARY_INSTRUCTION}\n\n<conversation>\n{transcript}\n</conversation>", } ] # No tools are needed for summarization. response = llm.call_llm(prompt, [], system=None, signal=signal) text = llm.extract_text(response).strip() if not text: # Keep a recognizable placeholder if the model returns no summary. return "(summary unavailable)" return text |
make_compaction_hook replaces the interface wrapper that returns a new list with agent_loop by calling it before the request. App._make_before_request is written back to disk after the history length changes; the branch of /compact is in App.handle_command. 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 | Slice assignment preserves the list shared by the loop and session.""" from . import compaction as _compaction def hook(messages): if not _compaction.needs_compaction(messages): return before = _compaction.estimate_tokens(messages) if emit: emit("compaction_start", tokens=before) compressed = _compaction.compact(messages, signal=signal) # Preserve the list identity with slice assignment. # Assigning messages = compressed would only rebind a local variable. # The caller would still hold the unchanged original list. messages[:] = compressed if emit: emit( "compaction_end", before=before, after=_compaction.estimate_tokens(messages), ) return hook |
188 189 190 191 192 193 | # Step 4: compaction check (chapter 6). # Compact before sending the next model request. # The hook must replace list contents in place, not assign a new list. # This preserves references held by the caller and session. if before_request is not None: before_request(messages) |
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | def _make_before_request(self): """Wrap automatic compaction and persist replaced history. The append hook only saves new messages. Compaction replaces old messages, so the session file must be rewritten rather than appended to.""" compact_hook = agent_mod.make_compaction_hook( signal=self.signal, emit=self.render ) def before_request(messages): before_len = len(messages) compact_hook(messages) if len(messages) != before_len: self.session.replace_messages(messages) return before_request |
223 224 225 226 227 228 | elif cmd == "/compact": before = compaction.estimate_tokens(self.session.messages) compacted = compaction.compact(self.session.messages, signal=self.signal) self.session.replace_messages(compacted) after = compaction.estimate_tokens(compacted) print('{0}Compacted: {1} → {2} tokens{3}'.format(DIM, before, after, RESET)) |
The system prompts in the first six chapters are a hard-coded string. It has two obvious shortcomings: I don’t know where I am (what day is it today, which directory is it in, what system is it in), and cannot be expanded (if I want to add project-specific knowledge, I have to change the code).
This chapter turns it into three pieces spelled out. It seems simple, but all three design points have practical reasons.
The large model's attention to different locations in the context is not uniform - strongest at the beginning and end, weakest in the middle (this is the so-called "lost in the middle").
So the arrangement should be determined according to "Importance × Stability":
The base prompt describes general working behavior, such as reading a file before editing it. Project-specific knowledge, such as commit-message conventions, belongs in a skill file.
Two things need to be distinguished here: saving environment information locally is for less reading and calculation; server-side prompt caching is for reusing the calculated request prefix. The former needs to consider whether the information has expired, and the latter needs to consider whether the prefix is stable.
Basic implementation here: environment_block() reads the directory, platform and date each time it is called, but the CLI only calls build_system_prompt() once when creating App, and reuses self.system_prompt for subsequent requests. Therefore it is actually a startup snapshot and will not be automatically updated after a long session spans days. This simple implementation is retained here.
The open source code of Codex provides a reference method: Treat the environment as a stateful context, save snapshots, compare changes, and then generate update fragments. This not only gives the model newer facts, but also tries to retain what has been sent before.
EnvironmentsState saves information such as the working directory, shell, date, time zone, network and file system permissions; when there is no old snapshot for the first time, compare it to the empty state to generate the required environment context. render_diff() generates updates when the directory or other environment fields change compared to the last snapshot; when there are no relevant changes, no environment fragment is generated. Multi-environment scenarios also distinguish between new, changed, and unavailable environments. user role fragment with a <environment_context> tag. The user role here is the way the client organizes context, and does not mean that this content is handwritten by the user. The date is also included in the snapshot comparison, and it cannot be generally said that "time does not enter the environmental context at all". If you continue to expand the agent here, you can use a smaller version: The fixed skeleton and the skills used in the current session form stable instructions; add an environment description when starting a session; check the directory, date and other fields at the beginning of each turn, add updates after the history when there are changes, and clearly refer to the latest environment description. Don't repeatedly rewrite the reminder at the beginning of history just to update the time. After compressing history or restoring a session, also ensure that the model still sees a valid current environment.
For example: keep yesterday's environment description and existing dialogue as they are, add "The current date has been updated to..." after crossing the day; add a new directory description after switching directories. For tasks that require precise time, the clock can be read on demand. This eliminates the need to put timestamps that change every second into fixed instructions.
This organization helps preserve reusable prefixes, but does not guarantee cache hits. Whether there is a real hit or not depends on the cached_tokens returned by the API; the specific fields, billing, and cache settings are subject to the documents of the model used and the service provider. An interface compatible with Chat Completions is used here, and it cannot be assumed that all backends adopt OpenAI's caching rules.
Reference: Codex Environment snapshot and difference update source code (checked for 2026-10-12); OpenAI prompt caching document. This is an optional improvement direction. The following code still uses the basic writing method of assembling prompts at startup.
A skill is a Markdown file describing how to perform a class of tasks. For example, git-commit.md can contain the project’s commit-message conventions.
Choosing files instead of code has three benefits:
skills/ directories. There is another important design principle here: Skills are optional enhancements, and no errors will be reported if they are missing. If the directory does not exist, an empty list will be returned. If a single file cannot be read, it will be skipped. The failure of an enhancement should not block the main process - this is the same idea as Chapter 2 "Tool failure should return error text instead of crashing".
The system prompt is sent with every request. So the longer it is, the greater the fixed overhead is per turn.
Keep skills concise. A rule such as “Write commit messages in Chinese; keep the first line under 50 characters” belongs in a skill file. A copy of the entire project documentation usually does not.
By the way: This also explains why real coding agents put tool descriptions in tool declarations (on demand) and make project documents searchable (the agent reads them by themselves), instead of stuffing them all into system prompts. Each line of system prompts pays for the entire session.
This chapter introduces prompt.py and passes build_system_prompt() through the CLI. General instructions, an environment snapshot, and Markdown skills form the prompt. Project-specific knowledge can then be added as a skill file.
BASE_PROMPT supplies general instructions; the environment and skill helpers build context; build_system_prompt() joins the layers; describe() reports loaded skills./skills lists the loaded skill files.mini_agent/prompt.py, the fixed behavior convention is placed here. 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | BASE_PROMPT = """\ You are a coding agent working in a terminal. How you work: - Act, don't explain. If the user asks for a change, make the change. - Read before you write. Never edit a file you have not just read. - Prefer the `edit` tool over rewriting a whole file with `write`. - When a tool returns an error, read the message and fix the call. - Stop when the task is done. Do not keep poking around. How you talk: - Be terse. Report what you did, not what you are about to do. - Use the user's language. """ |
environment_block reads the current directory, platform and date to form environment information. 32 33 34 35 36 37 38 39 | def environment_block(): """Read the working directory, platform, and date when called.""" return ( "\nEnvironment:\n" f"- working directory: {os.getcwd()}\n" f"- platform: {platform.system()} {platform.release()}\n" f"- today: {datetime.date.today().isoformat()}\n" ) |
load_skills reads the Markdown file in the skills directory, and skills_block joins their contents. 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | SKILLS_DIRNAME = "skills" def load_skills(root=None): """Load nonempty Markdown files from the working directory's skills folder. Return dictionaries with name and body. A missing directory is allowed; this teaching implementation skips individual unreadable files.""" root = root or os.getcwd() skills_dir = os.path.join(root, SKILLS_DIRNAME) if not os.path.isdir(skills_dir): return [] skills = [] for name in sorted(os.listdir(skills_dir)): if not name.lower().endswith(".md"): continue path = os.path.join(skills_dir, name) try: with open(path, "r", encoding="utf-8", errors="replace") as f: body = f.read().strip() except OSError: continue # Skip an unreadable optional skill file. if body: skills.append({"name": os.path.splitext(name)[0], "body": body}) return skills def skills_block(skills=None, root=None): """Render skill contents, returning an empty string when no skills exist.""" skills = load_skills(root) if skills is None else skills if not skills: return "" parts = ["\nSkills (follow these when relevant):"] for skill in skills: parts.append(f"\n--- {skill['name']} ---\n{skill['body']}") return "\n".join(parts) + "\n" |
build_system_prompt joins the layers in the order of skeleton, environment and skills. 90 91 92 93 94 | def build_system_prompt(skills=None, root=None): """Combine behavior instructions, environment information, and skills. Pass skills=[] to disable skills, or None to load them from disk.""" return BASE_PROMPT + environment_block() + skills_block(skills, root) |
App.__init__ loads the skill and constructs system_prompt, run_turn passes it to agent_loop. App.handle_command shows loaded skills. 74 75 76 77 78 79 80 81 82 83 | def __init__(self, args): self.args = args self.session = self._open_session() self.signal = Signal() self.steering = SteeringQueue() self.executor = agent_mod.make_executor() self.render = make_renderer(args.verbose) self.skills = prompt.load_skills() self.system_prompt = prompt.build_system_prompt(self.skills) self.before_request = self._make_before_request() |
216 217 218 219 220 221 222 | elif cmd == "/skills": if not self.skills: print('(No skills loaded. Add .md files under ./skills/)') for s in self.skills: print(' {0} ({1} characters)'.format(s['name'], len(s['body']))) total = len(prompt.build_system_prompt(self.skills)) print('{0}System prompt: {1} characters total{2}'.format(DIM, total, RESET)) |