mini agent · Rub a coding agent with your hands

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.

mini agent

This tutorial draws on the source code of pi and Codex.

Directory
Chapter 1 · Agent Loop Run the loop
Chapter 2 · Tools Give the agent a pair of hands
Chapter 3 · change queue Don’t mess with writing files concurrently
Chapter 4 · interruptions and turns How to intervene after running?
Chapter 5 · Session persistence Even if you turn it off, you can still continue chatting.
Chapter 6 · Context compaction What to do if the history is too long
Chapter 7 · System prompts and skills Let it know where it is
Chapter 1

Agent Loop

Run the model–tool loop
Goal Write a loop that requests a model response, executes requested tools, appends their results, and repeats until no tool calls remain.

The problem

How it works

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.

What a tool call actually does

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.

The OpenAI-style conversation format

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.

Where to look up protocol fields

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:

  • Definition of each field in the request body (model / messages / tools / max_tokens...): api-reference/chat/create
  • What does the response object look like (choices → message → content / tool_calls, and finish_reason at the same layer): api-reference/chat/object
  • How to coordinate the four roles (system / user / assistant / tool) and complete dialogue examples: api-reference/chat
  • Tool calling this set of conventions (tools declaration, tool_calls return, tool_call_id pairing): guides/function-calling

Compared 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.

Message pairing requirements

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.

Why the loop checks tool_calls instead of finish_reason

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.
  • And 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.

Why growing history needs compaction

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.

Design trade-offs

Files introduced or changed

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.

Newconfig.pyCentral settings for the endpoint, API key, model, timeouts, and later features; environment variables override the defaults.
Newllm.pycall_llm() sends a request; the extraction helpers read the assistant message, text, and tool calls.
Newagent.pyagent_loop() requests a response, records it unchanged, executes tools, appends results, and repeats.
Newcli.pyThe terminal REPL reads input, runs a turn, and prints the answer. -v enables tool output.
New__init__.pyDeclares mini_agent as an importable Python package.
New__main__.pyThe from .cli import main entry point enables python -m mini_agent.

Implementation

Step 1: Collect settings in config.py
  • View the CONFIG of mini_agent/config.py, the model address, key and model name are read from environment variables.
  • The same configuration also contains session and compaction parameters used in subsequent chapters. Here we focus on the model request part first.
Configuration and API key handlingmini_agent/config.py:10–53Expand code
mini_agent/config.pyLines 10–53
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'
        )
Step 2: Implement llm.call_llm
  • call_llm of mini_agent/llm.py combines system and history into a request and sends it to /chat/completions.
  • The signal will be checked before and after the request returns; details of interrupts are placed in Chapter 4.

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.

call_llm: send a model requestmini_agent/llm.py:24–75Expand code
mini_agent/llm.pyLines 24–75
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
Step 3: Extract message, text, and tool calls
  • View extract_message, extract_text, extract_tool_calls; the message retains content and tool call information.
  • The JSON below is used to reference the response structure, not a code file from another stage.

① 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 "".

Response extraction helpersmini_agent/llm.py:78–93Expand code
mini_agent/llm.pyLines 78–93
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 []
Step 4: Build the agent loop
  • View agent_loop of mini_agent/agent.py: request model, save reply, execute tool, and refill results.
  • Tool implementation focuses on mini_agent/tools.py; here we take the shell tool as an example, and other tools are placed in Chapter 2.
  • The current loop also contains access points for interruption, persistence and compaction, which will be explained separately later.

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.

tool_bash: execute a shell commandmini_agent/tools.py:101–124Expand code
mini_agent/tools.pyLines 101–124
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"]]
agent_loop: model requests, tools, and historymini_agent/agent.py:195–245Expand code
mini_agent/agent.pyLines 195–245
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
Step 5: Add the CLI entry point
  • build_parser and main of mini_agent/cli.py are responsible for startup; interactive input is handled by App.repl.
  • The one-time mode in 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.

CLI arguments and entry pointmini_agent/cli.py:270–312Expand code
mini_agent/cli.pyLines 270–312
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
Chapter 2

Tools

Give the agent tools
Goal Represent tools as a name-to-function table. Adding a tool should change its declaration and implementation without changing the loop.

The problem

How it works

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.

Problem 1: The loop depends on every tool implementation

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.

Problem 2: Tool failure will crash the agent

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.

Tool declarations and implementations are separate

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.

Why file tools use Python instead of the shell

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:

  • Quotation mark escaping: If there are spaces or Chinese brackets in the file name, the spelled out command will be broken; if there are quotation marks in the content, it will be even more disastrous.
  • glob expansion: 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.
  • Unable to locate error: The shell only gives you a return code, and you don’t know whether the file does not exist or the permissions are insufficient. If you directly enter 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).

Keeping file paths inside the working directory

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.

Design trade-offs

Files introduced or changed

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.

Newtools.pyTOOLS declares the interface for the model; HANDLERS and submit() implement dispatch. safe_path() constrains file-tool paths.
Changedagent.pyTool execution calls tools.submit(name, args), so the loop does not need a branch for every tool.

Implementation

Step 1: Declare TOOLS
  • View _function and TOOLS of mini_agent/tools.py, and declare the four tools bash, read, write, and edit.
  • The tool name in the schema needs to correspond to the HANDLERS key.

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.

TOOLS: declarations sent to the modelmini_agent/tools.py:21–74Expand code
mini_agent/tools.pyLines 21–74
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"],
    ),
]
Step 2: Validate paths with safe_path
  • safe_path uses realpath to resolve the path and then checks whether the target is in the working directory.
  • The file tool calls this function; the shell tool still executes commands according to the current user permissions.
safe_path: constrain file pathsmini_agent/tools.py:81–90Expand code
mini_agent/tools.pyLines 81–90
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
Step 3: Give bash, read, and write a common signature
  • View tool_bash, tool_read, and tool_write. The input parameters are all dictionaries and the return values are text.
  • write has been connected to the change queue. Chapter 3 will see how the queue coordinates file writing.

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.

bash and read: a common signaturemini_agent/tools.py:101–152Expand code
mini_agent/tools.pyLines 101–152
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)"
tool_write: replace the complete filemini_agent/tools.py:155–173Expand code
mini_agent/tools.pyLines 155–173
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)
Step 4: Require a unique match for edit
  • tool_edit reads the content first, then checks the number of matches in old_string, and replaces only the unique match.
  • Reading, checking and writing back are all placed in mutate and called by mutation_queue.run.
tool_edit: replace a unique matchmini_agent/tools.py:176–217Expand code
mini_agent/tools.pyLines 176–217
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)
Step 5: Dispatch through HANDLERS and submit
  • 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.

HANDLERS and submit: connect declarations to implementationsmini_agent/tools.py:226–246Expand code
mini_agent/tools.pyLines 226–246
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}"
_run_one: parse arguments and dispatch a toolmini_agent/agent.py:86–119Expand code
mini_agent/agent.pyLines 86–119
 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}
Chapter 3

Mutation Queue

Coordinate concurrent writes
Goal Execute tools concurrently while keeping each same-file read–modify–write operation inside one critical section.

The problem

edit#1: Read "A" ────────────┐
edit#2: Reads "A" ─────┼── Both reads A
edit#1: Write "A1" ─────┘
edit#2: writing "A2" ← overwrites edit#1

How it works

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.

Lost updates from concurrent writes

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.

Why concurrency needs coordinated writes

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.

One lock per path

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 same file → shares a lock → serial.
  • Different files → each use its own lock → still parallel.

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.

The lock registry has its own race condition

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.

Design trade-offs

Files introduced or changed

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.

Newmutation_queue.pyA lock registry indexed by normalized absolute paths. run(path, fn) coordinates mutations of the same file.
Changedtools.pywrite and edit pass the complete operation to mutation_queue.run(); the lock covers reading and checking as well as writing.
Changedagent.pyThreadPoolExecutor runs tools concurrently and collects results in the original call order.

Implementation

Step 1: Normalize the path key
  • View FileMutationQueue._key_for and use the normalized absolute path as the bucket key.
Normalize the lock key with realpathmini_agent/mutation_queue.py:13–25Expand code
mini_agent/mutation_queue.pyLines 13–25
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))
Step 2: Protect the lock registry
  • _registry protection lock table: the same key shares the same lock.
Protect lock creation with a registry lockmini_agent/mutation_queue.py:29–36Expand code
mini_agent/mutation_queue.pyLines 29–36
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
Step 3: Run a mutation under its path lock
  • run(path, fn) calls fn after obtaining the lock of the target file; different keys use different locks.
run: execute under the path lockmini_agent/mutation_queue.py:27–40Expand code
mini_agent/mutation_queue.pyLines 27–40
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()
Step 4: Lock the complete read–modify–write operation
  • Both tool_edit and tool_write hand over the actual change action to mutation_queue.run.
  • The critical section covers the complete reading, checking, and writing back process.
edit: lock the complete read–check–write operationmini_agent/tools.py:176–217Expand code
mini_agent/tools.pyLines 176–217
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)
Step 5: Execute tools in a thread pool
  • 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.

execute_tool_calls: run concurrently, collect in ordermini_agent/agent.py:69–83Expand code
mini_agent/agent.pyLines 69–83
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]
Chapter 4

Interruption and Steering

Interrupt or redirect a running turn
Goal Allow Ctrl+C to interrupt a turn and let new input steer the next model request while the worker is running.

The problem

How it works

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.

Why interruption uses an exception

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."

Why Signal uses threading.Event

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".

Why steering uses a queue

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.

Interrupted calls still need tool results

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.

Design trade-offs

Files introduced or changed

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.

Newcontrol.pyAborted, Signal, and SteeringQueue provide cooperative interruption and atomic draining of queued input.
Changedagent.pyChecks cancellation at execution boundaries and records a role="tool" result for every interrupted call already present in history.
Changedllm.pyChecks cancellation before sending a request and after receiving its response.
Changedcli.pyApp manages state, input, and slash commands. The worker runs the agent while the main thread reads input or signals interruption.

Implementation

Step 1: Implement the control primitives
  • View the Signal and SteeringQueue of mini_agent/control.py to store interruption status and supplementary input respectively.
Cancellation: Aborted and Signalmini_agent/control.py:11–40Expand code
mini_agent/control.pyLines 11–40
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)
Queued input: SteeringQueuemini_agent/control.py:48–70Expand code
mini_agent/control.pyLines 48–70
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
Step 2: Add loop checkpoints
  • agent_loop checks the interrupt at the beginning, reads the steering queue and determines the upper limit of the number of turns.
  • Assistant has declared that when the tool is called, the interrupt branch must complete the result; _run_one will also check the signal.
Checks at the start of a loop iterationmini_agent/agent.py:168–186Expand code
mini_agent/agent.pyLines 168–186
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
Record matching tool results after interruptionmini_agent/agent.py:215–236Expand code
mini_agent/agent.pyLines 215–236
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
The tool calls that have been written to the history need to match the results before you can continue to request the model.
Handle cancellation during tool executionmini_agent/agent.py:104–119Expand code
mini_agent/agent.pyLines 104–119
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}
Step 3: Check the signal around model requests
  • call_llm Check signal before sending request and after receiving response.
  • urllib's blocking read cannot be canceled immediately, so an interruption does not mean that the executing request or tool has stopped.
Check the signal before the requestmini_agent/llm.py:31–38Expand code
mini_agent/llm.pyLines 31–38
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
Check again after the responsemini_agent/llm.py:68–75Expand code
mini_agent/llm.pyLines 68–75
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
Step 4: Run the CLI worker while reading input
  • 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.
  • The current implementation is shown directly below; related sessions and compaction wiring are explained in subsequent chapters.

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.

App.repl: interactive inputmini_agent/cli.py:239–263Expand code
mini_agent/cli.pyLines 239–263
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))
App.run_turn: worker execution and steeringmini_agent/cli.py:135–183Expand code
mini_agent/cli.pyLines 135–183
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)
Chapter 5

Session Persistence

Resume after closing the terminal
Goal Save conversation history to disk so --continue can restore the latest session.

The problem

How it works

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.

Why sessions use JSONL

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:

  • Append is cheap: one line per line, O(1) instead of O(file size). When talking about hundreds of turns, the difference is considerable.
  • Crash-safe: Power off when writing line 7, the first 6 lines are still fully parsable. If the entire JSON file is damaged, not a single byte can be read.
  • Tolerant of local damage: If a single row is broken, just skip it without affecting other rows. Real world log files always have half a line in them.

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.

Writing a temporary file before replacing history

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.

Understanding append and persist

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.

Preserving Python list identity

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.

Design trade-offs

Files introduced or changed

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.

Newsession.pyOne JSONL file per session, with creation, loading, append, persistence, and replacement methods, plus helpers to list or locate sessions.
Changedagent.py_append() owns in-memory history changes; on_append delegates persistence to the session.
Changedcli.pyStartup and the session commands share the session layer. User input is saved before the model request.

Implementation

Step 1: Define Session state
  • View the id, messages, path of Session, and how create determines the session file.
Session statemini_agent/session.py:17–34Expand code
mini_agent/session.pyLines 17–34
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)
Step 2: Load JSONL history
  • Session.load reads JSONL line by line. The current basic implementation will skip and prompt if it encounters lines that cannot be parsed.
  • This only describes the existing implementation and does not treat line-by-line parsability as a guarantee of automatic repair or complete crash recovery.
load: skip malformed JSONL recordsmini_agent/session.py:36–55Expand code
mini_agent/session.pyLines 36–55
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)
Step 3: Separate memory changes from disk writes
  • append and append_message modify the memory and write to the disk at the same time; persist only writes to the disk.
  • When the loop has appended messages, on_append should use persist to avoid repeated appending.
Three methods with different write responsibilitiesmini_agent/session.py:59–80Expand code
mini_agent/session.pyLines 59–80
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
Step 4: Replace history atomically and in place
  • replace_messages Use slice replacement to keep the list object unchanged, and then rewrite the history through a temporary file.
Session.replace_messages: replace historymini_agent/session.py:93–106Expand code
mini_agent/session.pyLines 93–106
 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 {
Slice assignment retains the original list object to prevent the old list from being referenced elsewhere.
Step 5: Connect sessions to the loop and CLI
  • _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.
_append: update memory and invoke the persistence hookmini_agent/agent.py:133–137Expand code
mini_agent/agent.pyLines 133–137
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
The loop has appended messages, and the persistence hook only writes to the disk to avoid repeated appending.
List sessions and locate the latest sessionmini_agent/session.py:119–131Expand code
mini_agent/session.pyLines 119–131
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
App: initialize or locate a sessionmini_agent/cli.py:74–114Expand code
mini_agent/cli.pyLines 74–114
 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))
App.run_turn: use session historymini_agent/cli.py:135–158Expand code
mini_agent/cli.pyLines 135–158
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()
Session commands: /new, /list, /resumemini_agent/cli.py:187–215Expand code
mini_agent/cli.pyLines 187–215
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)
Chapter 6

Context Compaction

Make room in long conversations
Goal As estimated usage approaches the context limit, replace older history with a summary while retaining recent complete turns.

The problem

How it works

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).

Cut history only at a complete turn boundary

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.

Choosing how much recent history to keep

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:

  • By number of messages (retaining the most recent N messages): The simplest implementation, but the number of messages in a turn is floating - a Q&A with the tool may be 2, or it may be 30, and the actual number of retained messages can vary dozens of times.
  • By number of turns (retain the latest N turns): The retention amount fluctuates with the "size of the turn", but a turn is a unit that users can perceive. They can clearly understand it by reading the log and telling others ("the last three questions are still in the original text"). Take this route for the tutorial version.
  • According to token budget (retain enough recent history of about M tokens): closest to the real constraints, the actual number of turns retained will automatically adapt - if the chat is long-winded, keep a few turns, and if the chat is compact, keep a few more turns; the cost is that tokens must be calculated, and the cut point may fall in the middle of the turn, so additional processing of "cut half turns" is required.

No matter which unit is used, three principles are common:

  • Summarize older messages and preserve recent ones. Current work and fresh tool output need their original detail.
  • The retained unit must be a complete turn: Retaining half a turn is equivalent to creating an unmatched tool message (the constraint in the previous section).
  • The criterion is "whether the work area is enough", not "whether the number of tokens is low enough": The criterion is - when the model continues to work, should it look back to you for original information.

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.

pi: Backtrack from the end according to the token budget (20,000 tokens are left by default)Click to expand the original text
// 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,
	};
}
Codex (0.156.0): The entire paragraph is replaced with a handoff summary, without retaining the original text of recent turnsClick to expand the original text
// 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.

What the token estimate must include

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.

The prompt determines summary quality

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:

  • Keep verbatim file paths, function names, variable names, and error messages.
  • Keep decisions made and reasons ("Choose A instead of B because...").
  • Write as a note to your future self, not prose.
  • Don’t be polite, don’t restate the problem, and don’t summarize “what users want.”

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.

Tutorial version: SUMMARY_INSTRUCTION (mini_agent/compaction.py)Click to expand the original text
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.
pi: 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 (0.156.0): Compaction prompt - a string extracted from the local codex.exe. The source code path that appears at the same place is core/src/compact.rsClick to expand the original text
You 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:

  • The tutorial uses a checklist. It names the information that must survive verbatim but leaves the output format to the model. This keeps the instructions short, at the cost of a less predictable summary format.
  • pi nailed the format: six paragraphs of fixed subtitles (Goal/Constraints & Preferences/Progress/Key Decisions/Next Steps/Critical Context). Each paragraph is also given a line of square brackets written "What to fill in here". At the end, it is emphasized again that the precise path, function name, and error message should be retained. It also has two sentences: Do NOT continue the conversation - to prevent the model from treating the "summary request" as a new task and continue talking.
  • The shortest Codex: The first sentence clearly explains what the task is (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.
  • Two things written by all three companies: Make it clear who the summary is for; when naming, you must keep the path, name, decision, and unfinished things. The only difference lies in how strict the format constraints are - the tutorial version relies on lists, pi relies on templates, and the Codex relies on one sentence plus four items.
  • pi also has one more incremental version (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.

Two design details

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.

Design trade-offs

Files introduced or changed

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.

Newcompaction.pyestimate_tokens() estimates size; is_turn_start() and find_cut_index() locate a safe boundary; compact() and summarize() build the new history.
Changedagent.pymake_compaction_hook() checks before requests and replaces history in place with messages[:] = ....
Changedcli.py_make_before_request() persists compacted history; /compact triggers compaction manually.

Implementation

Step 1: Estimate token usage
  • estimate_tokens summarizes the number of characters in messages, tool names, and parameters to make approximate token estimates.
Estimate token usagemini_agent/compaction.py:32–59Expand code
mini_agent/compaction.pyLines 32–59
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)
Step 2: Identify turn starts
  • is_turn_start uses the user role and string content to identify the new turn; tool results use the tool role.
is_turn_start: identify complete turn boundariesmini_agent/compaction.py:66–71Expand code
mini_agent/compaction.pyLines 66–71
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)
Step 3: Choose a safe cut point
  • find_cut_index Select the cutting point from the beginning of the turn and keep the most recent turns.
find_cut_index: select the boundarymini_agent/compaction.py:74–91Expand code
mini_agent/compaction.pyLines 74–91
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.
Step 4: Implement compact and summarize
  • 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.

Summary instructionsmini_agent/compaction.py:13–25Expand code
mini_agent/compaction.pyLines 13–25
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.
"""
compact and summarize: the main flowmini_agent/compaction.py:98–135Expand code
mini_agent/compaction.pyLines 98–135
 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
Step 5: Connect compaction with in-place replacement
  • 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.
Replace history in placemini_agent/agent.py:256–280Expand code
mini_agent/agent.pyLines 256–280
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
Invoke compaction before model requestsmini_agent/agent.py:188–193Expand code
mini_agent/agent.pyLines 188–193
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)
Persist history after compactionmini_agent/cli.py:116–131Expand code
mini_agent/cli.pyLines 116–131
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
Manual compaction: /compactmini_agent/cli.py:223–228Expand code
mini_agent/cli.pyLines 223–228
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))
Chapter 7

System Prompts and Skills

Provide context and project skills
Goal Build the system prompt from general instructions, an environment snapshot, and optional Markdown skill files.

The problem

How it works

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.

Three prompt layers and their order

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":

  • Level 1 · Skeleton: The most critical constraints (how you work, how you speak) are placed at the front. For example, "Read before you write" can significantly reduce the situation where the model changes files based on imagination.
  • Level 3 · Skills: Project-specific knowledge, placed at the end (closer to the end, and more focused).
  • Tier 2 · Environment is sandwiched in the middle. It is pure fact and does not require special attention from the model, so it is most suitable to be placed in the middle.

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.

Local environment snapshots and server prompt caching

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.

  • The local cache can be used, but it needs to be updated. Platform information is usually relatively stable; when the directory, date, permissions, etc. change, it needs to be collected again. You cannot simply store all the environment information once and use it all the time, nor do you need to recalculate all fields every time.
  • Server-side cache reuses the same prefix. Changes in the earlier content will affect the longer prefix containing this change; the stable part before the change still has the opportunity to be reused. Actual hits also depend on the model, cache boundaries, length thresholds, retention times, and request routing, and are not guaranteed to be consistent throughout the day.

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.

An optional extension: append environment updates

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.

  • First create an environment snapshot: 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.
  • Subsequent comparison changes: 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.
  • Environmental facts as independent context: Update renders into a 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.

Why skills are Markdown files

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:

  • Changing it does not require touching the code, nor does it require revalidation. People who write documentation do not need to know how to write Python.
  • You can prepare it separately by project: If you change a project, you need to change a set of skills/ directories.
  • The content is natural language, which happens to be the form that the model is best at understanding. You don't need to define any schema.

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 recurring cost of system prompts

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.

Design trade-offs

Files introduced or changed

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.

Newprompt.pyBASE_PROMPT supplies general instructions; the environment and skill helpers build context; build_system_prompt() joins the layers; describe() reports loaded skills.
Changedcli.pyBuilds and reuses the prompt at startup. /skills lists the loaded skill files.

Implementation

Step 1: Define the base instructions
  • Look at the BASE_PROMPT of mini_agent/prompt.py, the fixed behavior convention is placed here.
Layer 1: general instructionsmini_agent/prompt.py:16–29Expand code
mini_agent/prompt.pyLines 16–29
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.
"""
Step 2: Collect environment information
  • environment_block reads the current directory, platform and date to form environment information.
Layer 2: environment informationmini_agent/prompt.py:32–39Expand code
mini_agent/prompt.pyLines 32–39
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"
    )
Step 3: Load skills from disk
  • load_skills reads the Markdown file in the skills directory, and skills_block joins their contents.
Layer 3: Markdown skillsmini_agent/prompt.py:51–87Expand code
mini_agent/prompt.pyLines 51–87
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"
Step 4: Assemble the prompt
  • build_system_prompt joins the layers in the order of skeleton, environment and skills.
  • The prompt requires the model to follow the language used by the user; the CLI always uses English.
Assemble the three layersmini_agent/prompt.py:90–94Expand code
mini_agent/prompt.pyLines 90–94
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)
Step 5: Pass it through the CLI
  • App.__init__ loads the skill and constructs system_prompt, run_turn passes it to agent_loop.
  • The /skills branch in App.handle_command shows loaded skills.
Connect the prompt to the CLImini_agent/cli.py:74–83Expand code
mini_agent/cli.pyLines 74–83
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()
Inspect loaded skills with /skillsmini_agent/cli.py:216–222Expand code
mini_agent/cli.pyLines 216–222
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))