HOW IT WORKS

How one question travels through the agent loop

Everything that happens between pressing send and seeing the reply, in the order it really runs. The app's sidebar picks Gemma 4 E2B or Qwen3.5 0.8B; both models run the same loop, which this project's own harness runs, or pi, a coding agent (§1 “Overview: two threads, one event list”). A question can carry images, sound or video (§7 “Images, sound and video: tokens in the context”), and with Let the model write code on, the model can also run Python in a Docker sandbox (§16 “Environment: really running the tool”). The model runs on this machine with transformers, or on a vLLM server (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”). Where the two models, the two ways of running them or the two harnesses differ, a section tells each in a tab of its own, one per model, per backend or per harness, and picking one in any section switches every tab of that kind on the page. Every part comes with the code that does it; a mark like agent/harnesses/simple_harness.py:99 is a file's path and a line number, hovering over it shows that line, and the GitHub icon beside it opens the file at that line in a new tab, as the icon on an excerpt's title does for its lines. The line numbers are looked up in the source when this page is built, and the links go to that commit; GitHub Actions rebuilds it on every push to main, so they follow the code as it changes.

The source and how to run it are on GitHub · line numbers from commit ca3dc5b

The agent loop: the context goes into the model and its output to the harness; a tool call runs in the environment and its observation goes back into the context; without one, the loop stops. Now: one lap = one step
The agent loop: the context goes into the model and its output to the harness; a tool call runs in the environment and its observation goes back into the context; without one, the loop stops. Now: one lap = one step
1

Page

Overview: two threads, one event list

Each question you send is one turn (one SimpleHarness); inside a turn is a loop, and each time around it is a step.

Every step does the same thing: the harness renders the whole conversation into one string (the context) → the model runs one inference, generating one token at a time → the harness parses the output. If there is a tool call, it validates it, asks you if it should, runs it in the environment and adds the result to the conversation for the next step; if there is none, it hands you the reply, and the turn ends.

The app runs on two threads. Streamlit's script thread draws the page; the agent runs on a background thread (app/page.py:356). The two talk through just three things:

These three are all the page asks of whatever runs the loop, the Harness (agent/harnesses/harness.py:42). Two harnesses run it: this project's own, SimpleHarness, and, with pi picked under What runs the loop in the sidebar, PiHarness, which hands the loop to pi, a coding agent. Where the two do a part differently, the sections below tell it in a tab for each; where pi asking this page's model and pi asking one of its own differ too, in a tab for each of those. Another agent that reports its loop as events, such as the Claude Agent SDK or Codex, could be one more harness written the same way.

SimpleHarness.run() is the loop itself (agent/harnesses/simple_harness.py:99): it renders the context, has the model generate, parses the output and runs the tools, sending an event at every stage. The model runs on this machine with transformers, or on a vLLM server (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”), and every token shows.

PiHarness starts pi as a child process in RPC mode for each question (agent/harnesses/pi_harness.py:100): commands go in on stdin, and records come out on stdout, one JSON object per line. pi runs the loop, and PiHarness only turns pi's records into the page's events; one of pi's turns (one request to the model, and the tools its reply calls) is a step.

agent/harnesses/pi_harness.pylines 410–427
410def _on(self, rec: dict) -> bool:
411    """Turn one of pi's records into the page's events; True once pi has settled."""
412    kind = rec.get("type")
413    if kind == "turn_start":
414        self._turn_start()
415    elif kind == "message_start" and self._open and rec["message"].get("role") == "assistant":
416        self._stream_t, self._wrote = time.perf_counter(), False
417    elif kind == "message_update" and self._open:
418        self._update(rec["assistantMessageEvent"])
419    elif kind == "message_end" and self._open and rec["message"].get("role") == "assistant":
420        self._message_end(rec["message"])
421    elif kind == "tool_execution_start" and self._open and not rec.get("parentToolCallId"):
422        self._tool_start(rec["toolCallId"], rec["toolName"], rec.get("args") or {})
423    elif kind == "tool_execution_end" and rec.get("toolCallId") in self._calls and not rec.get("parentToolCallId"):
424        self._tool_end(rec["toolCallId"], rec.get("result") or {}, rec.get("isError", False), rec.get("durationMs"))
425    elif kind == "extension_ui_request":
426        self._dialog(rec)
427    return kind == "agent_settled"

pi asks either this page's model or one it is logged in to (Claude, GPT…), as the sidebar's The model pi asks says. On this page's model, model_endpoint.py serves the model in memory to pi as an OpenAI-compatible endpoint, and every token still shows; on one of pi's own, the provider's API sends back text alone.

The project has two parts. agent/ runs a turn: the harness that runs the loop, the tools, the model and what runs it, and it never imports Streamlit. app/ is what you see, on top of the agent: it starts a turn, draws the events the harness sends and passes your clicks back. streamlit_app.py at the top only starts app/page.py. What each file in agent/ does:

In app/:

Beside the two, models/ holds the downloaded models, one folder each, which git ignores (§3 “The model folder: every file in models/”), and docker-compose/ what the page starts vLLM in Docker from (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”).

2

Model

Model families: switching models changes only this layer

The harness, the decoding loop and the page are written once; what differs between models is gathered in one Family object.

The two models run exactly the same loop; what differs is the format they write in:

  • Turns: <|turn>user … <turn|>.
  • Tool calls: <|tool_call>call:name{key:<|"|>value<|"|>}<tool_call|>.
  • Tool results: in the same model turn (<|tool_response> … <tool_response|>).
  • Stop tokens: <turn|> after an answer and <|tool_response> when it wants a tool, so the stop token tells which.
  • Thinking: <|think|> at the start of the system turn, and the reasoning between <|channel>thought and <channel|>.
  • Turns: ChatML's <|im_start|>user … <|im_end|>.
  • Tool calls: XML, one <function=name> inside <tool_call>, then a <parameter=name> for each argument, every value plain text.
  • Tool results: in a new user message (<tool_response> … </tool_response>), followed by a new assistant turn.
  • Stop tokens: <|im_end|> after an answer and for a tool alike, and only a tool call in the output tells them apart.
  • Thinking: the chat template opens the assistant message with <think>, and the reasoning runs until </think>.

All of these differences go into a Family, and the rest of the code just asks the family; there is no if Gemma branch anywhere:

agent/model_families/__init__.pylines 71–123The fields come in three groups: for reading the model's output, for writing the conversation back for the chat template, and for the page. The last few have defaults and only some families need them, such as the video setting Qwen gives its processor in processor_kwargs; file_notes and drafter_file_notes explain each file in the model folder (§3 “The model folder: every file in models/”).
71class Family:
72    key: str
73    name: str
74    model_types: tuple[str, ...]  # config.json `model_type` values of this family
75
76    # Reading the model's output.
77    parse: Callable[..., dict]  # (raw output, {tool name: parameters schema}, language of its errors) -> thinking, tool_calls, content
78    thought_prefix: str  # a prompt that ends with this has the model start inside its thinking
79    channel_switch: dict[str, str]  # tokens that switch what the model is writing: thought, tool_call, content
80    stop_tokens: dict[str, dict[str, str]]  # tokens that end a generation -> language -> what stopping there means
81
82    # Writing the conversation back for the chat template.
83    reasoning_key: str  # the assistant-message field the chat template reads earlier thinking from
84    text_with_calls: bool  # whether text written before a tool call survives re-rendering the history
85
86    # Showing it on the page; the text is in both of the page's languages (i18n.py).
87    user_turn: str  # where a user message starts in a prompt; the step card shows the newest one
88    special_groups: dict[str, list]  # language -> (title, subtitle, ((token, name, what it is for), ...)) for each group of control tokens
89    thinking_help: dict[str, str]  # what the thinking toggle does to the prompt
90    tools_note: dict[str, str]  # how the tool schemas appear in the prompt
91    model_note: dict[str, str] = field(default_factory=dict)
92    sampling: dict | None = None  # recommended temperature / top_p / top_k when a checkpoint ships none
93    byte_level: bool = False  # the vocabulary spells bytes as characters, GPT-2 style (BYTE_OF); otherwise it is SentencePiece's
94    processor_kwargs: dict = field(default_factory=dict)  # passed to the processor along with images, audio and video
95    file_notes: dict[str, FileNote] = field(default_factory=dict)  # a file in the checkpoint's folder -> what it holds in this family
96    drafter_file_notes: dict[str, FileNote] = field(default_factory=dict)  # the same for the folder of its MTP drafter (DRAFTERS)
97
98    def show_piece(self, piece: str) -> str:
99        """A vocabulary entry as the text it stands for, which is what a token's popover shows: ␣ for a space,
100        ↵ for a newline, and a byte that is only part of a character as <0xE5>, the way SentencePiece writes one."""
101        if self.byte_level and all(c in BYTE_OF for c in piece):
102            # surrogateescape keeps each byte that is not whole UTF-8 as U+DC80–U+DCFF, so it can be written in hex.
103            text = bytes(BYTE_OF[c] for c in piece).decode("utf-8", errors="surrogateescape")
104            text = "".join(f"<0x{ord(c) - 0xDC00:02X}>" if "\udc80" <= c <= "\udcff" else c for c in text)
105        else:
106            text = piece.replace("▁", " ")  # SentencePiece marks a leading space with ▁
107        return text.replace(" ", "␣").replace("\n", "↵")
108
109    @cached_property
110    def special_tokens(self) -> dict[str, dict[str, tuple[str, str]]]:
111        """Control token -> language -> (name, what it is for)."""
112        out: dict[str, dict[str, tuple[str, str]]] = {}
113        for lang, groups in self.special_groups.items():
114            for _, _, rows in groups:
115                for piece, name, desc in rows:
116                    out.setdefault(piece, {})[lang] = (name, desc)
117        return out
118
119    @cached_property
120    def special_re(self) -> re.Pattern:
121        """Matches the control tokens in a prompt, longest first so <|image|> is not read as <|image>."""
122        pieces = sorted(self.special_tokens, key=len, reverse=True)
123        return re.compile("|".join(re.escape(p) for p in pieces))

When a model loads, the model_type in the checkpoint's config.json finds its family. The stop tokens are those the generation config lists plus those the family declares: the Qwen3.5 checkpoint lists only <|endoftext|>, not the <|im_end|> that ends every turn, so without the family the model would keep writing after its answer. Qwen's tokenizer does not mark <think> and <tool_call> as special either, and the family adds those too. The tokenizer comes inside the model's processor, which also turns images, sound and video into the model's inputs; which of those a model reads, the checkpoint says (§7 “Images, sound and video: tokens in the context”).

agent/model_backends/simple_backend.pylines 145–161
145self.family = model_families.for_model_type(AutoConfig.from_pretrained(_download(model_id, "config.json")).model_type)
146files = self.folder = _download(model_id)
147# The processor holds the tokenizer and, for a model that reads more than text, what turns files into its inputs.
148self.processor = AutoProcessor.from_pretrained(files)
149self.tokenizer = getattr(self.processor, "tokenizer", self.processor)
150self.model = AutoModelForMultimodalLM.from_pretrained(files, dtype=self.dtype, device_map=self.device).eval()
151self.modalities = self._modalities()
152self.media_token_ids = {kind: getattr(self.model.config, f"{kind}_token_id", None) for kind in self.modalities}
153self.load_seconds = time.perf_counter() - start
154
155# The generation config's stop tokens, plus the family's: a checkpoint may list only some of them.
156# Qwen3.5 ships no generation_config.json, and its config.json names <|endoftext|> but not <|im_end|>,
157# which ends every chat turn.
158eos = self.model.generation_config.eos_token_id
159self.eos_ids = set(eos if isinstance(eos, list) else [eos]) | self.token_ids(self.family.stop_tokens)
160# Control tokens count as special even when the tokenizer does not mark them (Qwen's <think>).
161self.special_ids = set(self.tokenizer.all_special_ids) | self.eos_ids | self.token_ids(self.family.special_tokens)

To add a model: another checkpoint of a family that is already here (a bigger Gemma 4, say) is one line in MODELS (agent/model_families/__init__.py:23) and one in REVISIONS, the commit it downloads at; with an MTP drafter, also DRAFTERS and the drafter's commit, and VLLM_SPECULATIVE for how vLLM drafts for it on a GPU. A new family is a module like agent/model_families/qwen3_5.py, with a parser for the output and notes on the special tokens (in Chinese and in English), added to FAMILIES (agent/model_families/__init__.py:129); a matching model_type picks it automatically.

agent/model_families/qwen3_5.pylines 269–307Qwen3.5's family is almost all data; only parse is code.
269QWEN3_5 = Family(
270    key="qwen3_5",
271    name="Qwen3.5",
272    model_types=("qwen3_5",),
273    parse=parse_output,
274    thought_prefix="<think>\n",
275    channel_switch={"<think>": "thought", "</think>": "content", "<tool_call>": "tool_call", "</tool_call>": "content"},
276    stop_tokens={
277        "<|im_end|>": {"zh": "訊息結束;回覆和工具呼叫都停在這裡", "en": "end of message; replies and tool calls both stop here"},
278        "<|endoftext|>": {"zh": "文字結束", "en": "end of text"},
279    },
280    reasoning_key="reasoning_content",
281    text_with_calls=True,  # the template keeps a message's text in front of its <tool_call>
282    user_turn="<|im_start|>user",
283    special_groups=GROUPS,
284    thinking_help={
285        "zh": "chat template 在 assistant 訊息開頭先寫好 `<think>`,模型先寫推理;關掉時寫一段空的 `<think></think>`。"
286        "0.8B 官方預設不開,開了容易一直想下去停不下來。",
287        "en": "The chat template opens the assistant message with `<think>`, so the model reasons first; off, it writes an empty `<think></think>`. "
288        "Off by default for the 0.8B model, which once it starts thinking often cannot stop.",
289    },
290    tools_note={
291        "zh": "chat template 把每個工具的 JSON Schema 原樣放進 system turn 的 `<tools>…</tools>`,後面附上 XML 呼叫格式的說明。",
292        "en": "The chat template puts each tool's JSON Schema as it is in `<tools>…</tools>` in the system turn, followed by how to write a call in XML.",
293    },
294    model_note={
295        "zh": "混合架構:24 層裡每 4 層有 3 層是 Gated DeltaNet(線性 attention,狀態大小固定,不隨脈絡變長),1 層是 gated attention。"
296        "在 transformers 裡沒裝 flash-linear-attention 時,它們用 PyTorch 參考實作,prefill 比較慢。",
297        "en": "A hybrid: of every 4 of its 24 layers, 3 are Gated DeltaNet (linear attention, whose state has a fixed size and does not grow with the context) and 1 is gated attention. "
298        "In transformers, without flash-linear-attention installed, they run the PyTorch reference implementation, so prefill is slower.",
299    },
300    # The checkpoint ships no generation_config.json; these are the model card's non-thinking text settings.
301    sampling={"temperature": 1.0, "top_p": 1.0, "top_k": 20},
302    byte_level=True,
303    # Cap each video frame at the pixels the reference implementation (qwen-vl-utils) allows; transformers asks
304    # for this to be said outright until it becomes the default.
305    processor_kwargs={"cap_pixels_per_frame": True},
306    file_notes=FILES,
307)
3

Model

The model folder: every file in models/

A model's files are in a folder of its own in models/: transformers loads the whole model from it, vLLM reads the tokenizer and the chat template there too, and in Docker the weights as well. Here is each file, for both models and Gemma's drafter.

Whatever runs the model, a download is snapshot_download() into MODELS_DIR (agent/model_backends/simple_backend.py:48): by default the project's models/, which git ignores; AGENT_LOOP_MODELS moves it elsewhere (HF_HUB_CACHE does not, since it is often set once for every project on a machine). It downloads the commit pinned in REVISIONS (agent/model_families/__init__.py:48), not whatever main is by then, so whenever it runs, it gets the files this page was written and tested against. Files already there are not fetched again, and when the Hub cannot be reached, what the folder holds is used.

Inside, models/ is laid out as Hugging Face's cache:

Which files are downloaded, and what reads the weights, depends on what runs the model:

SimpleBackend has _download() fetch the whole checkpoint, the weights included, and loads all of it on this machine. It fetches config.json alone first, to tell the model's family from it, so a model the page does not support fails before gigabytes of weights come down (§2 “Model families: switching models changes only this layer”). The MTP drafter downloads the same way, the first time MTP is switched on (§10 “MTP: the drafter guesses, the model checks them all at once”).

agent/model_backends/simple_backend.pylines 51–61
51def _download(model_id: str, only: str | None = None) -> str:
52    """The model's folder in MODELS_DIR, after fetching the files it lacks (only `only`, if given), at the revision
53    model_families.REVISIONS pins it to.
54
55    from_pretrained(local_files_only=True) is no test of the cache: once config.json is cached, a tokenizer whose
56    own files are missing raises a ValueError (Gemma 4) or loads empty (Qwen3.5) rather than an OSError.
57    snapshot_download lists the model's files on the Hub and fetches only those the cache lacks; when the Hub
58    can't be reached it returns the cached folder, so a downloaded model still starts offline."""
59    if os.path.isdir(model_id):  # a checkpoint on disk rather than a Hugging Face id
60        return model_id
61    return snapshot_download(model_id, revision=model_families.REVISIONS.get(model_id), allow_patterns=only, cache_dir=MODELS_DIR)

Before it starts the server, vllm_docker.start() downloads the whole checkpoint into models/, the weights included, and with MTP on a GPU Gemma 4's drafter too (Qwen3.5's MTP layer is in its own weights). The server itself downloads nothing:

agent/model_backends/vllm_docker.pylines 191–194
191say("download", "")
192snapshot_download(model_id, revision=model_families.REVISIONS.get(model_id), cache_dir=MODELS_DIR)
193if config and (drafter := config.get("model")):  # the server reads models/ offline, its drafter's weights too
194    snapshot_download(drafter, revision=config["revision"], cache_dir=MODELS_DIR)

docker-compose/compose.yaml mounts that folder in the container at /models, read-only, and points Hugging Face's cache there, with no network. _env() hands Docker Compose the full path of MODELS_DIR as AGENT_LOOP_MODELS (agent/model_backends/vllm_docker.py:113), so a models/ moved elsewhere is the one mounted. What vLLM compiles for the model, the first start's longest wait, goes in a Docker volume of its own, not in models/:

docker-compose/compose.yamllines 37–42
37  - ${AGENT_LOOP_MODELS:-../models}:/models:ro  # the project's models/; by hand, a full path, since Compose takes a relative one from here
38  # What vLLM compiles for a model, the first start's longest wait, kept for the next container that loads it.
39  - compile-cache:/root/.cache/vllm
40environment:
41  HF_HUB_CACHE: /models
42  HF_HUB_OFFLINE: "1"

vLLM is given the model's id and the commit in REVISIONS (--revision), which in the cache is the folder models--<org>--<name>/snapshots/<revision>/, the very files transformers would load. The cache's links are relative, so under /models they still lead to blobs/. Offline, vLLM would call the model by that folder's path, so --served-model-name gives it back the id the page asks for:

docker-compose/compose.yamllines 23–26
23- ${VLLM_MODEL:-Qwen/Qwen3.5-0.8B}
24- --revision=${VLLM_REVISION:-2fc06364715b967f1860aea9cf38778875588b17}
25# Offline, vLLM names the model by its folder in models/; the page asks for it by its id.
26- --served-model-name=${VLLM_MODEL:-Qwen/Qwen3.5-0.8B}

The page reads that same folder: VLLMBackend's _checkpoint_files() wants every file but the weights, which are all there already, so nothing more downloads. There is one copy on disk: the server loads the weights from it, and the page reads the tokenizer and the chat template there to turn the prompt into token ids (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”).

A server at VLLM_URL has a copy of the model of its own, on the machine it runs on, so the page downloads every file but the weights into models/ (none of the kinds WEIGHTS lists, agent/model_backends/vllm_backend.py:65), at the same commit:

agent/model_backends/vllm_backend.pylines 160–166
160def _checkpoint_files(model_id: str) -> str:
161    """The model's folder in MODELS_DIR with everything but its weights, at the revision model_families.REVISIONS pins.
162    The server should load the same revision (vllm serve <model> --revision <commit>) so that both read the same
163    tokenizer and chat template."""
164    if os.path.isdir(model_id):  # a checkpoint on disk rather than a Hugging Face id
165        return model_id
166    return snapshot_download(model_id, revision=model_families.REVISIONS.get(model_id), cache_dir=MODELS_DIR, ignore_patterns=WEIGHTS)

For the two to read the same tokenizer and chat template, start the server at the commit in REVISIONS, as in vllm serve Qwen/Qwen3.5-0.8B --revision 2fc0636…. The page downloads by the name the server was started with (the root in its /v1/models), so a server started from a folder of its own (vllm serve /data/gemma) works only when the same folder is at the same path on this machine, which the page then reads where it is, downloading nothing; otherwise the page says why it cannot load.

Before that, the model menu downloads only the config.json of each model the server serves, whose model_type tells its family, and whether the page supports it (agent/model_backends/vllm_backend.py:178); the other files come once that model is picked.

When the model loads, which code reads which file:

A revision is exactly the same files on macOS, on Linux with CUDA or on a computer with only a CPU. What differs is how they load: pick_device() picks the device (agent/model_backends/simple_backend.py:110), and the weights load in the files' bfloat16 on a GPU but become float32 on a CPU, twice the memory.

  • config.json: AutoConfig reads it first, and its model_type picks the family (agent/model_backends/simple_backend.py:145); the specs at the top of the Model tab come from it too.
  • tokenizer.json, tokenizer_config.json, chat_template.jinja and the processor's settings: AutoProcessor reads them into one processor (agent/model_backends/simple_backend.py:148); render() runs the chat template (agent/model_backends/simple_backend.py:249).
  • The weights: AutoModelForMultimodalLM builds the model from config.json and fills it with the tensors of the .safetensors file by name (agent/model_backends/simple_backend.py:150).
  • generation_config.json: the stop tokens (agent/model_backends/simple_backend.py:158) and the recommended sampling settings (agent/model_backends/simple_backend.py:207).
  • README.md, LICENSE and .gitattributes: the model card, the license and the settings of the Hub's git, for people; the app does not read them.

VLLMBackend reads the same files the same way, all but the weights:

  • config.json: AutoConfig reads it, and its model_type picks the family (agent/model_backends/vllm_backend.py:345); the specs at the top of the Model tab come from it too, but the context limit is the server's (--max-model-len, agent/model_backends/vllm_backend.py:354).
  • tokenizer.json, tokenizer_config.json, chat_template.jinja and the processor's settings: AutoProcessor reads them into one processor, as SimpleBackend does (agent/model_backends/vllm_backend.py:347), so the chat template runs here, not on the server (§6 “Context: rendering the whole conversation into one string”). The server loads a tokenizer and a processor from its copy too (in Docker, this very folder), to expand the media (§7 “Images, sound and video: tokens in the context”).
  • The weights: the server loads them, on its device and in its precision, and the page never does, so the Model tab has no device, precision or parameter count (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”).
  • generation_config.json: the page takes the stop tokens and the recommended sampling settings from it (agent/model_backends/vllm_backend.py:194). The server reads it too, for sampling defaults of its own, which every request sets to do nothing (§9 “Sampling: how each token is picked”).
  • README.md, LICENSE and .gitattributes: neither the page nor the server reads them.

The files in each model's folder:

Gemma 4 E2B (google/gemma-4-E2B-it, 9 files, about 10.3 GB):

  • config.json (5 kB): Gemma4ForConditionalGeneration, in three parts. text_config is the 35-layer language model: 28 sliding window layers, which see only the last 512 tokens, and 7 full attention layers, hidden size 1536, 8 attention heads sharing 1 set of keys and values; the last 20 layers (num_kv_shared_layers) compute no keys and values of their own but use those of earlier layers. vision_config is a 16-layer vision encoder that makes an image at most 280 tokens, and audio_config a 12-layer audio encoder. The context limit is 131,072 tokens.
  • generation_config.json (208 B): the stop tokens [1, 106, 50], that is <eos>, <turn|> and <|tool_response>; recommended T 1.0, top-p 0.95, top-k 64.
  • model.safetensors (10.2 GB): 2,011 tensors, 5.12B parameters, all bfloat16. The largest part is the Per-Layer Embeddings (embed_tokens_per_layer, 2.35B, 46%): each of the 35 layers keeps a 256-wide vector for each of the 262,144 tokens, only looked up and never multiplied, which is why the name counts effective parameters (E2B). The rest is the 35 layers themselves at 1.88B, the input embedding at 0.40B, the audio encoder at 0.30B and the vision encoder at 0.17B.
  • tokenizer.json (32 MB): a SentencePiece-style BPE with 262,144 tokens and 514,906 merges; a space becomes ▁ in front of the next word, and a character the vocabulary lacks splits into UTF-8 bytes. tokenizer_config.json (3 kB) gives the control tokens their roles (sot_token is <|turn>, stc_token <|tool_call> and so on).
  • chat_template.jinja (19 kB): writes the <|turn>system / user / model turns, the <|think|> for thinking, the tools as <|tool>declaration:…<tool|> and the attachments' placeholders (§6 “Context: rendering the whole conversation into one string”).
  • processor_config.json (1.7 kB): one file for all three inputs. An image is cut into 16-pixel patches pooled 3×3, at most 280 tokens; sound is sampled at 16 kHz into 128 mel bands, one token per 40 ms and at most 750 (30 seconds); a video takes at most 32 frames of at most 70 tokens each (§7 “Images, sound and video: tokens in the context”).

Qwen3.5 0.8B (Qwen/Qwen3.5-0.8B, 13 files, about 1.8 GB):

  • config.json (2.9 kB): Qwen3_5ForConditionalGeneration. text_config is the 24-layer language model, 18 Gated DeltaNet layers (linear_attention) and 6 full attention layers (one in every 4), hidden size 1024, 8 heads with 2 sets of keys and values, a context limit of 262,144; vision_config is a 12-layer vision encoder that images and video share; there is no sound encoder. mtp_num_hidden_layers: 1 says the weights come with an MTP layer.
  • No generation_config.json: the only stop token left is config.json's <|endoftext|>, so the family adds <|im_end|>; the recommended sampling settings (T 1.0, top-p 1.0, top-k 20) come from the family too (agent/model_families/qwen3_5.py:301).
  • model.safetensors-00001-of-00001.safetensors (1.7 GB): a single shard, named in the sharded format, with model.safetensors.index.json recording which shard each of the 488 tensors is in. 0.87B parameters: the 24 layers at 0.50B, the embedding at 0.25B, the vision encoder at 0.10B, and a 20M MTP layer (mtp.) that transformers skips when loading. Most are bfloat16; the Gated DeltaNet layers' A_log and norms are stored in float32.
  • tokenizer.json (12.8 MB): a byte-level BPE as GPT-2's, 248,044 tokens plus 26 added ones and 247,587 merges after NFC normalization; every byte is spelled as one printable character, a space as Ġ. vocab.json (6.7 MB) and merges.txt (3.4 MB) are the same vocabulary and merges in the older format. tokenizer_config.json (16.7 kB) takes <|im_end|> as its eos_token and also holds a chat template identical to chat_template.jinja.
  • chat_template.jinja (7.8 kB): ChatML, with the tool schemas in <tools>…</tools>, a tool result as a user message and the thinking inside <think> (§6 “Context: rendering the whole conversation into one string”).
  • preprocessor_config.json and video_preprocessor_config.json: an image is cut into 16-pixel patches, 2×2 of them merged into one token, so a token covers 32×32 pixels, and an image is kept between 65,536 and 16,777,216 pixels; in a video, every 2 frames make one set of tokens (§7 “Images, sound and video: tokens in the context”).

Gemma 4 E2B's MTP drafter (google/gemma-4-E2B-it-assistant, 7 files, about 190 MB):

  • config.json: Gemma4AssistantForCausalLM, 4 layers (3 sliding window, 1 full attention), hidden size 256 next to the model's 1536 (backbone_hidden_size); all 4 read the model's keys and values (num_kv_shared_layers: 4). Its output is clustered: the 262,144 tokens are sorted into 2,048 clusters of 128, the clusters are scored first, and logits are computed only for the 4,096 tokens of the best 32 (§10 “MTP: the drafter guesses, the model checks them all at once”).
  • generation_config.json: num_assistant_tokens: 6, six guesses at a time (agent/model_backends/simple_backend.py:233).
  • model.safetensors (158 MB): 77.7M parameters. 67M of them are embed_tokens (262,144 tokens × 256), used as the output table; pre_projection narrows the model's token embedding next to its hidden state (3,072 wide) to 256, and post_projection widens it back to 1,536; the 4 layers themselves are only about 9M. masked_embedding.token_ordering is 262,144 integers listing the tokens cluster by cluster.
  • tokenizer.json and tokenizer_config.json: the same vocabulary as the model's; the app does not load them, since the drafter reads and writes the model's token ids.

The model folder card at the bottom of the Model tab lists these files for the model that is loaded: describe_folder() reads each file's size and the values of the fields the family's notes mention (agent/model_backends/simple_backend.py:64). The notes themselves are on the family (Gemma: agent/model_families/gemma4.py:418; Qwen: agent/model_families/qwen3_5.py:306), so another checkpoint of the family shows its own numbers. With the model on vLLM, it lists the folder the page reads, and weights that are not in it (with a server at VLLM_URL) are on the server only (app/render.py:1434).

agent/model_families/__init__.pylines 62–67
62class FileNote:
63    """What one file in a checkpoint's folder holds in this family. The Model tab shows the note next to the file,
64    with the values in this checkpoint of `fields`, dotted paths into a JSON file (text_config.sliding_window)."""
65
66    text: dict[str, str]  # language -> the note
67    fields: tuple[str, ...] = ()
4

Page

Sending a question: load the model, start a thread for the agent

The input box and the example buttons both go through start_run(): take the lock, freeze the settings, start a thread.

On every rerun, current_llm() first gets a Backend for the model picked in the sidebar, and the rest of the page takes it for just that (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”). load_llm() is cached with st.cache_resource(max_entries=1), so only one model stays in memory. Switching models, or what runs them, first has release_model() clear that cache and hand the memory back, and only then does the next model load, so the two never hold memory at once. Streamlit's fast reruns are off too (.streamlit/config.toml): a rerun waits for the run in progress to reach its next Streamlit call rather than running beside it in a new thread, so a switch made while a model loads takes effect once it has loaded, and the model does not finish loading after it was let go. The first time a model is used, its recommended temperature, top-p and top-k go into the sidebar; the Qwen3.5 checkpoint ships no generation_config.json, so its recommendations come from the family (agent/model_backends/simple_backend.py:207). Switching models in the sidebar's menu starts a new conversation (app/page.py:585).

app/page.pylines 169–180
169@st.cache_resource(max_entries=1, show_spinner=False)  # one model in memory: switching models frees the last one
170def load_llm(model_id: str, url: str | None) -> Backend:
171    llm = vllm_backend.VLLMBackend(model_id, url) if url else SimpleBackend(model_id)
172    llm.warmup()
173    return llm
174
175
176def release_model():
177    """Let the model in memory go now, rather than once the next one has loaded beside it."""
178    load_llm.clear()
179    gc.collect()
180    empty_cache()

The built-in harness runs this very model: the sidebar picks the model at its top, then what runs it.

With pi picked, pi_picker() first starts pi once to ask which models it has (agent/harnesses/pi_harness.py:179), at most once a minute. With This page's model picked under The model pi asks, it then picks and loads the model as the built-in harness does; with pi's own models, it lists those pi has credentials for, and nothing loads on this machine.

app/page.pylines 310–312
310if st.session_state.pi_source == "page":
311    model_picker(model_menu(), busy)
312    return PiAgent(st.session_state.model_id, found["version"], llm=current_llm())

Loading the page's model, the rest depends on what runs it (for the built-in harness and for pi on this page's model alike):

load_llm() makes a SimpleBackend, which loads the model onto this machine (§3 “The model folder: every file in models/”). Right after transformers is picked again over vLLM (Docker), current_llm() first stops vLLM's container, and only then loads the model. On CUDA, cuBLAS's workspaces go before the memory is handed back: cuBLAS keeps one from the first matmul on, carved out of the segment transformers reserved for the weights, and while it lives none of that segment can go back (agent/model_backends/simple_backend.py:125). Switching models turns MTP back off; the first time MTP speculative decoding is switched on, current_llm() also loads the drafter (agent/model_backends/simple_backend.py:225).

current_llm() first makes sure the vLLM server in Docker is up, starting it if it is not, and takes its URL (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”); load_llm() then makes a VLLMBackend for it. What runs the model is, like the model in memory, one choice for every tab: when another tab has started or stopped vLLM's container, this one follows rather than load the model beside it (app/page.py:403), and a new tab or a reload also takes the model the container runs (app/page.py:396). MTP follows the server too, on or off, rather than start it again the other way, and switching models leaves MTP as it is, since it is how the server was started (§10 “MTP: the drafter guesses, the model checks them all at once”). Only the tab that just picked leads, until current_llm() has started or stopped vLLM.

load_llm() makes a VLLMBackend for the server at VLLM_URL. The model menu lists the models that server serves, the sidebar has no What runs the model to pick, and the page never starts or stops the server; MTP is the server's, and the sidebar's switch is disabled (§10 “MTP: the drafter guesses, the model checks them all at once”).

app/page.pylines 224–246
224def current_llm() -> Backend:
225    model_id = st.session_state.model_id
226    if st.session_state.pop("stop_vllm", False):  # transformers was just picked again: vLLM's memory goes back first
227        with st.spinner(tr("停止 vLLM 的容器…", "Stopping vLLM's container…")):
228            vllm_docker.stop()
229    st.session_state.pop("engine_picked", None)  # vLLM is stopped or about to start: from now on this tab follows it too
230    url = vllm_server(model_id)["url"] if docker_vllm() else VLLM_URL
231    if url:
232        loading = tr(f"連線到 {url} 的 vLLM 伺服器,載入 {model_id} 的 tokenizer…", f"Connecting to the vLLM server at {url} and loading {model_id}'s tokenizer…")
233    else:
234        loading = tr(f"載入 {model_id}(第一次要下載權重;之後約 10–20 秒)…這時候切換的設定,載入完才會生效。",
235                     f"Loading {model_id} (the first time downloads the weights; after that it takes about 10–20 s)… What you switch meanwhile takes effect once it has loaded.")
236    with st.spinner(loading):
237        llm = load_llm(model_id, url)
238    if st.session_state.sampling_for != model_id:  # a model just chosen starts from its own recommended decoding, MTP off
239        # but on vLLM in Docker, where MTP is how the server was just started
240        for key, value in {**llm.info["defaults"], **({} if docker_vllm() else {"mtp": False})}.items():
241            st.session_state[key] = value
242        st.session_state.sampling_for = model_id
243    if st.session_state.mtp and llm.drafter_id and llm.drafter is None:
244        with st.spinner(tr(f"載入 MTP 草稿模型 {llm.drafter_id}(第一次要下載約 190 MB)…", f"Loading the MTP drafter {llm.drafter_id} (the first time downloads about 190 MB)…")):
245            llm.load_drafter()
246    return llm

The input box (app/page.py:803) and the example buttons both call start_run(). Files can be attached in the input box, and a model that hears can be sent a recording; some examples attach a sample file from app/assets/examples/, and the same files are listed above the input box, to go with a question you type yourself. start_run() first tries to take a global lock without waiting (app/page.py:457): there is one copy of the model, so however many tabs are open, only one agent can run at a time, and without the lock the tab shows a notice. Only with the lock does it save the files in the conversation's folder (§7 “Images, sound and video: tokens in the context”).

app/page.pylines 454–468The lock is the threading.Lock in generation_slot(), shared by every tab through st.cache_resource.
454def start_run(who: "Backend | PiAgent", text: str, uploads: Sequence[tuple[str, bytes]] = ()) -> bool:
455    """Ask the question in a new run, with `uploads` (file name, contents) saved in the conversation's folder first."""
456    slot = generation_slot()
457    if not slot.lock.acquire(blocking=False):
458        st.toast(tr("另一個 agent 正在執行(可能在別的分頁),請稍等。", "Another agent is running (maybe in another tab). Please wait."))
459        return False
460    try:
461        attachments = [media.save(chat_folder(), name, data, who.modalities) for name, data in uploads]
462        run = LiveRun(harness_for(who, text, attachments), text, attachments, slot)
463    except Exception:
464        slot.lock.release()
465        raise
466    st.session_state.runs.append(run)
467    st.session_state.view = len(st.session_state.runs) - 1
468    return True

current_settings() packs the sidebar's values into RunSettings, with the decoding parameters (MTP included) in its Sampling. These settings hold for the whole turn; changing the sidebar during a run only affects the next one. It also records the page's language: the errors that the harness and the tools send the model are worded in it for the whole turn. tools are the tools the model is offered: run_python only when Let the model write code is on and Docker is ready (§16 “Environment: really running the tool”).

app/page.pylines 418–428
418def current_settings(llm: Backend) -> RunSettings:
419    s = st.session_state
420    return RunSettings(
421        sampling=Sampling(s.temperature, s.top_p, s.top_k, s.max_new_tokens, mtp=s.mtp and (llm.drafter is not None or docker_vllm())),
422        max_steps=s.max_steps,
423        thinking=s.thinking,
424        use_tools=s.use_tools,
425        tools=offered_tools(),
426        approve_tools=s.approve_tools,
427        lang=s.lang,
428    )

harness_for() decides what runs the turn: a SimpleHarness on the model in memory, or a PiHarness, which, when pi asks this page's model, gets that model and the sidebar's decoding too (PageModel). Either gets the conversation so far, the attachments and the conversation's folder, and its settings are fixed now.

app/page.pylines 471–479
471def harness_for(who: "Backend | PiAgent", text: str, attachments: list[media.Attachment]) -> Callable[[Callable[[dict], None]], Harness]:
472    """What runs the turn: pi, or this project's harness on the model in memory, with the sidebar's settings now."""
473    history, folder = st.session_state.history, chat_folder()
474    if isinstance(who, PiAgent):
475        settings, session = pi_settings(who), st.session_state.chat_id
476        page = pi_harness.PageModel(who.llm, current_settings(who.llm).sampling) if who.llm else None
477        return lambda emit: pi_harness.PiHarness(settings, history, text, emit, attachments, folder, session, page)
478    run_settings = current_settings(who)
479    return lambda emit: SimpleHarness(who, history, text, run_settings, emit, attachments, folder)

LiveRun makes that harness with self.events.append as its emit, and starts the background thread. The thread releases the lock in a finally only after run.run() returns, so the lock always comes back, even when the agent fails.

app/page.pylines 344–372daemon=True: stopping the server does not wait for the agent to finish.
344class LiveRun:
345    """One user turn: the harness thread and the events it has emitted so far. `make` makes the harness (simple_harness.SimpleHarness
346    or pi_harness.PiHarness) given the function it sends its events to."""
347
348    def __init__(self, make: Callable[[Callable[[dict], None]], Harness], text: str, attachments: list[media.Attachment], slot: Slot):
349        self.text = text
350        self.attachments = attachments
351        self.events: list[dict] = []
352        self.absorbed = False
353        self.run = make(self.events.append)
354        self._slot = slot
355        slot.run = self
356        self.thread = threading.Thread(target=self._work, daemon=True)
357        self.thread.start()
358
359    def _work(self):
360        try:
361            self.run.run()
362        finally:
363            if self._slot.run is self:
364                self._slot.run = None  # do not keep the model alive after a switch to another one
365            self._slot.lock.release()
366
367    @property
368    def live(self) -> bool:
369        return self.thread.is_alive()
370
371    def data(self) -> dict:
372        return render.build(self.events[:])
5

Harness

Preparing a run: messages, tools, system prompt

The harness gathers what the model will see this turn: the conversation, the tools and the system prompt.

SimpleHarness.__init__ appends this question to the conversation so far and keeps it as self.messages (agent/harnesses/simple_harness.py:72). Tool calls and tool results are appended after it; the model itself remembers nothing, and what it sees in each step is this list rendered into a string again. The messages use Hugging Face's common format (role, content, tool_calls), which both families accept; each family's chat template takes care of its own format. With files attached, that message's content is a list of parts (§7 “Images, sound and video: tokens in the context”). self.tools are the tools the model gets this turn, and self.files is the conversation's folder. _stop and _decided are the two synchronization tools the UI thread signals the agent with.

agent/harnesses/simple_harness.pylines 67–80
67def __init__(self, llm: Backend, history: list[dict], user_text: str, settings: RunSettings, emit: Callable[[dict], None],
68             attachments: Sequence[media.Attachment] = (), files: Path | None = None):
69    self.id = uuid.uuid4().hex[:12]
70    self.llm = llm
71    self.settings = settings
72    self.messages = [*history, {"role": "user", "content": media.user_content(user_text, list(attachments))}]
73    self.files = files  # the conversation's folder, which run_python's sandbox sees as /data
74    self.tools = {name: t for name, t in TOOLS.items() if name in settings.tools} if settings.use_tools else {}
75    self._emit = emit
76    self._start = time.perf_counter()
77    self._stop = threading.Event()
78    self._timed_out = False
79    self._decisions: dict[str, bool] = {}
80    self._decided = threading.Condition()

run() starts by taking the model's family and deciding what the model gets to see this turn: it collects the schema() of every tool in self.tools for the chat template (none with Use tools off), and keeps a map from tool name to parameters schema for the parser (Qwen's argument values are plain text, and only the schema says their types); the system prompt comes in two versions, with tools (agent/harnesses/simple_harness.py:36) and without. The first event, run_start, carries the model and the family, so the page knows whose format to draw the turn in; it also says which harness ran the turn (simple, or pi's pi).

agent/harnesses/simple_harness.pylines 99–113stop_reason starts out as max_steps: when the loop uses up its steps without a break, the step limit is why it stopped.
99def run(self):
100    s = self.settings
101    family = self.llm.family  # how this model writes its turns, thinking and tool calls
102    tools = [t.schema() for t in self.tools.values()] or None
103    schemas = {t.name: t.parameters for t in TOOLS.values()}
104    system = {"role": "system", "content": SYSTEM_WITH_TOOLS if tools else SYSTEM_NO_TOOLS}
105    self.emit(
106        "run_start", run_id=self.id, harness="simple", model=self.llm.model_id, family=family.key,
107        settings=_settings_dict(s), tools=list(self.tools),
108    )
109
110    seen: list[tuple[str, str]] = []
111    format_errors = 0
112    prev_sequence = ""
113    stop_reason, final = "max_steps", ""

A tool's schema is a JSON Schema, wrapped in the function format that Hugging Face chat templates know:

agent/tools.pylines 84–88
84def schema(self) -> dict:
85    return {
86        "type": "function",
87        "function": {"name": self.name, "description": self.description, "parameters": self.parameters},
88    }

PiHarness.run() starts pi, picks the model and the thinking level and turns pi's own retries off (a retry would be a step of its own, so a failed request is shown as an error instead), then sends the question as a prompt, each command only after pi has answered the last, since pi handles commands concurrently and a prompt sent beside set_model would go to the old model.

agent/harnesses/pi_harness.pylines 289–298
289self._pi = Process(self._args(), self.folder, self._inbox, self._env())
290# Each waits for its answer: pi handles commands concurrently, and a prompt must not go to the model it starts on.
291self._pi.request({"type": "set_model", "provider": s.provider, "modelId": s.model})
292self._pi.request({"type": "set_thinking_level", "level": s.thinking})
293# A failed request is retried by pi as a new turn, which would be a step of its own; the page shows the error instead.
294self._pi.request({"type": "set_auto_retry", "enabled": False})
295message, images = self._message()
296if not self._stopped_meanwhile():
297    self._pi.request({"type": "prompt", "message": message, **({"images": images} if images else {})})
298    self._loop()

What the model gets to see is pi's: its own system prompt, its tools (of which the sidebar picks the ones the model sees) and the session it keeps in the conversation's folder, on which the next question starts pi again, so the model reads all of it. The page starts pi without your own extensions, skills, prompt templates and AGENTS.md (agent/harnesses/pi_harness.py:56), so what it draws is pi's own loop; the one thing it adds is its extension, pi_extension.ts (§6 “Context: rendering the whole conversation into one string”, §15 “Validation and permission: the model never touches the environment”). Files attached to the question are saved in pi's working folder and named in the question, and a model that reads images gets them too (agent/harnesses/pi_harness.py:344). run_start is as the built-in harness's; it carries the family when pi asks this page's model, and none when pi asks one of its own.

6

Context

Context: rendering the whole conversation into one string

The chat template joins the system prompt, the tools and the conversation into one long string; it is all the model can see in this step.

Each step starts at for step in range(1, s.max_steps + 1); the sidebar's Max steps is the top of that range. The first thing is to render:

agent/harnesses/simple_harness.pylines 116–132step_start has the page open a new step card; context goes out with the whole string the model really reads.
116for step in range(1, s.max_steps + 1):
117    self.emit("step_start", step=step)
118    prompt = self.llm.render([system, *self.messages], tools, s.thinking)
119    encoded = self.llm.encode(prompt, media.parts(self.messages))
120    # Media sit in user messages, all before what is new since the last step; so the new part is the same
121    # text in the prompt and in what the model reads, only further along by what the media expanded into.
122    added_from = len(encoded.text) - len(prompt) + common_prefix(prompt, prev_sequence) if prev_sequence else 0
123    self.emit(
124        "context",
125        step=step,
126        prompt=encoded.text,
127        n_tokens=encoded.n_tokens,
128        added_from=added_from,
129        media_tokens=encoded.media_tokens,
130        context_length=self.llm.context_length,
131        roles=_role_counts([system, *self.messages]),
132    )

pi starts a turn and sends its request to the provider agent-loop, which is model_endpoint.py: pi's system prompt, its tools and the conversation, as OpenAI's JSON. As on pi's own models, the extension reports the request first, and the Context tab keeps it under the context. The endpoint turns it back into the conversation SimpleHarness keeps (agent/harnesses/model_endpoint.py:121): a tool call's arguments a dict again, the thinking under the family's key, a tool result with its tool's name. Then, as SimpleHarness does, it renders and encodes it and sends context; what is new in the step is counted from the conversation as of the model's last message (agent/harnesses/model_endpoint.py:158).

agent/harnesses/model_endpoint.pylines 215–229
215    messages = conversation(body, llm, serving.folder)
216    prompt = llm.render(messages, tools, thinking)
217    encoded = llm.encode(prompt, media.parts(messages))
218except Exception as e:  # pi shows it as the request's error
219    self._json(400, {"error": {"message": f"{type(e).__name__}: {e}"}})
220    return
221before = seen_before(messages)
222# Media sit in user messages, all before what is new; so the new part is the same text, further along (SimpleHarness).
223if before:
224    previous = llm.render(before, tools, thinking, generation_prompt=False)
225    added_from = len(encoded.text) - len(prompt) + common_prefix(prompt, previous)
226else:
227    added_from = 0
228emit("context", prompt=encoded.text, n_tokens=encoded.n_tokens, added_from=added_from, media_tokens=encoded.media_tokens,
229     context_length=llm.context_length, roles={role: sum(m["role"] == role for m in messages) for role in dict.fromkeys(m["role"] for m in messages)})

Every pi the page starts loads one small extension, pi_extension.ts. Before each request pi sends the model, it sends what the request holds as a notification; in RPC mode a notification is an extension_ui_request on stdout, which PiHarness turns into a request event (agent/harnesses/pi_harness.py:524): the step card's context section and the Context tab show pi's system prompt, its tools and the conversation as JSON, with what this step added (the last step's reply and observation) marked. How the provider's API writes the request out as the string its model reads, neither pi nor the provider says, so the chat template the rest of this section is about is not to be seen here.

agent/harnesses/pi_extension.tslines 50–52
50pi.on("before_provider_request", (event, ctx) => {
51	ctx.ui.notify(REQUEST_MARK + JSON.stringify(event.payload), "info");
52});

render() calls the chat template that comes with the tokenizer, here even when the model runs on vLLM, and when pi asks this page's model (§3 “The model folder: every file in models/”). The format is the model's own, and the harness does not need to know it:

It starts with <bos><|turn>system; with thinking mode on, a <|think|> comes first in the system turn; each tool is written as <|tool>declaration:name{…}<tool|>; each message is wrapped in <|turn>role … <turn|>.

generation_prompt=True (add_generation_prompt) ends it with <|turn>model, opening the model's turn for it to write next.

No <bos>; it starts with <|im_start|>system; the tool schemas go in as JSON, as they are, between <tools> and </tools>, followed by how to write a call in XML; each message is wrapped in <|im_start|>role … <|im_end|>.

generation_prompt=True (add_generation_prompt) ends it with <|im_start|>assistant, opening the model's turn for it to write next, followed by <think> (thinking mode on) or an empty <think></think> (off).

agent/model_backends/simple_backend.pylines 249–254
249def render(self, messages: list[dict], tools: list[dict] | None, thinking: bool, generation_prompt: bool = True) -> str:
250    """The chat template turns messages and tool schemas into the one string the model reads.
251    With `generation_prompt` it also opens the model's turn, so the model writes next."""
252    return self.tokenizer.apply_chat_template(
253        messages, tools=tools or None, tokenize=False, add_generation_prompt=generation_prompt, enable_thinking=thinking
254    )

Next encode() turns the string into the tokens the model really reads (agent/harnesses/simple_harness.py:119). With text alone, they are the tokenizer's tokens; when the conversation has images, sound or video, the chat template wrote a single placeholder token where each goes, and encode() expands it into the tens to thousands of tokens that file takes (§7 “Images, sound and video: tokens in the context”), so context goes out with the expanded string. n_tokens is its length, which the page compares with the context limit to show how much of it is used. added_from is where what is new in this step begins: the length of the start that the rendered string shares with prev_sequence (§14 “Writing the tool call back into the conversation” says what prev_sequence is), plus however much longer the media made it, since the media are all in user messages, before what is new. From step 2 on, what is new is exactly the tool result.

agent/harnesses/simple_harness.pylines 58–63Compares character by character and returns the length of the start the two strings share.
58def common_prefix(a: str, b: str) -> int:
59    n = min(len(a), len(b))
60    i = 0
61    while i < n and a[i] == b[i]:
62        i += 1
63    return i

The page's Context tab prints the string in the context event as it is (app/render.py:1144), followed by what the model writes in this step; it marks everything after added_from as new in this step (in step 1, which has no step before it, from the newest user message on), and turns the special tokens the family lists into dark chips you can hover over for a note; a long run of one special token, like the hundreds of <|image|> an image expands into, is one chip with the count. A step pi ran also has the request it sent, in place of the context on pi's own models, and folded under it on this page's model (app/render.py:1168).

7

Context

Images, sound and video: tokens in the context

Attached files go into the conversation's folder; those the model reads are expanded by its processor into a run of tokens, which the prefill replaces with the encoders' output.

Which things a model reads is found out from the checkpoint when it loads: the config needs that kind of encoder, and the processor the part that feeds it. Gemma 4 E2B has a vision and an audio encoder, so it sees images, hears sound and watches video (frame by frame); Qwen3.5 0.8B has only a vision encoder and reads images and video. The input box takes only files the model reads, plus data files such as CSV and Excel once Let the model write code is on.

agent/model_backends/simple_backend.pylines 196–205
196def _modalities(self) -> tuple[str, ...]:
197    """What the checkpoint reads besides text: each kind needs an encoder in the config and a part of the processor
198    to feed it. It is asked of the checkpoint, not the family, since checkpoints of one family can differ."""
199    config, processor = self.model.config, self.processor
200    reads = {
201        "image": getattr(config, "vision_config", None) is not None and hasattr(processor, "image_processor"),
202        "audio": getattr(config, "audio_config", None) is not None and hasattr(processor, "feature_extractor"),
203        "video": getattr(config, "video_token_id", None) is not None and hasattr(processor, "video_processor"),
204    }
205    return tuple(kind for kind, ok in reads.items() if ok)

On a vLLM server, it is those less what the server does not take: at load, the backend asks the server with a one-token request for each kind of media the checkpoint reads, and the model reads those it takes (agent/model_backends/vllm_backend.py:367). Only /inference/v1/generate takes media, so a server started without --enable-scale-out takes none. A kind it refuses is a plain file for run_python; the Model tab lists the kinds the server refused and why (app/render.py:1250), and above the input box the page says which cannot be attached.

agent/model_backends/vllm_backend.pylines 428–456
428def _probe(self, hi: list[int], kinds: tuple[str, ...]) -> tuple[bool, bool, dict]:
429    """Ask the server, with one-token requests that start from `hi` (a short prompt's ids), whether it has
430    /inference/v1/generate (--enable-scale-out), whether it sends sampling masks (--return-sampling-mask), and which
431    of `kinds` (the checkpoint's media) it takes. A kind it does not take comes back with why, in both of the page's
432    languages."""
433    try:
434        out = self._generate_once(hi, [], top_k=2)
435    except ServerError as e:
436        if e.status is None:  # unreachable, not refused
437            raise
438        if e.status == 403:  # what vLLM answers on that route without --enable-scale-out
439            why = {"zh": "伺服器啟動時沒有加 `--enable-scale-out`,只有 /v1/completions 可用,而它只收文字。",
440                   "en": "the server was started without `--enable-scale-out`, so only /v1/completions is there, which takes text only."}
441        elif e.status == 404:  # a vLLM without the route
442            why = {"zh": "這個版本的 vLLM 沒有 /inference/v1/generate,只有 /v1/completions 可用,而它只收文字;新版 vLLM 加上 `--enable-scale-out` 才有。",
443                   "en": "this vLLM has no /inference/v1/generate, only /v1/completions, which takes text only; a newer vLLM started with `--enable-scale-out` has it."}
444        else:
445            why = {"zh": f"伺服器的 /inference/v1/generate 拒絕了請求,所以只用 /v1/completions,它只收文字({e.text['en']})",
446                   "en": f"the server's /inference/v1/generate refused a request, so only /v1/completions is used, which takes text only ({e.text['en']})"}
447        return False, False, {kind: why for kind in kinds}
448    masks = any(choice.get("sampling_mask") is not None for choice in out.get("choices") or [])
449    refused = {}
450    for kind in kinds:
451        prompt = self.render([{"role": "user", "content": [{"type": kind}, {"type": "text", "text": "Hi"}]}], None, False)
452        try:
453            self._generate_once(self.tokenizer(prompt, add_special_tokens=False).input_ids, [probe_part(kind, self.audio_rate)])
454        except ServerError as e:
455            refused[kind] = {"zh": f"伺服器不收:{e.text['en']}", "en": f"the server refused it: {e.text['en']}"}
456    return True, masks, refused

start_run() saves each file in the conversation's folder, keeping its name to plain characters, since the model may have to type it into code (agent/media.py:62). In the user's message, each file the model reads is a part of its own, before the text; the model cannot see into other files, which get a note after the text: run_python finds them under /data, and a CSV, TSV, plain-text or Markdown file comes with its first line (for a CSV, the column names; not a JSON file, whose first line is mostly a [). Without it a small model guesses the columns in its code, a Date that is not there, say, and fails; a few rows of data are too many, as Gemma copies them into its code and works out the answer from them alone instead of reading the file. The note is for the model, so it is in English, like the system prompt.

agent/media.pylines 97–107
97def user_content(text: str, attachments: list[Attachment]) -> str | list[dict]:
98    """The user's message: the text alone, or the media first (as their models are trained to read them), then the
99    text, and a note on the files only run_python can open: where they are and how a text file begins. The note is
100    for the model, so it is in English."""
101    if not attachments:
102        return text
103    files = [a for a in attachments if a.kind == "file"]
104    described = [f"/data/{a.name} ({fmt_size(a.size)})" + (f", whose first line is: {head}" if (head := preview(a.path)) else "") for a in files]
105    note = "Attached for run_python, which reads them:\n" + "\n\n".join(described) if files else ""
106    words = "\n\n".join(t for t in (text, note) if t)
107    return [a.part() for a in attachments if a.kind != "file"] + ([{"type": "text", "text": words}] if words else [])

The chat template writes a placeholder token where each part goes, and the model's processor expands each placeholder into the tokens that file takes:

The placeholders are <|image|>, <|audio|> and <|video|>. An image is up to 280 <|image|> between <|image> and <image|>; a video is 32 frames taken evenly (all of them, for a shorter one), each after its time as mm:ss and up to 70 <|video|>; sound is one <|audio|> per 40 ms, at most 750, so only the first 30 seconds are heard.

The placeholders are <|vision_start|><|image_pad|><|vision_end|> for an image and <|video_pad|> for a video. The bigger the image, the more tokens (about 300 <|image_pad|> for 640×480); a video gives 2 frames a second, in pairs, each pair after its time, such as <0.3 seconds>.

What expands them, and where the encoders run, depends on what runs the model:

encode() hands the processor all the media in the conversation, in the order they come, to expand:

agent/model_backends/simple_backend.pylines 256–282media_tokens counts how many tokens each kind of media takes; the step card's context section shows it.
256def encode(self, prompt: str, parts: list[dict]) -> Encoded:
257    """The prompt as the model reads it. Without media that is the prompt's tokens. With media (`parts`, the
258    conversation's media in the order of their placeholders), the processor replaces each placeholder with the
259    tokens that image, clip or video takes, and makes the inputs of the encoders whose output goes there."""
260    if not parts:
261        ids = self.tokenizer(prompt, add_special_tokens=False, return_tensors="pt").input_ids
262        return Encoded(prompt, ids, ids.shape[-1])
263    files = {kind: [p["path"] for p in parts if p["type"] == kind] for kind in media.MEDIA}
264    inputs: dict = {}
265    if files["image"]:
266        inputs["images"] = [media.load_image(path) for path in files["image"]]
267    if files["audio"]:
268        inputs["audio"] = [media.load_audio(path, self.processor.feature_extractor.sampling_rate) for path in files["audio"]]
269    if files["video"]:
270        videos = [media.load_video(path) for path in files["video"]]
271        inputs["videos"] = [frames for frames, _ in videos]
272        inputs["videos_kwargs"] = {"video_metadata": [metadata for _, metadata in videos]}
273        # A processor that takes a set number of frames (Gemma 4: 32) refuses a video with fewer; it gets all there are.
274        wanted, fewest = getattr(self.processor.video_processor, "num_frames", None), min(len(frames) for frames, _ in videos)
275        if wanted and fewest < wanted:
276            inputs["videos_kwargs"]["num_frames"] = fewest
277    out = self.processor(text=prompt, return_tensors="pt", **inputs, **self.family.processor_kwargs)
278    ids = out.pop("input_ids")
279    out.pop("attention_mask", None)  # one sequence, no padding: every position counts, as with text
280    counts = {kind: int((ids == token).sum()) for kind, token in self.media_token_ids.items() if token is not None}
281    text = self.tokenizer.decode(ids[0], skip_special_tokens=False)
282    return Encoded(text, ids, ids.shape[-1], dict(out.to(self.device, dtype=self.dtype)), {k: n for k, n in counts.items() if n})

The processor wants decoded data, which media.py prepares: images become RGB, and photos are turned upright by their EXIF; sound is decoded with PyAV into mono at the 16 kHz the model's feature extractor expects; a video gives at most 64 frames taken evenly, shrunk to 768 pixels on the longer side, with the frame rate of what was kept, so the times the processor writes are right (agent/media.py:135). Each file is decoded once and reused in every step after.

In the prefill, the encoders' output replaces the embeddings of those placeholder tokens. Images and videos go through the vision encoder first and its output goes to the prefill, as generate() does it; handed to the forward pass as they are, videos lose their frame counts in transformers 5.20's Gemma 4. Qwen3.5's position encoding (M-RoPE) needs the images' sizes, so every input but the pixels stays; it also keeps the last prompt's position offset on the model, which is cleared before every prefill. After the prefill, the media are in the KV cache, and decoding is the same as for text. tests/test_real_model.py checks that with an image, a video and a recording, too, greedy output matches generate().

agent/model_backends/simple_backend.pylines 361–376
361def _encoded_media(self, inputs: dict) -> dict:
362    """Run the vision encoder over the prompt's images and videos before the prefill, as generate() does, and pass
363    its output in their place. Leaving it to the forward pass breaks videos on Gemma 4: transformers 5.20's
364    Gemma4ForConditionalGeneration takes num_frames_per_video but does not hand it on to the video encoder."""
365    if not inputs or "mm_encoder_outputs" not in inspect.signature(self.model.forward).parameters:
366        return inputs
367    base, encoded = self.model.base_model, {}
368    for kind in ("image", "video"):
369        encoder = getattr(base, f"get_{kind}_features", None)
370        args = {k: inputs[k] for k in inspect.signature(encoder).parameters if k in inputs} if encoder else {}
371        if encoder and any(k.startswith("pixel_values") for k in args):
372            encoded[kind] = encoder(**args, return_dict=True)
373    if not encoded:
374        return inputs
375    # The pixels go; the rest stays, such as the grid sizes Qwen3.5's positions are computed from.
376    return {**{k: v for k, v in inputs.items() if not k.startswith("pixel_values")}, "mm_encoder_outputs": encoded}

The files go to the server next to the placeholders the chat template wrote (an image upright as PNG, sound decoded to WAV, a video as the file, agent/model_backends/vllm_backend.py:242), the server expands each placeholder, and the encoders run in its prefill. encode() asks the server how first, with a request that generates one token, so the Context tab shows the prompt as the server reads it; a different expansion at generation time is an error.

agent/model_backends/vllm_backend.pylines 490–504
490def encode(self, prompt: str, parts: list[dict]) -> Encoded:
491    """The prompt's token ids, which are what the server reads. With media (`parts`, in the order of their
492    placeholders), the server expands each placeholder into the tokens its file takes, so it is asked how first, with
493    a one-token request; the text and the count are of the prompt as the server reads it, and the ids stay the ones
494    the chat template wrote, which generate() sends again with the files."""
495    ids = self.tokenizer(prompt, add_special_tokens=False).input_ids
496    if not parts:
497        return Encoded(prompt, ids, len(ids))
498    if unread := {p["type"] for p in parts} - set(self.modalities):  # media.save makes those plain files, so this is a bug
499        raise ValueError(f"The vLLM server takes no {', '.join(sorted(unread))} here, only {', '.join(self.modalities) or 'text'}.")
500    files = [content_part(p, self.audio_rate) for p in parts]
501    expanded = self._generate_once(ids, files, return_token_ids=True)["prompt_token_ids"]
502    counts = Counter(kind for token in expanded for kind, media_id in self.media_token_ids.items() if media_id is not None and token == media_id)
503    return Encoded(self.tokenizer.decode(expanded, skip_special_tokens=False), ids, len(expanded),
504                   {"content_parts": files, "prompt_token_ids": expanded}, dict(counts))
8

Model

Model: prefill, then one token at a time

One forward pass reads the whole prompt; after that, each new token takes another forward pass.

The harness consumes the events that generate() yields in a for loop: prefill and each token are passed on with the step number, and the final done is kept and sent on as model_end.

agent/harnesses/simple_harness.pylines 134–143The self._stop.is_set passed in is a function: the model calls it before every token to ask whether to stop.
134done = None
135for ev in self.llm.generate(encoded, s.sampling, self._stop.is_set):
136    kind = ev.pop("type")
137    if kind == "done":
138        done = ev
139    else:
140        self.emit(kind, step=step, **ev)
141assert done is not None  # generate() always ends with done
142raw = done.pop("raw")
143self.emit("model_end", step=step, raw=raw, **done)

The endpoint consumes generate()'s events in the same way, sending each into pi's step. pi starts a turn and then sends its request, and the two reach the page by different ways, so the request first waits until the page has opened that step (agent/harnesses/pi_harness.py:335). Each token of the thinking and of the reply also goes back to pi as it comes, as reasoning_content and content. Before every token the model asks whether to stop: whether the turn is being stopped, or pi has stopped listening (as it does when it is aborted).

agent/harnesses/model_endpoint.pylines 253–260
253for ev in llm.generate(encoded, serving.sampling, lambda: gone or serving.stopped()):
254    kind = ev.pop("type")
255    if kind == "done":
256        done = ev
257        continue
258    emit(kind, **ev)
259    if kind == "token" and ev["channel"] in ("thought", "content"):
260        send({"reasoning_content" if ev["channel"] == "thought" else "content": ev["text"]})

No backend runs here: the provider's API streams text back to pi, pi sends each piece on as a message_update record, and PiHarness turns it into delta, with its channel (agent/harnesses/pi_harness.py:445); the step card draws the pieces by channel, without token boundaries (app/render.py:1060). When the message ends, model_end carries the token counts the provider reported, how long the first output took and the cost at pi's prices (agent/harnesses/pi_harness.py:461). The tabs below are about this page's model.

agent/harnesses/pi_harness.pylines 445–454
445def _update(self, e: dict):
446    kind = e.get("type")
447    if kind == "text_delta":
448        self._delta("content", e.get("delta", ""))
449    elif kind == "thinking_delta":
450        self._delta("thought", e.get("delta", ""))
451    elif kind == "toolcall_start":
452        self._delta("tool_call", ("\n" if self._wrote else "") + e.get("toolName", "") + " ")
453    elif kind == "toolcall_delta":
454        self._delta("tool_call", e.get("delta", ""))

How the page's model generates (for the built-in harness and for pi on this page's model alike) depends on what runs it:

prefill: the whole prompt, already token ids from encode(), goes through one forward pass, which also puts the encoders' output in place of any media (§7 “Images, sound and video: tokens in the context”). use_cache=True keeps each layer's state (the keys and values in ordinary attention layers, a fixed-size state in Qwen3.5's Gated DeltaNet layers), so each new token needs only its own position computed; logits_to_keep=1 returns the logits for the last position only, since only the distribution of the next token is needed (agent/model_backends/simple_backend.py:354). Prefill time depends mostly on the prompt's length; the table in the Model tab has each step's prefill ms.

agent/model_backends/simple_backend.pylines 284–302If the prompt ends with the family's thought_prefix (<|channel>thought for Gemma, <think> for Qwen), the model starts out inside its thinking.
284def generate(self, prompt: Encoded, sampling: Sampling, should_stop: Callable[[], bool]) -> Iterator[dict]:
285    """Yield a prefill event, one event per token, then a done event."""
286    ids = prompt.ids.to(self.device)
287    channel = "thought" if prompt.text.endswith(self.family.thought_prefix) else "content"
288    drafting = sampling.mtp and self.drafter is not None
289    mtp = {"drafted": 0, "accepted": 0, "forwards": 0} if drafting else None
290    generated: list[int] = []
291    text = ""
292    finish = "max_new_tokens"
293
294    with torch.inference_mode():
295        # Models with multimodal RoPE (Qwen3.5) keep the position offset of the last prompt's images on the model
296        # and add it in every later forward pass; each prompt starts from its own.
297        if hasattr(self.model.base_model, "rope_deltas"):
298            self.model.base_model.rope_deltas = None
299        start = time.perf_counter()
300        out = self._forward(ids, None, 1, drafting, **self._encoded_media(prompt.media))
301        prefill_ms = (time.perf_counter() - start) * 1000
302        yield {"type": "prefill", "n_tokens": ids.shape[-1], "ms": round(prefill_ms, 1)}

The decoding loop takes sampled tokens from tokens one at a time: they normally come from _one_at_a_time(), and from _drafted() with MTP on (§10 “MTP: the drafter guesses, the model checks them all at once”); the loop itself does not need to know which. Each time around: check for stop → get a token → work out whether it is a special token and whether it switches channel, where the family's channel_switch says which tokens switch to thinking, a tool call or the reply (Gemma: agent/model_families/gemma4.py:394; Qwen: agent/model_families/qwen3_5.py:275) → decode all the tokens so far and take the difference, so that a Chinese character split across several byte tokens comes out only once it is whole → send a token event.

agent/model_backends/simple_backend.pylines 304–334
304decode_start = time.perf_counter()
305tokens = self._drafted(out, sampling, mtp) if mtp is not None else self._one_at_a_time(out, sampling)
306for token_id, details in tokens:
307    if should_stop():
308        finish = "user_stopped"
309        break
310    generated.append(token_id)
311
312    piece = self.piece(token_id)
313    special = token_id in self.special_ids
314    if piece in self.family.channel_switch:
315        channel = self.family.channel_switch[piece]
316        token_channel = "control"
317    else:
318        token_channel = "control" if special else channel
319
320    # Decode the whole sequence and diff it, so characters split across byte tokens come out whole.
321    full = self.tokenizer.decode(generated, skip_special_tokens=False)
322    delta = "" if full.endswith("�") else full[len(text):]
323    if delta:
324        text = full
325    yield {
326        "type": "token",
327        "index": len(generated) - 1,
328        "id": token_id,
329        "piece": piece,
330        "text": delta,
331        "special": special,
332        "channel": token_channel,
333        **details,
334    }

A stop token, or reaching max_new_tokens (the sidebar's Max tokens per step), ends the loop.

agent/model_backends/simple_backend.pylines 336–340
336if token_id in self.eos_ids:
337    finish = "stop_token"
338    break
339if len(generated) == sampling.max_new_tokens:
340    break

If it has not stopped, the loop asks tokens for the next one. Only then does _one_at_a_time() feed the token it just sampled back into the model, with the cache, for the next position's logits; it is a generator, so once the loop ends, no extra forward pass runs.

agent/model_backends/simple_backend.pylines 378–383
378def _one_at_a_time(self, out, s: Sampling) -> Iterator[tuple[int, dict]]:
379    """Plain decoding: sample a token, feed it back with the cache for the next position's logits, repeat."""
380    while True:
381        token_id, details = self._sample(out.logits[0, -1].float().cpu(), s)
382        yield token_id, details
383        out = self._forward(torch.tensor([[token_id]], device=self.device), out.past_key_values, 1)

The final done tells the harness why it stopped (finish: stop_token, max_new_tokens or user_stopped), at which token, how fast it generated, and the whole raw output, raw (special tokens included, because the harness parses by them).

agent/model_backends/simple_backend.pylines 342–352
342decode_s = time.perf_counter() - decode_start
343yield {
344    "type": "done",
345    "finish": finish,
346    "stop_piece": self.piece(generated[-1]) if finish == "stop_token" else None,
347    "n_tokens": len(generated),
348    "decode_ms": round(decode_s * 1000, 1),
349    "tok_per_s": round(len(generated) / decode_s, 1) if decode_s > 0 else None,
350    "mtp": mtp,
351    "raw": self.tokenizer.decode(generated, skip_special_tokens=False),
352}

VLLMBackend.generate() yields the same events, from the tokens the server streams back as server-sent events (what each token comes with is in §9 “Sampling: how each token is picked”). prefill goes out once the first token is back, so its time is the time to the first token, the network included:

agent/model_backends/vllm_backend.pylines 506–526
506def generate(self, prompt: Encoded, sampling: Sampling, should_stop: Callable[[], bool]) -> Iterator[dict]:
507    """Yield the events SimpleBackend.generate yields, from the server's stream: prefill once the first token is back
508    (its time is the time to the first token), one event per token, then done."""
509    channel = "thought" if prompt.text.endswith(self.family.thought_prefix) else "content"
510    generated: list[int] = []
511    text = ""
512    finish = None
513    end: dict = {}  # the finish reason and the speculative decoding counts, once the stream has them
514    start = time.perf_counter()
515    decode_start = None
516
517    # /inference/v1/generate when the server has it: it says more about each token, and takes media.
518    if self.generate_api:
519        tokens = self._stream_generate(prompt, sampling, end)
520    else:
521        tokens = self._stream(self._request(prompt, sampling), sampling, end)
522    try:
523        for token_id, details in tokens:
524            if decode_start is None:
525                decode_start = time.perf_counter()
526                yield {"type": "prefill", "n_tokens": prompt.n_tokens, "ms": round((decode_start - start) * 1000, 1)}

vLLM keeps the KV cache of what a prompt shares with the last one (prefix caching), so from step 2 on it reads only what differs from the last step: the model's turn as the chat template rewrote it, and the new observation; SimpleBackend reads the whole prompt from the start every step.

From the token on it is as in SimpleBackend: the family's channel_switch says which channel, decoding all the tokens so far gives the text, and a token event goes out (agent/model_backends/vllm_backend.py:532). The server decides when to stop: the request carries the stop tokens (stop_token_ids) and max_tokens, the sidebar's Max tokens per step, cut to what fits in the server's context (§9 “Sampling: how each token is picked”). Pressing stop closes the stream, which tells the server to stop generating (agent/model_backends/vllm_backend.py:556).

The final done is the same as SimpleBackend's: the server's finish_reason stop becomes stop_token and length max_new_tokens, and anything else is an error; when the server decodes speculatively, it also carries the server's counts (§10 “MTP: the drafter guesses, the model checks them all at once”).

agent/model_backends/vllm_backend.pylines 561–577
561if finish is None:
562    reason = end.get("finish_reason")
563    if reason not in ("stop", "length"):
564        raise ServerError(f"vLLM 伺服器沒有正常結束這一步(finish_reason:{reason})。",
565                          f"The vLLM server did not finish this step normally (finish_reason: {reason}).")
566    finish = "stop_token" if reason == "stop" else "max_new_tokens"
567decode_s = time.perf_counter() - decode_start
568yield {
569    "type": "done",
570    "finish": finish,
571    "stop_piece": self.piece(generated[-1]) if finish == "stop_token" and generated else None,
572    "n_tokens": len(generated),
573    "decode_ms": round(decode_s * 1000, 1),
574    "tok_per_s": round(len(generated) / decode_s, 1) if decode_s > 0 else None,
575    "mtp": mtp_counts(end.get("spec"), len(generated)) if finish != "user_stopped" else None,
576    "raw": self.tokenizer.decode(generated, skip_special_tokens=False),
577}

Greedy output need not match generate() token for token: vLLM batches requests and has kernels of its own. Matching generate() exactly is SimpleBackend's promise only.

9

Model

Sampling: how each token is picked

temperature → top-k → top-p → draw one; and keep the model's own distribution for the popover.

_sample() runs once per token. It first takes the softmax of the raw logits, the model's own distribution (T = 1, nothing cut): the model probability, the rank and the top 5 candidates in a token's popover all come from it.

A temperature of 0 or less is greedy: it takes the most likely token, and of two equally likely ones the lower id, as generate()'s argmax does (in bfloat16, logits tie more often than one would think). Otherwise, in order: logits ÷ T → keep only the top k (top-k; 0 = no limit) → softmax again → add up from the most likely down until the cumulative probability reaches top-p (nucleus, keeping at least one) → renormalize → draw one with torch.multinomial. None of this depends on the model; both families share it.

agent/model_backends/simple_backend.pylines 432–480
432def _sample(self, logits: torch.Tensor, s: Sampling, draft: int | None = None) -> tuple[int, dict]:
433    """Pick the next token and report the model's distribution and what the settings did to it.
434    A `draft` (the MTP drafter's guess) is kept with the probability the settings give it, which in greedy
435    decoding means when it is the top token; otherwise the pick comes from the other candidates. Either way
436    each token comes out exactly as often as without a drafter."""
437    probs = torch.softmax(logits, -1)  # the model's own distribution (T = 1, nothing cut off)
438    # Equally likely tokens go lowest id first, the one argmax and so generate() takes. topk leaves their order open
439    # (on the CPU it can list the higher id first), and in bfloat16 two logits are equal more often than one would think.
440    top = sorted(zip(*probs.topk(TOP_N)), key=lambda c: (-float(c[0]), int(c[1])))
441
442    if s.temperature <= 0:  # T -> 0 is greedy decoding
443        chosen = int(top[0][1])
444        kept = 1
445        p_sampled = 1.0
446    else:
447        scaled = logits / s.temperature
448        if s.top_k > 0:
449            cand_logits, cand_ids = scaled.topk(min(s.top_k, scaled.numel()))
450        else:
451            cand_logits, cand_ids = scaled.sort(descending=True)
452        cand_probs = torch.softmax(cand_logits, -1)
453        # Nucleus: keep the smallest prefix whose probability reaches top_p (always at least one token).
454        kept = int(((cand_probs.cumsum(-1) - cand_probs) < s.top_p).sum())
455        cand_ids, cand_probs = cand_ids[:kept], cand_probs[:kept] / cand_probs[:kept].sum()
456        weights = cand_probs
457        at: torch.Tensor | list = (cand_ids == draft).nonzero().flatten() if draft is not None else []
458        if len(at) and float(torch.rand(())) < float(cand_probs[at[0]]):
459            pick = int(at[0])
460        else:
461            if len(at):  # the guess was turned down: draw from the other candidates
462                weights = cand_probs.clone()
463                weights[at[0]] = 0
464            pick = int(torch.multinomial(weights, 1))
465        chosen = int(cand_ids[pick])
466        p_sampled = float(cand_probs[pick])
467
468    p = float(probs[chosen])
469    # Candidates are a prefix of the tokens sorted by probability, so rank decides who survived.
470    return chosen, {
471        "p": p,
472        "logprob": float(torch.log(probs[chosen])) if p > 0 else None,
473        "rank": int((probs > probs[chosen]).sum()) + 1,
474        "p_sampled": p_sampled,
475        "kept": kept,
476        "top": [
477            {"id": int(i), "piece": self.piece(int(i)), "p": float(v), "kept": rank < kept}
478            for rank, (v, i) in enumerate(top)
479        ],
480    }

The server does the sampling. Every sidebar setting is sent, and every other sampling setting is set to do nothing, so that defaults the server took from the checkpoint cannot change the distribution:

agent/model_backends/vllm_backend.pylines 579–592
579def _sampling_params(self, prompt: Encoded, s: Sampling) -> dict:
580    """Each sidebar setting, and every other sampling setting set to do nothing, so that a default the server took
581    from the checkpoint (a repetition penalty, say) cannot change the distribution the page shows."""
582    return {
583        "max_tokens": max(1, min(s.max_new_tokens, self.context_length - prompt.n_tokens)),  # vLLM refuses a request that cannot fit
584        "temperature": max(s.temperature, 0.0),  # 0 is greedy there as here
585        "top_p": s.top_p,
586        "top_k": s.top_k,  # 0 is no limit there as here
587        "min_p": 0.0,
588        "repetition_penalty": 1.0,
589        "presence_penalty": 0.0,
590        "frequency_penalty": 0.0,
591        "stop_token_ids": sorted(self.eos_ids),
592    }

A plain vllm serve has only /v1/completions: each token comes with its log probability and the 5 most likely tokens at its position with theirs, keyed token_id:<id>, so the rank is known only when the token is among those 5. By default vLLM sends the model's own distribution (--logprobs-mode raw_logprobs), which means the same as SimpleBackend's p.

agent/model_backends/vllm_backend.pylines 639–651
639def _details(self, token_id: int, logprobs: dict, n: int, s: Sampling) -> dict:
640    """What /v1/completions said about the n-th token of a chunk: its log probability, and those of the 5 most likely
641    tokens, keyed "token_id:<id>". The token's rank is known only when it is one of those 5."""
642    token_logprobs, top_logprobs = logprobs.get("token_logprobs") or [], logprobs.get("top_logprobs") or []
643    if n >= len(token_logprobs) or token_logprobs[n] is None:
644        return {}
645    logprob = token_logprobs[n]
646    listed = top_logprobs[n] if n < len(top_logprobs) and top_logprobs[n] else {}
647    # The chosen token is listed too, even when it is not among the 5.
648    listed = {int(k.removeprefix("token_id:")): v for k, v in listed.items() if k.startswith("token_id:")}
649    top = sorted(listed.items(), key=lambda c: (-c[1], c[0]))[:TOP_N]
650    rank = 1 + sum(v > logprob for _, v in top) if any(i == token_id for i, _ in top) else None
651    return self._token_details(token_id, logprob, listed, rank, None, s)

Yet a server may run another mode, and no route says which, so at load the backend asks for one token at temperature 1 with top-k 2 and the 5 most likely tokens (agent/model_backends/vllm_backend.py:271). In a processed mode, the 3 that top-k cut come back as -9999; log probabilities add up to at most 1, and logits do not. processed_logprobs is the distribution the token was drawn from, after temperature, top-k and top-p, so the chosen token's is its sampling probability, sent as p_sampled, and no p or top 5 is sent; when some of those listed were cut, how many were left is known too. Logits are no probabilities, and none is sent as one. The rank is the same in every mode: temperature keeps the order, and top-k and top-p keep the most likely tokens.

agent/model_backends/vllm_backend.pylines 705–727
705def _token_details(self, token_id: int, logprob: float, listed: dict[int, float], rank: int | None,
706                   mask: list[int] | None, s: Sampling) -> dict:
707    """The details of a token event, from what the server said about the token: its log probability, those of the
708    tokens listed with it, its rank and its sampling mask when it sent them. What the numbers may be shown as
709    depends on the server's --logprobs-mode. Only the model's own log probabilities are p, logprob and the top 5.
710    Processed ones are the distribution the token was drawn from, so the token's is its sampling probability, and
711    the tokens top-k and top-p cut come back as UNREPORTED. Logits are neither."""
712    details: dict = {"rank": rank} if rank else {}
713    if self.logprobs_mode == "raw_logprobs":
714        # Equally likely tokens go lowest id first, as in SimpleBackend.
715        ranked = sorted(listed.items(), key=lambda c: (-c[1], c[0]))
716        if rank is not None and rank > TOP_N:  # listed only because it was chosen
717            ranked = [c for c in ranked if c[0] != token_id]
718        top = [{"id": i, "piece": self.piece(i), "p": math.exp(v)} for i, v in ranked[:TOP_N]]
719        details.update(p=math.exp(logprob), logprob=logprob, top=top)
720    elif self.logprobs_mode == "processed_logprobs":
721        details["p_sampled"] = math.exp(logprob)
722    if mask is not None:
723        details["kept"] = len(set(mask))
724    elif self.logprobs_mode.startswith("processed") and any(v <= UNREPORTED for v in listed.values()):
725        # Some of the most likely tokens were cut, and those left are more likely still, so all of them are listed.
726        details["kept"] = sum(v > UNREPORTED for v in listed.values())
727    return self._greedy(details, token_id, s)

Started with --enable-scale-out, the server also has /inference/v1/generate: token ids in and token ids out as well, but it tells more. At load the backend asks the server what it can do, with requests that generate one token each: whether it has that route, whether it sends sampling masks, and which media it takes (§7 “Images, sound and video: tokens in the context”). What that route brings:

  • Every token's rank, outside the top 5 as well. Its list puts the chosen token first, so the backend asks for one more and sorts the top 5 by rank. vLLM's rank counts the tokens tied with the chosen one as ahead of it, and with speculative decoding it compares the logits, while the log probabilities listed can tie where the logits do not. So the backend counts the rank from the listed log probabilities, only those more likely: a token tied for first is #1, as with SimpleBackend.
  • --return-sampling-mask: the tokens top-k and top-p left at each position, from which comes how many candidates were left. vLLM allows it only with --logprobs-mode processed_logprobs, so a server with masks sends the sampling distribution: the sampling probability and the candidates left show, and the model probability, the top 5 and which of them were cut do not. Such a server also refuses temperature 0 and top-k 0 (top-k bounds the mask), so the sidebar's sliders start at 0.05 and 1. A server without --enable-scale-out shows no mask, so at load it is also asked for a greedy token, and a refusal says it has masks on (agent/model_backends/vllm_backend.py:413). Greedy decoding needs no server: one candidate left, drawn with probability 1 (agent/model_backends/vllm_backend.py:729).

vLLM (Docker) starts its server with --enable-scale-out and the default raw_logprobs, so every token's rank, the model probability and the top 5 show.

The numbers in the popover: p is the chosen token's probability in the model's own distribution; rank is its place; kept is how many candidates were left after the cuts; p_sampled is its probability in the cut and renormalized distribution. The shade behind a token in a step card comes from p (app/render.py:724): the darker it is, the less the model expected that token, and yet it was drawn. These numbers are listed in backend.py's TOKEN_DETAILS (agent/model_backends/backend.py:63): transformers knows them all, a vLLM server what it was started to tell, and what a backend does not know is left out, never drawn from some other number (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”).

10

Model

MTP: the drafter guesses, the model checks them all at once

With MTP speculative decoding on in the sidebar, one forward pass of the model can produce several tokens, and every token's probability stays the same.

Ordinary decoding runs a full forward pass for every token, and most of that time goes into reading gigabytes of weights from memory; computing a few more positions in the same pass is hardly any slower (measured with Gemma 4 E2B on an Apple M5: about 56 ms for 1 position, about 70 ms for 7). So if something cheap guesses the next few tokens first, the model can check them all in one forward pass. That is speculative decoding; MTP (multi-token prediction) is the guesser Google trained for Gemma 4.

The guesses come from the drafter google/gemma-4-E2B-it-assistant (agent/model_families/__init__.py:31): about 78M parameters, 4 layers, hidden size 256, next to the model's 5.1B. It has no KV cache of its own: of Gemma 4 E2B's 35 layers, the last 20 do not compute their own keys and values but share those of two earlier layers (the last sliding window layer and the last full attention layer), and the drafter reads the same ones. It loads the first time MTP is switched on, and how many tokens it guesses at a time is in its generation config (num_assistant_tokens = 6).

agent/model_backends/simple_backend.pylines 225–244
225def load_drafter(self):
226    """Load the MTP drafter that goes with this model (model_families.DRAFTERS), if any, and warm it up."""
227    with self._drafter_lock:
228        if self.drafter is not None or self.drafter_id is None:
229            return
230        start = time.perf_counter()
231        folder = _download(self.drafter_id)
232        self.drafter = AutoModelForCausalLM.from_pretrained(folder, dtype=self.dtype, device_map=self.device).eval()
233        self.draft_tokens = self.drafter.generation_config.num_assistant_tokens  # guesses per forward pass
234        self.warmup(mtp=True)
235        cfg = self.drafter.config.get_text_config()
236        self.info["drafter"] = {
237            "model_id": self.drafter_id,
238            "parameters": self.drafter.num_parameters(),
239            "layers": cfg.num_hidden_layers,
240            "hidden_size": cfg.hidden_size,
241            "draft_tokens": self.draft_tokens,
242            "load_seconds": round(time.perf_counter() - start, 1),
243            "folder": describe_folder(folder, self.family.drafter_file_notes),
244        }

Guess: the input is the last token's embedding next to the model's last-layer hidden state at the last position; the output is the logits for the next token, and the most likely one is the guess. The guessed token and the drafter's own hidden state are then the input for another guess, up to 6. All 6 sit at the same position, because the keys and values it sees go only as far as the model has really read.

agent/model_backends/simple_backend.pylines 418–430
418def _draft(self, token_id: int, hidden: torch.Tensor, shared: dict, position: int) -> list[int]:
419    """The drafter's greedy guesses for the tokens after `token_id`. It has no cache of its own: it attends to the
420    model's keys and values, so every guess sits at the position of `token_id`, and what it has guessed so far
421    comes in through its input, the embedding of the last guess next to the drafter's last hidden state."""
422    embed = self.model.get_input_embeddings()
423    positions = torch.tensor([[position]], device=self.device)
424    guesses: list[int] = []
425    while len(guesses) < self.draft_tokens and token_id not in self.eos_ids:
426        x = torch.cat([embed(torch.tensor([[token_id]], device=self.device)), hidden], -1)
427        out = self.drafter(inputs_embeds=x, position_ids=positions, shared_kv_states=shared)
428        token_id, hidden = int(out.logits[0, -1].argmax()), out.last_hidden_state
429        guesses.append(token_id)
430    return guesses

Check: the model runs one forward pass over the token just settled plus the 6 guesses, and logits_to_keep=7 returns the distributions at 7 positions: the one at position i is the distribution of the next token before reading guess i. Starting from the first guess, _sample() checks them in order: a guess it keeps is a token without a forward pass of its own; the first guess it turns down is replaced by the model's own pick at that position, and the guesses after it are dropped; when all 6 hold, the last position's distribution gives one more token. So one forward pass produces between 1 and 7 tokens.

agent/model_backends/simple_backend.pylines 385–416
385def _drafted(self, out, s: Sampling, stats: dict) -> Iterator[tuple[int, dict]]:
386    """MTP decoding. After each token the drafter guesses the next few, and one forward pass over the token and the
387    guesses gives the model's distribution at every guessed position. _sample checks the guesses in order: each
388    one it keeps is a token for free, the first one it turns down is replaced by the model's own pick, and the
389    rest are dropped. When every guess holds, the last position gives one more token."""
390    past = out.past_key_values
391    past.activate_past_recording()  # so the sliding-window layers can still drop guesses that were turned down
392    hidden, shared = out.hidden_states[-1][:, -1:], out.shared_kv_states
393    token_id, details = self._sample(out.logits[0, -1].float().cpu(), s)
394    while True:
395        yield token_id, details
396        guesses = self._draft(token_id, hidden, shared, past.get_seq_length())
397        out = self._forward(torch.tensor([[token_id, *guesses]], device=self.device), past, len(guesses) + 1, True)
398        logits = out.logits[0].float().cpu()
399        stats["forwards"] += 1
400        stats["drafted"] += len(guesses)
401        for n, guess in enumerate(guesses):
402            token_id, details = self._sample(logits[n], s, guess)
403            if token_id != guess:
404                details["draft"] = {"piece": self.piece(guess), "accepted": False}
405                break
406            stats["accepted"] += 1
407            yield token_id, {**details, "draft": {"piece": self.piece(guess), "accepted": True}}
408        else:
409            n = len(guesses)
410            token_id, details = self._sample(logits[n], s)
411        # Keep the cache and the drafter's inputs up to the last kept guess (or the token, when none held).
412        dropped = len(guesses) - n
413        past.crop(-dropped)
414        hidden = out.hidden_states[-1][:, n : n + 1]
415        shared = {k: (key[:, :, : key.shape[2] - dropped], value[:, :, : value.shape[2] - dropped])
416                  for k, (key, value) in out.shared_kv_states.items()}

The dropped guesses are already in the KV cache, and past.crop() takes them out; sliding window layers normally drop keys and values that are too old right away, and activate_past_recording() keeps them until after the crop(). The keys and values for the drafter are cut back the same way.

Why the output does not change: given a guess, _sample() in greedy decoding (T = 0) keeps it only when it is the top token, so the output is the same as without MTP (computing 7 positions at once gives very slightly different numbers from computing 1, so when two tokens are almost equally likely it may now and then pick the other). When sampling, a guess is kept with its probability p after temperature, top-k and top-p; if it is not kept, it is taken out of the candidates and the token is drawn from the rest. Every token still comes out with probability p: the drafter decides how many forward passes it takes, not what gets written.

agent/model_backends/simple_backend.pylines 457–464
457at: torch.Tensor | list = (cand_ids == draft).nonzero().flatten() if draft is not None else []
458if len(at) and float(torch.rand(())) < float(cand_probs[at[0]]):
459    pick = int(at[0])
460else:
461    if len(at):  # the guess was turned down: draw from the other candidates
462        weights = cand_probs.clone()
463        weights[at[0]] = 0
464    pick = int(torch.multinomial(weights, 1))

On the page:

  • Kept guesses are underlined in the step card; a run of underlined tokens and the plain token after it come from one forward pass of the model.
  • A token's popover says the drafter guessed it, or that the guess was turned down and what the drafter had guessed.
  • The step card's model section gets a line on how many guesses were kept, how many decoding forward passes ran and how many it would take without MTP; the table in the Model tab has these two columns too.

How many are kept depends a lot on the content. Measured with greedy decoding on an Apple M5: tool calls are almost all guessed right (34 of 35 kept, about 15 → 43 tok/s), English code about half, and long answers in Traditional Chinese only about a tenth, hardly any faster. The drafter guesses well only where the text is fixed in form and predictable.

Speculative decoding is decided when the server starts (--speculative-config), not request by request:

  • vLLM (Docker) on a GPU takes the model's setting in model_families.VLLM_SPECULATIVE (agent/model_families/__init__.py:39): Gemma 4 with its drafter, google/gemma-4-E2B-it-assistant, Qwen3.5 with the MTP layer in its own checkpoint, which transformers cannot use. It is on by default, and the switch starts the server again the other way (agent/model_backends/vllm_docker.py:117). vLLM 0.32's CPU image cannot decode either model speculatively, so on a CPU the switch is disabled.
  • A server at VLLM_URL decides as whoever started it did, and the sidebar's MTP switch is disabled.

With --per-request-spec-decode-metrics summary (which needs --speculative-config), the last part of the response carries the step's counts, which become the step card's guesses kept and forward passes. Set to detailed, as vLLM (Docker) has it, it also says how many guesses each verify step kept: the first token comes from the prefill, and each verify step after it gives the guesses it kept and then one token of the model's own, which replaced a guess turned down when fewer were kept than guessed. The server does not count a step without guesses, so only when these numbers account for every token can the page mark which were guesses; otherwise it shows the counts alone. When they do, only the guesses up to the stop token count as kept: vLLM counts them before it cuts the step at the stop token, so the guesses after it would count too. That is known only once the step ends, so the underlines appear then (app/render.py:435), and what a turned-down guess was, the server does not say.

agent/model_backends/vllm_backend.pylines 283–299
283def drafted_tokens(accepted: list[int], drafted: list[int], n_tokens: int) -> dict | None:
284    """Which of a step's tokens were guesses the model kept (kept_at) and which replace a guess it turned down
285    (turned_down_at), from vLLM's per-step counts (--per-request-spec-decode-metrics detailed). The first token comes
286    from the prefill; after it, each verify step gives the guesses it kept and then one token of the model's own,
287    which replaces the next guess when it kept fewer than were drafted. vLLM does not count a step without guesses,
288    so when the steps do not account for every token, nobody can tell which tokens were guessed: None."""
289    kept: list[int] = []
290    turned_down, at = [], 1
291    for n_kept, n_drafted in zip(accepted, drafted):
292        kept += range(at, at + n_kept)
293        if n_kept < n_drafted:
294            turned_down.append(at + n_kept)
295        at += n_kept + 1
296    last = accepted[-1] + 1 if accepted else 1
297    if not at - last < n_tokens <= at:  # only a stop token or the token limit may cut the last step short
298        return None
299    return {"kept_at": [i for i in kept if i < n_tokens], "turned_down_at": [i for i in turned_down if i < n_tokens]}
11

Model

What runs the model: the Backend interface, transformers here or a vLLM server

The harness and the page know only a Backend: mainly three methods, render, encode and generate, and the events it yields. The sections above tell transformers here and a vLLM server apart in their tabs; this one is about the interface they share, and where a vLLM server comes from.

The prefill, the decoding loop, the sampling and MTP in the sections above are SimpleBackend's in simple_backend.py in their transformers tabs, and VLLMBackend's in vllm_backend.py in their vLLM tabs. Yet the harness and the page never ask for either; they use the model only through a Backend (agent/model_backends/backend.py:67): render() puts the conversation through the chat template, encode() turns the prompt into the tokens the model reads (media expanded), and generate() yields prefill, then a token for each token, then done; and the page reads a few attributes: the family, what the model reads, the context limit and the Model tab's info. Whatever has these can run the model.

agent/model_backends/backend.pylines 67–102A Protocol is only a type: SimpleBackend need not inherit from it, and having these methods and attributes is enough. The tests check that SimpleBackend, VLLMBackend and the tests' FakeLLM are each a Backend.
67class Backend(Protocol):
68    """What runs the model. The page holds one at a time (app.page.current_llm) and runs one turn on it at a time."""
69
70    model_id: str
71    family: model_families.Family  # how the model writes its turns, thinking and tool calls (model_families/)
72    modalities: tuple[str, ...]  # what it reads besides text: "image", "audio", "video"
73    context_length: int  # the most tokens it reads at once
74    info: dict  # what the Model tab shows, and in "defaults" the recommended temperature, top_p and top_k
75    drafter_id: str | None  # the MTP drafter that goes with the model (model_families.DRAFTERS), if any
76    drafter: object | None  # that drafter once load_drafter() has loaded it; Sampling.mtp turns it on only then
77
78    def warmup(self) -> None:
79        """Pay what the first question would otherwise wait for, such as compiling kernels."""
80
81    def load_drafter(self) -> None:
82        """Load the MTP drafter, if the model has one and it is not loaded yet."""
83
84    def render(self, messages: list[dict], tools: list[dict] | None, thinking: bool, generation_prompt: bool = True) -> str:
85        """The chat template's string for the messages and tool schemas, which is what the model reads; with
86        `generation_prompt` it also opens the model's turn. The harness finds what is new in a step by comparing
87        this string with the last step's, so it has to be the prompt itself, not something like it."""
88
89    def encode(self, prompt: str, parts: list[dict]) -> Encoded:
90        """The prompt as the model reads it, with each image, clip or video in `parts` (media.parts) expanded into
91        the tokens it takes."""
92
93    def generate(self, prompt: Encoded, sampling: Sampling, should_stop: Callable[[], bool]) -> Iterator[dict]:
94        """Generate the model's turn, asking should_stop() before every token. It yields, in order:
95
96        - {"type": "prefill", "n_tokens", "ms"}: the prompt has been read, in `ms` milliseconds;
97        - {"type": "token", ...} for each token, with TOKEN_FIELDS and the TOKEN_DETAILS the backend knows;
98        - {"type": "done", "finish", "stop_piece", "n_tokens", "decode_ms", "tok_per_s", "mtp", "raw"}: `finish`
99          is "stop_token", "max_new_tokens" or "user_stopped"; `stop_piece` is the stop token's piece; `mtp` is
100          {"drafted", "accepted", "forwards"} with MTP on (and "kept_at", "turned_down_at" from a backend that
101          learns the guesses only then), else None; and `raw` is the whole output, special tokens included, which
102          the family parses."""

The page decides which one in a single place: given a server's URL (VLLM_URL, or that of the server the page started in Docker), load_llm() uses vLLM, and without one runs the model here (app/page.py:171). From then on current_llm(), the sidebar and SimpleHarness (§5 “Preparing a run: messages, tools, system prompt”) get just a Backend; it stays in st.cache_resource until the model, or what runs it, changes (§4 “Sending a question: load the model, start a thread for the agent”).

The two backends know different amounts about the model, and the difference is all in the token event. Every backend sends TOKEN_FIELDS: the place, the token's id and piece, the text it adds, whether it is special, and whether it is thinking, a tool call or the reply. TOKEN_DETAILS it sends only when it knows them: the probability, the rank, the sampling probability, the candidates left, the top 5, and what the MTP drafter guessed.

agent/model_backends/backend.pylines 52–63
52TOKEN_FIELDS = ("index", "id", "piece", "text", "special", "channel")
53# What a token event carries only when the backend knows it:
54#   p, logprob  the token's probability in the model's own distribution (temperature 1, nothing cut off), and its log
55#   rank        the token's place in that distribution, 1 for the most likely
56#   p_sampled   its probability in the distribution it was drawn from, after temperature, top-k and top-p
57#   kept        how many candidates top-k and top-p left
58#   top         the 5 most likely tokens, each with id, piece, p, and kept (whether top-k and top-p left it) when known
59#   draft       with MTP, what the drafter guessed here and whether the model kept it; absent where it guessed nothing.
60#               A backend that learns only at the end of a step which tokens were guesses (vllm_backend.py) says so in
61#               done's mtp instead (kept_at, turned_down_at), and render.py marks those tokens; it may not know what a
62#               turned-down guess was (piece None).
63TOKEN_DETAILS = ("p", "logprob", "rank", "p_sampled", "kept", "top", "draft")

The page draws what it gets (app/render.py:810): a number nobody reported is not a row, a token without a probability gets no heat, and when nobody said which candidates were cut, none of the top 5 is grayed out (app/render.py:784). The page would rather show less than fill in a number from something else.

The three backends:

SimpleBackend (simple_backend.py) loads the weights onto this machine with transformers, and its hand-written decoding loop sees the whole distribution at every token, so it sends every one of TOKEN_DETAILS; it knows each MTP guess it kept or turned down (§10 “MTP: the drafter guesses, the model checks them all at once”); its greedy output equals generate()'s; it reads the images, sound and video the checkpoint has encoders for.

VLLMBackend (vllm_backend.py) has the weights on a vLLM server. Each token comes back with its log probability and those of the most likely tokens, and more depending on how the server was started (§9 “Sampling: how each token is picked”); the media the model reads are those the checkpoint has encoders for and the server takes (§7 “Images, sound and video: tokens in the context”); speculative decoding is the server's (§10 “MTP: the drafter guesses, the model checks them all at once”).

FakeLLM (tests/fakes.py) replays scripted output token by token and loads no weights; most tests run the whole harness and page with it (tests/fakes.py:147).

A vLLM server comes from one of two places:

Without VLLM_URL, when the sidebar's What runs the model picks vLLM (Docker), agent/model_backends/vllm_docker.py starts it with Docker Compose from docker-compose/compose.yaml (agent/model_backends/vllm_docker.py:171). The weights are downloaded into this machine's models/ first, and the container mounts that read-only and downloads nothing itself, so the server loads exactly the commit REVISIONS pins (§3 “The model folder: every file in models/”). Where Docker has NVIDIA's runtime, compose.gpu.yaml goes on top and the server runs on the GPU, with a 2 GiB KV cache as on the CPU rather than vLLM's 92% of the GPU's memory; elsewhere it runs on the CPU (agent/model_backends/vllm_docker.py:133), a Mac's included, in Docker's Linux VM, from an image docker-compose/vllm-cpu/ builds with two fixes vLLM 0.32.0 lacks on a CPU. Before vLLM takes over, the page lets go of the model on this machine (app/page.py:176); picking transformers again, or quitting the app, stops the container (agent/model_backends/vllm_docker.py:238, agent/model_backends/vllm_docker.py:250).

Any vLLM server, on this machine or another, started with vllm serve <model> --revision <its commit in REVISIONS>, and VLLM_URL tells the page where it is. The model menu lists the models it serves (/v1/models), each by what it was loaded from, without the LoRA adapters; one of a family the page does not support yet is marked ⚠, and picking it says why and offers a GitHub issue asking for it (app/page.py:130). How much the page sees of each token, and which media the model reads, depends on the flags the server was started with (§9 “Sampling: how each token is picked”, §7 “Images, sound and video: tokens in the context”, §10 “MTP: the drafter guesses, the model checks them all at once”). The prompts go to that server; the page itself stays on this machine.

VLLMBackend's prompt is just as exact: it reads the very tokenizer and chat template SimpleBackend does, from the same commit in models/ (§3 “The model folder: every file in models/”), renders and tokenizes the prompt here, and sends the server token ids, not text, so the server reads exactly the string in the Context tab (§6 “Context: rendering the whole conversation into one string”).

The Model tab names the server and its version and leaves out the device, the precision and the parameter count; the media the checkpoint reads but the server does not take are listed there with why (§7 “Images, sound and video: tokens in the context”), and the model folder card lists the folder the page reads (§3 “The model folder: every file in models/”).

To use another serving layer, SGLang or Ollama say, write one more class with these methods and attributes that yields the same events, and give load_llm() one more choice; it sends only the TOKEN_DETAILS it knows. The fake server in tests/test_vllm_backend.py is a pattern for its tests.

12

Harness

Harness: splitting the output into thinking, tool calls and the reply

The model only writes text; which part is a tool call, someone has to read out: the built-in harness and the model endpoint with the family's parser, a provider's API before it sends the reply back.

The chat template sometimes opens the thinking at the end of the prompt (the family's thought_prefix); that part is in the prompt, not in raw, so it is put back before the output goes to the family's parse, with s.lang as the language for its error messages. With Use tools off, a tool call the model writes anyway counts as none.

agent/harnesses/simple_harness.pylines 146–149
146prefix = family.thought_prefix if prompt.endswith(family.thought_prefix) else ""
147parsed = family.parse(prefix + raw, schemas, s.lang)
148calls = parsed["tool_calls"] if tools else []
149self.emit("parsed", step=step, thinking=parsed["thinking"], content=parsed["content"], tool_calls=calls)

The endpoint reads the model's output with the same family's parser, putting back first the thinking the chat template opened; the calls it can read go back to pi as tool_calls, for pi to run. A call it cannot read is not sent, and pi's turn ends there: unlike the built-in harness (§15 “Validation and permission: the model never touches the environment”), nothing tells the model what it wrote wrong. Once the message pi got ends, PiHarness sends the thinking, the reply and the calls in it as parsed.

agent/harnesses/model_endpoint.pylines 270–277
270prefix = family.thought_prefix if prompt.endswith(family.thought_prefix) else ""
271calls = [c for c in family.parse(prefix + raw, schemas, serving.lang)["tool_calls"] if not c["error"]] if tools else []
272if calls:
273    send({"tool_calls": [{"index": i, "id": f"call_{uuid.uuid4().hex[:8]}", "type": "function",
274                          "function": {"name": c["name"], "arguments": json.dumps(c["arguments"] or {}, ensure_ascii=False)}}
275                         for i, c in enumerate(calls)]})
276finish = "length" if done["finish"] == "max_new_tokens" else "tool_calls" if calls else "stop"
277send(finish=finish)

A provider's API sends the tool calls apart from the text, already as JSON; the model's own format stays at the provider. pi reads them out of the finished message, and PiHarness sends them on as parsed, with the thinking and the reply.

agent/harnesses/pi_harness.pylines 478–482
478blocks = message.get("content") or []
479calls = [{"name": b.get("name", ""), "arguments": b.get("arguments") or {}, "raw_args": json.dumps(b.get("arguments") or {}, ensure_ascii=False),
480          "error": None} for b in blocks if b.get("type") == "toolCall"]
481thinking = "\n\n".join(b["thinking"] for b in blocks if b.get("type") == "thinking" and b.get("thinking"))
482self.emit("parsed", step=self._step, thinking=thinking, content=self._text(message), tool_calls=calls)

For the built-in harness and for pi on this page's model, the family's parser reads the output. Both parsers return the same structure, thinking, tool_calls (each with name, arguments and error) and content, so the rest of the harness does not care which model it was.

Gemma 4's parser works in three steps: (1) cut out the thinking between <|channel>thought and <channel|>; (2) find every <|tool_call>call:name{…}<tool_call|> with a regular expression (agent/model_families/gemma4.py:18) and hand the arguments to a small parser; (3) record a tool call that opens but never closes (cut off at the token limit, say) as a format error. The text left over, without stop tokens, is the reply for the user.

agent/model_families/gemma4.pylines 32–66
32def parse_output(raw: str, schemas: dict | None = None, lang: str = "zh") -> dict:
33    """Split one model turn into thinking, tool calls and visible content. Gemma marks each value's
34    type itself, so the tool schemas are not needed. Errors are worded in `lang`."""
35    text = raw
36    thinking = None
37    if THOUGHT_OPEN in text:
38        before, _, rest = text.partition(THOUGHT_OPEN)
39        thought, closed, after = rest.partition(THOUGHT_CLOSE)
40        thinking = thought.strip()
41        text = before + (after if closed else "")
42
43    calls = []
44    for m in CALL_RE.finditer(text):
45        call = {"name": m.group(1), "raw_args": m.group(2), "arguments": None, "error": None}
46        try:
47            call["arguments"] = parse_args(m.group(2))
48        except ParseError as e:
49            call["error"] = e.text[lang]
50        calls.append(call)
51
52    rest = CALL_RE.sub("", text)
53    if CALL_OPEN in rest:
54        head, _, tail = rest.partition(CALL_OPEN)
55        name = re.match(r"call:(\w+)", tail)
56        calls.append({
57            "name": name.group(1) if name else "?",
58            "raw_args": tail,
59            "arguments": None,
60            "error": pick(lang, "工具呼叫沒有結束標記 <tool_call|>", "The tool call has no closing <tool_call|>"),
61        })
62        rest = head
63
64    for marker in END_MARKERS:
65        rest = rest.replace(marker, "")
66    return {"thinking": thinking, "tool_calls": calls, "content": rest.strip()}

Gemma's arguments are not JSON: keys have no quotes, and strings are wrapped in the special token <|"|>, as in {timezone:<|"|>Asia/Taipei<|"|>}. _Parser is a recursive descent parser: at <|"|> it reads up to the next <|"|>, at { an object, at [ an array, at " a JSON string, and anything else is a number, true / false or null, or else a string.

agent/model_families/gemma4.pylines 99–110
99def value(self):
100    self.skip_ws()
101    if self.text.startswith(STR, self.pos):
102        return self.delimited()
103    ch = self.peek()
104    if ch == "{":
105        return self.obj()
106    if ch == "[":
107        return self.arr()
108    if ch == '"':
109        return self.json_string()
110    return self.scalar()

Qwen3.5's parser: the thinking is before </think>; in each <tool_call> block it finds <function=name> and the <parameter=name> sections. Every value is plain text, so its type comes from the tool's schema: a string stays as it is, a number, array or object is read as JSON, and a boolean is whether it says true or false; text that does not convert stays text, and the schema check that follows reports the wrong type to the model.

agent/model_families/qwen3_5.pylines 31–53
31def parse_output(raw: str, schemas: dict | None = None, lang: str = "zh") -> dict:
32    """Split one model turn into thinking, tool calls and visible content. `schemas` maps a tool
33    name to its parameters schema, which says what type each plain-text value is. Errors are worded in `lang`."""
34    text = raw
35    thinking = None
36    if THINK_CLOSE in text:
37        thought, _, text = text.partition(THINK_CLOSE)
38        thinking = thought.split(THINK_OPEN)[-1].strip()
39    elif THINK_OPEN in text:  # cut off while still thinking
40        text, _, thought = text.partition(THINK_OPEN)
41        thinking = thought.strip()
42
43    calls = [parse_call(m.group(1), schemas or {}, lang) for m in CALL_RE.finditer(text)]
44    rest = CALL_RE.sub("", text)
45    if CALL_OPEN in rest:
46        rest, _, tail = rest.partition(CALL_OPEN)
47        call = parse_call(tail, schemas or {}, lang)
48        call["arguments"], call["error"] = None, pick(lang, "工具呼叫沒有結束標記 </tool_call>", "The tool call has no closing </tool_call>")
49        calls.append(call)
50
51    for marker in END_MARKERS:
52        rest = rest.replace(marker, "")
53    return {"thinking": thinking, "tool_calls": calls, "content": rest.strip()}
agent/model_families/qwen3_5.pylines 76–87
76def convert(value: str, kind: str | None):
77    """Turn a plain-text value into the schema's type. If it does not fit, keep the text and let
78    the schema check report the wrong type to the model."""
79    if kind in (None, "string"):
80        return value
81    if kind == "boolean":
82        word = value.strip().lower()
83        return word == "true" if word in ("true", "false") else value
84    try:
85        return json.loads(value)  # integer, number, object, array
86    except ValueError:
87        return value
13

Stop

Whether to stop: the harness decides

The model only stops generating; whether the turn ends is up to the harness, once it has read the output.

After parsing, it first checks the two cases that end the turn at once: you pressed stop, or this step has no tool call. Without a tool call, the reply is the final answer and the stop reason is no_tool_call; if the generation was cut off at the token limit without a tool call, the stop reason is max_new_tokens. Either way, the reply is kept as an assistant message, part of the history for the next turn.

What counts here is whether parsing found a tool call, not which token it stopped at. Gemma stopping at <|tool_response> does mean it wants a tool, but Qwen stops at <|im_end|> both after an answer and for a tool; deciding by the parse lets both families share this code.

agent/harnesses/simple_harness.pylines 151–159
151if done["finish"] == "user_stopped":
152    stop_reason, final = "user_stopped", parsed["content"]
153    self.messages.append({"role": "assistant", "content": final})
154    break
155if not calls:
156    stop_reason = "max_new_tokens" if done["finish"] == "max_new_tokens" else "no_tool_call"
157    final = parsed["content"]
158    self.messages.append({"role": "assistant", "content": final})
159    break

The other ways to stop are spread around the loop:

  • The for loop uses up its steps without a break → max_steps (the default set in §5 “Preparing a run: messages, tools, system prompt”).
  • Stopped or timed out while waiting for a confirmation → user_stopped or approval_timeout (agent/harnesses/simple_harness.py:189).
  • MAX_FORMAT_ERRORS (3) tool calls in a row that cannot be parsed → format_errors.
  • Any exception from the backend or the harness itself → error; the message shows on the page instead of the thread dying silently, and a backend's error (a vLLM server's, say) comes in both languages, of which the page shows the one it is in. A tool's exception does not end the turn; it becomes an observation (§16 “Environment: really running the tool”).
agent/harnesses/simple_harness.pylines 191–202However it stops, run_end always goes out last, with the stop reason, the final reply and the whole message list.
191        if format_errors >= MAX_FORMAT_ERRORS:
192            stop_reason = "format_errors"
193            break
194except Exception as e:  # surface a crash of the backend or the harness in the UI instead of a silent hang; a tool's becomes an observation
195    # A backend's error says what went wrong in both languages (vllm_backend.ServerError), and the page shows
196    # the one it is in; other errors are Python's own, in English.
197    both = getattr(e, "text", None)
198    texts = {"text": {lang: f"{type(e).__name__}: {t}" for lang, t in both.items()}} if isinstance(both, dict) else {}
199    self.emit("error", message=f"{type(e).__name__}: {e}", **texts)
200    stop_reason = "error"
201
202self.emit("run_end", stop_reason=stop_reason, final=final, messages=self.messages)

pi decides when its loop ends: a reply without a tool call ends it, and pi then says with agent_settled that it has nothing more to do. PiHarness only keeps the page's limits: pi has no step limit of its own, so when pi starts the turn past Max steps, PiHarness asks pi to abort and draws no further step; pressing stop or an unanswered confirmation aborts it too.

agent/harnesses/pi_harness.pylines 429–443
429def _turn_start(self):
430    if self._aborting:  # pi goes on to the turn its tool results start before the abort reaches it
431        self._open = False
432        return
433    if self._step >= self.settings.max_steps:  # pi has no step limit of its own; the page's is kept by stopping it
434        self._abort("max_steps")
435        self._open = False
436        return
437    self._step += 1
438    self._open = True
439    self._turn_t = self._stream_t = time.perf_counter()
440    self.emit("step_start", step=self._step)
441    with self._unserved:
442        self._waiting = self._step
443        self._unserved.notify_all()

The stop reason follows why it stopped: an abort's reason, error when pi reports a failed request (pi's retries are off, so a failure does not become another step), max_new_tokens for a reply cut off at the token limit, and no_tool_call otherwise.

agent/harnesses/pi_harness.pylines 399–406
399def _stop_reason(self) -> str:
400    if self._aborting:
401        return self._aborting
402    if self._error:
403        return "error"
404    if self._last and self._last.get("stopReason") == "length":
405        return "max_new_tokens"
406    return "no_tool_call"
14

Harness

Writing the tool call back into the conversation

A tool call first becomes an assistant message, which the chat template writes back in the model's own format in the next step.

With tool calls, the harness gives each one an id (call_{step}_{i}) and writes them into an assistant message in the format of OpenAI's and Hugging Face's tool_calls; in the next step the chat template writes it back as Gemma's <|tool_call>…<tool_call|> or Qwen's <tool_call>…</tool_call>.

Then it renders the conversation with this assistant message but without the tool results yet, and keeps that as prev_sequence; generation_prompt=False, since it is not the model's turn to write yet. Compared with it, what the next step's prompt has in addition is the tool result. Comparing with the last prompt plus the model's output would be off, because the template rewrites the model's part when it renders it again (it sorts the arguments and replaces the thinking markup).

agent/harnesses/simple_harness.pylines 161–176
161ids = [f"call_{step}_{i}" for i in range(len(calls))]
162assistant = {
163    "role": "assistant",
164    # Some templates cannot keep text in front of a tool call (see Family.text_with_calls).
165    "content": parsed["content"] if family.text_with_calls else "",
166    "tool_calls": [
167        {"id": cid, "type": "function", "function": {"name": c["name"], "arguments": c["arguments"] or {}}}
168        for cid, c in zip(ids, calls)
169    ],
170}
171if parsed["thinking"]:
172    assistant[family.reasoning_key] = parsed["thinking"]
173self.messages.append(assistant)
174# The template rewrites the model's turn when it re-renders history (sorted arguments,
175# its own thought markup), so compare the next prompt with this rendering, not prompt + raw.
176prev_sequence = self.llm.render([system, *self.messages], tools, s.thinking, generation_prompt=False)

pi writes the step into its session itself: the model's message, tool calls included, then each tool result, in the session file in the conversation's folder, and its next request carries all of it. On this page's model, the endpoint turns the request back into the conversation (§6 “Context: rendering the whole conversation into one string”), and the chat template writes the calls back in the model's own format, as below; on pi's own models, the calls in the request are JSON, and how the provider writes them for its model is not to be seen.

Where the chat template writes the calls back in the model's own format (for the built-in harness and for the endpoint alike), the two families differ in two ways, both decided by the family: whether the text a model writes before a tool call is kept (text_with_calls), and the field the thinking goes in, for the template to read it from (reasoning_key).

Gemma's template would move the text before a tool call after the tool results and close the turn there, so that text is dropped; the thinking goes in reasoning.

Qwen's template keeps the text before a tool call, and so does the harness; the thinking goes in reasoning_content.

15

Harness

Validation and permission: the model never touches the environment

Each call has three checks to pass: format and schema, repeats, and (when it is on) your confirmation.

Each call goes to _handle_call(). The first check is validation: a call that failed to parse, names no tool offered this turn (run_python without Let the model write code, say) or has arguments that do not fit the schema never runs.

agent/harnesses/simple_harness.pylines 204–215
204def _handle_call(self, step: int, cid: str, call: dict, seen: list) -> str:
205    """Validate, approve and execute one tool call. Returns the observation text for the model."""
206    lang = self.settings.lang
207    tool = self.tools.get(call["name"])
208    args, errors = call["arguments"] or {}, []
209    if call["error"]:
210        errors = [pick(lang, f"無法解析工具呼叫:{call['error']}", f"Could not parse the tool call: {call['error']}")]
211    elif tool is None:
212        known = ", ".join(self.tools)
213        errors = [pick(lang, f"沒有 {call['name']} 這個工具,可用的工具是 {known}", f"There is no tool {call['name']}; the tools are {known}")]
214    else:
215        args, errors = tool.validate(args, lang)

Tool.validate() checks that the required arguments are there, that there are no unknown ones, and that the types and enums are right, then fills in the defaults. Each error is a full sentence in the language the page was in when the turn started, because it becomes, word for word, the observation the model reads in the next step.

agent/tools.pylines 90–105
90def validate(self, args: dict, lang: str = "zh") -> tuple[dict, list[str]]:
91    """Check arguments against the schema; return them with defaults filled in, plus any errors in `lang`."""
92    props = self.parameters.get("properties", {})
93    errors = [pick(lang, f"缺少必要參數 {k}", f"Missing required argument {k}") for k in self.parameters.get("required", []) if k not in args]
94    for key, value in args.items():
95        spec = props.get(key)
96        if spec is None:
97            errors.append(pick(lang, f"沒有 {key} 這個參數", f"There is no argument {key}"))
98        elif not TYPE_CHECKS[spec["type"]](value):
99            got = type(value).__name__
100            errors.append(pick(lang, f"{key} 應該是 {spec['type']},收到 {got}", f"{key} should be {spec['type']}, got {got}"))
101        elif "enum" in spec and value not in spec["enum"]:
102            errors.append(pick(lang, f"{key} 只能是 {spec['enum']}", f"{key} must be one of {spec['enum']}"))
103    filled = {k: spec["default"] for k, spec in props.items() if "default" in spec}
104    filled.update(args)
105    return filled, errors

The second check records repeats: the tool name plus the arguments as sorted JSON is the key, compared with the earlier calls in this turn; an exact repeat is flagged in the step card as the loop maybe going in circles. It is only a warning and blocks nothing. Then the tool_call event goes out; with errors, the errors go straight back as the observation, without reaching the environment.

agent/harnesses/simple_harness.pylines 217–232
217key = (call["name"], json.dumps(args, sort_keys=True, ensure_ascii=False))
218repeated = key in seen
219seen.append(key)
220self.emit(
221    "tool_call",
222    step=step,
223    call_id=cid,
224    name=call["name"],
225    arguments=args,
226    raw_args=call["raw_args"],
227    errors=errors,
228    repeated=repeated,
229    environment=tool.environment if tool else None,
230)
231if errors:
232    return self._observe(step, cid, call["name"], {"error": "; ".join(errors)}, ok=False, ms=0)

The third check is you. With Ask me before running a tool on, the harness sends approval_request and waits in _wait_for_decision(). A rejection, a stop or a timeout each becomes an error observation, and that text is all the model sees in the next step.

agent/harnesses/simple_harness.pylines 235–248
235if self.settings.approve_tools:
236    self.emit("approval_request", step=step, call_id=cid, name=call["name"], arguments=args)
237    approved = self._wait_for_decision(cid)
238    # Nobody answered when the wait timed out or the run was stopped; only then is it not a rejection.
239    stopped = cid not in self._decisions and not self._timed_out
240    self.emit("approval", step=step, call_id=cid, approved=approved, timed_out=self._timed_out, stopped=stopped)
241    if not approved:
242        if self._timed_out:
243            error = pick(lang, "等待使用者確認逾時,這個工具呼叫沒有執行", "No one confirmed the tool call in time, so it did not run")
244        elif stopped:
245            error = pick(lang, "使用者停止了這次執行,這個工具呼叫沒有執行", "The user stopped this run, so the tool call did not run")
246        else:
247            error = pick(lang, "使用者拒絕執行這個工具呼叫", "The user refused to run this tool call")
248        return self._observe(step, cid, call["name"], {"error": error}, ok=False, ms=0)

The wait uses a threading.Condition: it wakes every 0.5 s until you press a button (decide() calls notify_all), press stop, or APPROVAL_TIMEOUT passes (agent/harnesses/simple_harness.py:34, 5 minutes). A timeout counts as a rejection and stops the whole turn, so a closed tab cannot hold on to the model.

agent/harnesses/simple_harness.pylines 270–280
270def _wait_for_decision(self, cid: str) -> bool:
271    deadline = time.monotonic() + APPROVAL_TIMEOUT
272    with self._decided:
273        while cid not in self._decisions and not self._stop.is_set():
274            left = deadline - time.monotonic()
275            if left <= 0:  # treat as rejected and end the run
276                self._timed_out = True
277                self._stop.set()
278                break
279            self._decided.wait(timeout=min(left, 0.5))
280        return self._decisions.get(cid, False)

pi checks a call's arguments against the tool's schema itself before running it; a call that does not fit does not run, and the error is the result the model reads. PiHarness flags repeats as the built-in harness does (agent/harnesses/pi_harness.py:489).

pi does not ask anyone before it runs a tool. With Ask me before running a tool on, the page's extension opens a confirm dialog every time pi is about to run a tool. In RPC mode the dialog is an extension_ui_request on stdout, which PiHarness turns into an approval_request (agent/harnesses/pi_harness.py:508), and your Allow or Reject goes back on stdin (agent/harnesses/pi_harness.py:542); a rejected call does not run, and the result the model reads is the refusal, in the language the page was in when the turn started. Unanswered, the dialog stops pi after APPROVAL_TIMEOUT, as with the built-in harness.

agent/harnesses/pi_extension.tslines 55–60
55pi.on("tool_call", async (event, ctx) => {
56	const call = JSON.stringify({ toolCallId: event.toolCallId, toolName: event.toolName, input: event.input });
57	if (!(await ctx.ui.confirm(APPROVE_TITLE, call))) {
58		return { block: true, reason: env.AGENT_LOOP_REFUSAL || "The user refused to run this tool call" };
59	}
60	return undefined;

On the page, a waiting call shows Allow and Reject buttons in the chat, and pressing one calls run.run.decide(); an argument over several lines, like run_python's code, is shown in a code block of its own, so you can read it before you decide. Asking first is written into the harness's code, not into the prompt as a request to the model, so the model has no way around it.

app/page.pylines 887–899
887for call in render.pending_approvals(data):
888    with st.container(border=True):
889        # An argument over several lines (code) is shown below the call rather than squeezed into its JSON.
890        blocks = {k: v for k, v in call["arguments"].items() if isinstance(v, str) and "\n" in v.strip()}
891        args = json.dumps({k: "…" if k in blocks else v for k, v in call["arguments"].items()}, ensure_ascii=False)
892        st.markdown(tr("**Agent 想執行這個工具,要允許嗎?**", "**The agent wants to run this tool. Allow it?**") + f"  \n`{call['name']}({args})`")
893        for code in blocks.values():
894            st.code(code)
895        allow, reject = st.columns(2)
896        allow.button(tr("允許", "Allow"), key=f"allow-{i}-{call['call_id']}", type="primary", width="stretch",
897                     on_click=run.run.decide, args=(call["call_id"], True))
898        reject.button(tr("拒絕", "Reject"), key=f"reject-{i}-{call['call_id']}", width="stretch",
899                      on_click=run.run.decide, args=(call["call_id"], False))
16

Environment

Environment: really running the tool

Only after the checks does the tool run; its errors become observations too, and never crash the loop.

After tool_start goes out, the tool's Python function is called; a tool that takes_files (run_python) also gets the conversation's folder. A ToolError from the tool is an expected failure (a mistyped timezone, a page that does not exist, code that fails), and its message goes to the model as it is, in the turn's language; any other exception is caught too and becomes {"error": …}.

agent/harnesses/simple_harness.pylines 250–258
250self.emit("tool_start", step=step, call_id=cid, name=call["name"], environment=tool.environment)
251start = time.perf_counter()
252try:
253    result, ok = tool.fn(**args, **({"files": self.files} if tool.takes_files else {})), True
254except ToolError as e:
255    result, ok = {"error": e.text[lang]}, False
256except Exception as e:
257    result, ok = {"error": f"{type(e).__name__}: {e}"}, False
258return self._observe(step, cid, call["name"], result, ok=ok, ms=(time.perf_counter() - start) * 1000)

The five tools reach different environments:

  • get_current_time: reads the system clock and converts with an IANA timezone name (agent/tools.py:180).
  • calculator: computes over an AST allowlist and never calls eval(). Only numbers, arithmetic, powers and a few math functions are allowed; before a power it estimates the number of digits, and it caps the digits argument of round, which computes a power inside, so an expression like 101010 cannot hang the thread.
  • search_wikipedia / read_wikipedia: call the Wikipedia API with httpx (agent/tools.py:198), modeled on the Wikipedia actions the ReAct paper gave its model on HotpotQA (search, lookup and finish), here as search and read.
  • run_python: runs the code the model writes in a Docker container and returns what it prints and the charts it draws (agent/tools.py:306).
agent/tools.pylines 129–152Walks the syntax tree recursively and raises ToolError at any node outside the allowlist.
129def _eval(node):
130    if isinstance(node, ast.Expression):
131        return _eval(node.body)
132    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
133        return node.value
134    if isinstance(node, ast.Name) and node.id in CONSTS:
135        return CONSTS[node.id]
136    if isinstance(node, ast.BinOp) and type(node.op) in BIN_OPS:
137        left, right = _eval(node.left), _eval(node.right)
138        # Check the size before computing: ((10**999)**999)**999 would otherwise hang.
139        if isinstance(node.op, ast.Pow) and right > 0 and _digits(left) * right > MAX_DIGITS:
140            raise ToolError(f"結果超過 {MAX_DIGITS} 位數", f"The result has more than {MAX_DIGITS} digits")
141        return BIN_OPS[type(node.op)](left, right)
142    if isinstance(node, ast.UnaryOp) and type(node.op) in UNARY_OPS:
143        return UNARY_OPS[type(node.op)](_eval(node.operand))
144    if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in FUNCS and not node.keywords:
145        args = [_eval(a) for a in node.args]
146        if node.func.id == "factorial" and args and args[0] > 500:
147            raise ToolError("factorial 的參數太大", "The argument of factorial is too large")
148        # round(5, -10**9) computes 10 ** 10**9 inside int.__round__, where the check on powers above cannot see it.
149        if node.func.id == "round" and len(args) > 1 and abs(args[1]) > MAX_DIGITS:
150            raise ToolError("round 的位數參數太大", "The digits argument of round is too large")
151        return FUNCS[node.func.id](*args)
152    raise ToolError(f"不支援的運算:{ast.unparse(node)}", f"Unsupported operation: {ast.unparse(node)}")

calculator can only work out one expression; run_python lets the model write whole programs, so its environment is shut tighter. Every call starts a new container from the image agent/sandbox/Dockerfile builds (Python 3.12 with numpy and pandas, openpyxl to read Excel, tabulate to print tables, and matplotlib to draw charts, with a font for Chinese labels): no network, a read-only file system where only /tmp can be written, 512 MB of memory, one CPU, at most 64 processes, no Linux capabilities, and your own user, never root (agent/tools.py:234: on Linux a folder mounted in the container keeps its permissions, which might not let another user in); the conversation's folder is mounted read-only at /data. The code goes in on stdin, and the container is deleted when it is done.

agent/tools.pylines 243–256
243def sandbox_command(name: str, files: Path | None) -> list[str]:
244    """docker run for one piece of code, read from stdin. The container has no network, a read-only file system
245    except a small /tmp, a cap on memory, CPU and processes, no Linux capabilities, the host's own user (never root),
246    and the conversation's files read-only at /data; --rm deletes it afterwards. Its charts come out on stdout,
247    after FIGURES_MARK."""
248    cmd = [
249        "docker", "run", "--rm", "-i", "--name", name, "--network", "none",
250        "--memory", "512m", "--memory-swap", "512m", "--cpus", "1", "--pids-limit", "64",
251        "--read-only", "--tmpfs", "/tmp:rw,size=64m", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
252        "--user", sandbox_user(), "-e", "HOME=/tmp", "-e", f"AGENT_LOOP_FIGURES={FIGURES_MARK}", "-w", "/tmp",
253    ]
254    if files is not None:
255        cmd += ["-v", f"{Path(files).resolve()}:/data:ro"]
256    return cmd + [SANDBOX_IMAGE, "python", "-I", "-"]

The container cannot write anywhere the page can read, so the charts come out on stdout with what the code prints: matplotlib's backend in the image (agent/sandbox/agent_loop_figures.py) turns each figure into a PNG at plt.show() or when the program ends, and as it exits writes them after the printed text: a line with the marker run_python passes in AGENT_LOOP_FIGURES, then one base64 PNG per line. run_python cuts them off what the model reads, saves them in the conversation's folder as figure-1.png, figure-2.png… (agent/tools.py:283) and returns a ToolResult: the model reads {"stdout": …, "figures": ["/data/figure-1.png"]}, and the images themselves are for the page alone, drawn in the chat and on this step's card.

Code that runs past 30 seconds has its container stopped; a nonzero exit code (an exception, say), too much memory or neither output nor a chart is a ToolError, so the model reads the traceback or a reminder in the next step and can fix the code and run it again. Docker is not required: run_python is offered only when Let the model write code is on in the sidebar, and only then does the page look for Docker (agent/tools.py:259); if it is not installed, not running, or the image is not built or is older than agent/sandbox/Dockerfile (going by its agent-loop-sandbox.version label), the sidebar says what to do, and the turn goes on without run_python.

pi runs its own tools: read, bash, edit, write, grep, find and ls, of which the sidebar picks the ones the model sees (read, bash, edit and write by default, as pi offers). They run on this computer, in the conversation's folder, which is pi's working folder, without a sandbox: unlike run_python in a Docker container with no network and read-only files, bash reaches whatever you can. When pi starts a tool, PiHarness sends tool_call and tool_start (with asking first, tool_start only once you allow it); when the tool ends, tool_result, with the text pi's tool returned.

agent/harnesses/pi_harness.pylines 489–498
489def _tool_start(self, call_id: str, name: str, args: dict):
490    key = (name, json.dumps(args, sort_keys=True, ensure_ascii=False))
491    repeated = key in self._seen
492    self._seen.append(key)
493    self._calls[call_id] = {"name": name, "arguments": args, "step": self._step}
494    environment = environment_of(name)
495    self.emit("tool_call", step=self._step, call_id=call_id, name=name, arguments=args, raw_args=json.dumps(args, ensure_ascii=False),
496              errors=[], repeated=repeated, environment=environment)
497    if not self.settings.approve_tools:  # otherwise it starts once the user allows it (_decided)
498        self.emit("tool_start", step=self._step, call_id=call_id, name=name, environment=environment)
17

Context

The observation goes back into the context for the next step

The tool result becomes a tool message; when the next step renders, it appears after the model's tool call.

In _observe() the tool result (or error) becomes a JSON string (a ToolResult with charts is split first: the model reads only its data, and the images go only into the event, for the page), cut at MAX_RESULT_CHARS (2000 characters) with …(truncated) added, so one long web page cannot flood the context; the tool_result event goes out, and the string is returned.

agent/harnesses/simple_harness.pylines 260–268
260def _observe(self, step: int, cid: str, name: str, result: dict | ToolResult, ok: bool, ms: float) -> str:
261    # The model reads a ToolResult's data; its images (run_python's charts) are only for the page to show.
262    result, images = (result.data, result.images) if isinstance(result, ToolResult) else (result, [])
263    text = json.dumps(result, ensure_ascii=False)
264    truncated = len(text) > MAX_RESULT_CHARS
265    if truncated:
266        text = text[:MAX_RESULT_CHARS] + "…(truncated)"
267    self.emit("tool_result", step=step, call_id=cid, name=name, ok=ok, result=text, truncated=truncated, ms=round(ms, 1), images=images)
268    return text

The returned string is appended to self.messages as a role: tool message. The count of format errors in a row is updated at the same time: one more for a parse error, back to zero otherwise.

agent/harnesses/simple_harness.pylines 178–193
178for cid, call in zip(ids, calls):
179    result = self._handle_call(step, cid, call, seen)
180    if call["error"]:
181        format_errors += 1
182    else:
183        format_errors = 0
184    self.messages.append({"role": "tool", "tool_call_id": cid, "name": call["name"], "content": result})
185    if self._stop.is_set():
186        break
187
188if self._stop.is_set():
189    stop_reason = "approval_timeout" if self._timed_out else "user_stopped"
190    break
191if format_errors >= MAX_FORMAT_ERRORS:
192    stop_reason = "format_errors"
193    break

pi puts the tool's result after its call and starts the next turn; PiHarness sends tool_result when the tool ends, with the text pi's tool returned (agent/harnesses/pi_harness.py:500). The next request carries it, and the step's request marks it as new. On this page's model, the endpoint makes it a tool message with its tool's name, which the chat template writes into the context, as below.

Then it is back to the top of the loop, and when the next step renders (for the built-in harness and for pi on this page's model alike), the chat template writes the tool result into the context; this is what the step card shows as the last step's observation, back in the context:

<|tool_response>response:name{value:<|"|>…<|"|>}<tool_response|>, right after the model's tool call and still in the same model turn; the model reads the result and carries on writing.

<|im_start|>user followed by <tool_response> … </tool_response>, so the result is a user message; a new <|im_start|>assistant follows, and the model writes in a new turn.

Either way, in the next step the model may call a tool again or answer right away.

18

Page

The page: how events become what you see

The background thread only appends events; the page redraws the whole list every 0.5 s.

workspace is a fragment: while an agent runs, it reruns itself every 0.5 s and redraws only the chat and the views on the right, not the whole page. The moment the run ends, the whole page reruns once, and the polling stops. Streamlit keeps the fragment to rerun it on its own, so it holds the model only weakly: when another model, or what runs it, is picked, release_model() can let this one go.

app/page.pylines 1111–1125
1111@st.fragment(run_every=POLL_SECONDS if polling else None)
1112def workspace():
1113    if (llm := model()) is None:  # let go meanwhile: the full rerun that follows draws the next one
1114        st.rerun()
1115    absorb_finished()
1116    live_run = next((run for run in st.session_state.runs if run.live), None)
1117    other = other_tab_run()
1118    if polling and live_run is None and other is None:
1119        st.rerun()  # the run just ended: redraw everything once and stop polling
1120    with st.container(key="workspace"):
1121        chat, viz = st.columns([5, 7], gap="large")
1122        with chat:
1123            chat_panel(llm, live_run, other)
1124        with viz:
1125            viz_panel(llm)

On every redraw, LiveRun.data() copies the event list so far and hands it to render.build(), which folds it into steps in order. run_start says which model and family the turn uses, and from then on the special tokens, the stop tokens and where the user's message starts are all asked of that family; step_start opens a new step, context, prefill, token and parsed fill it in (a turn pi ran also has the request pi sent, request, and when pi asks one of its own models, no family and none of the first three, but delta, the text as it streams), model_end marks the tokens that were guesses when a server says so only then (§10 “MTP: the drafter guesses, the model checks them all at once”), and the tool events go to their calls by call_id. All the HTML after that is a pure function of this structure (and of the page's language), so it can be drawn at any moment of a run; switching the language just redraws the same events in the other one, finished turns included.

app/render.pylines 381–432
381def build(events: list[dict]) -> dict:
382    data: dict = {"model": None, "family": None, "harness": None, "settings": {}, "steps": [], "end": None, "error": None, "last": None, "t": 0}
383    for ev in events:
384        kind = ev["type"]
385        data["last"], data["t"] = kind, ev["t"]
386        if kind == "run_start":
387            # A harness that does not know how the model writes its turns (pi) sends no family.
388            data["model"], data["family"] = ev["model"], model_families.get(ev["family"]) if ev.get("family") else None
389            data["harness"], data["settings"] = ev.get("harness", "simple"), ev["settings"]
390        elif kind == "step_start":
391            data["steps"].append({
392                "n": ev["step"], "t0": ev["t"], "t1": ev["t"], "context": None, "prefill": None,
393                "tokens": [], "text": [], "request": None, "done": None, "parsed": None, "calls": {},
394            })
395        elif kind == "run_end":
396            data["end"] = ev
397        elif kind == "error":
398            data["error"] = ev.get("text") or ev["message"]
399        elif data["steps"]:
400            step = data["steps"][-1]
401            step["t1"] = ev["t"]
402            if kind == "token":
403                step["tokens"].append(ev)
404            elif kind == "delta":  # kept as runs of one channel: [{"channel", "text"}]
405                if step["text"] and step["text"][-1]["channel"] == ev["channel"]:
406                    step["text"][-1] = {**step["text"][-1], "text": step["text"][-1]["text"] + ev["text"]}
407                else:
408                    step["text"].append({"channel": ev["channel"], "text": ev["text"]})
409            elif kind in ("request", "context", "prefill", "parsed"):
410                step[kind] = ev
411            elif kind == "model_end":
412                step["done"] = ev
413                step["tokens"] = with_drafts(step["tokens"], ev.get("mtp") or {})
414            elif kind == "tool_call":
415                step["calls"][ev["call_id"]] = {**ev, "approval": None, "started": False, "result": None}
416            elif kind == "approval_request":
417                step["calls"][ev["call_id"]]["approval"] = "pending"
418            elif kind == "approval":
419                if ev["approved"]:
420                    approval = "approved"
421                elif ev.get("timed_out"):
422                    approval = "timeout"
423                elif ev.get("stopped"):
424                    approval = "stopped"
425                else:
426                    approval = "rejected"
427                step["calls"][ev["call_id"]]["approval"] = approval
428            elif kind == "tool_start":
429                step["calls"][ev["call_id"]]["started"] = True
430            elif kind == "tool_result":
431                step["calls"][ev["call_id"]]["result"] = ev
432    return data

Which box of the loop diagram lights up and which arrow flows is decided by the last event:

app/render.pylines 610–626
610state["active"], state["edge"] = {
611    # pi sends the request as its turn starts, and says nothing more until the model's output comes back.
612    "step_start": ("context", "context-model" if data["harness"] == "pi" else "env-context" if step and step["n"] > 1 else None),
613    "context": ("context", "context-model"),
614    "prefill": ("model", "context-model"),
615    "token": ("model", None),
616    "delta": ("model", None),
617    "model_end": ("harness", "model-harness"),
618    "parsed": ("harness", "model-harness"),
619    "tool_call": ("harness", None),
620    "approval_request": ("harness", None),
621    "approval": ("harness", None),
622    "tool_start": ("env", "harness-env"),
623    "tool_result": ("env", "env-context") if started else ("harness", None),
624    "run_end": ("stop", "harness-stop"),
625    "error": ("stop", None),
626}.get(last, (None, None))

When a turn ends, absorb_finished() keeps the full message list from run_end as history. The next question is appended to it, so the agent remembers what was said before, earlier tool results included.

app/page.pylines 488–495
488def absorb_finished():
489    """When a run ends, its full message list becomes the history for the next turn."""
490    for run in st.session_state.runs:
491        if not run.live and not run.absorbed:
492            end = next((e for e in reversed(run.events) if e["type"] == "run_end"), None)
493            if end:
494                st.session_state.history = end["messages"]
495            run.absorbed = True

The stop button calls SimpleHarness.stop(): it sets _stop and wakes a thread that is waiting for a confirmation. The decoding loop checks _stop before every token, so after the press at most one more token is generated.

agent/harnesses/simple_harness.pylines 84–92
84def stop(self):
85    self._stop.set()
86    with self._decided:
87        self._decided.notify_all()
88
89def decide(self, call_id: str, approved: bool):
90    with self._decided:
91        self._decisions[call_id] = approved
92        self._decided.notify_all()

The stop button calls PiHarness.stop(), which puts the stop in the queue run() reads pi's records from; run() then sends pi abort, takes every confirmation still waiting as stopped, and wakes a request still waiting for its step (agent/harnesses/pi_harness.py:385). pi stops the request to the model and the tool that runs; on this page's model, the endpoint stops before the next token, and turns away a request that belongs to no step instead of prefilling it. A pi that has not settled after STOP_GRACE (10 s) is killed.

agent/harnesses/pi_harness.pylines 385–397
385def _abort(self, reason: str):
386    if self._aborting:
387        return
388    self._aborting = reason
389    for call_id, (dialog, _) in list(self._pending.items()):
390        timed_out = reason == "approval_timeout"
391        self.emit("approval", step=self._calls[call_id]["step"], call_id=call_id, approved=False, timed_out=timed_out, stopped=not timed_out)
392        self.pi.send({"type": "extension_ui_response", "id": dialog, "cancelled": True})
393    self._pending.clear()
394    self.pi.send({"type": "abort"})
395    self._kill_at = time.monotonic() + STOP_GRACE
396    with self._unserved:  # a request waiting for its step learns now, not after WAIT_FOR_STEP, that none will open
397        self._unserved.notify_all()
#

Reference

Events

The harness appends an event at every stage; everything on the page is drawn from this list. They are in the order they come in a turn.

EventSent fromCarriesWhat the page does with it
run_startagent/harnesses/simple_harness.py:106which harness ran it, the model and family (none from pi), the turn's settings, the available toolswhose format to draw the turn in; the step limit, the decoding notes
step_startagent/harnesses/simple_harness.py:117the step numberopens a new step card; Context lights up in the loop diagram
requestagent/harnesses/pi_harness.py:539the request pi sends the model, as JSON (an image as its type alone), where this step's new part is, the number of messages per role; only from pithe step card's context section when pi asks a provider's model; the Context tab
contextagent/harnesses/simple_harness.py:124the whole prompt as the model reads it (media expanded), its token count, added_from, the tokens each kind of media takes, the context limit, the number of messages per rolethe step card's context section, the Context tab
prefillagent/model_backends/simple_backend.py:302the prompt's token count, the prefill timethe Model box in the loop diagram, prefill ms in the Model tab
tokenagent/model_backends/simple_backend.py:326id, piece, text, channel, whether it is special, probability, rank, the top 5 candidates; with MTP, also draft (what the drafter guessed and whether it was kept). The fields from the probability on are there only when the backend knows them: simple_backend.py knows them all, a vLLM server the probability and the top 5, and more as its flags allowthe token's heat color, underline and popover; the reply appearing bit by bit on the left
deltaagent/harnesses/pi_harness.py:459a piece of the model's output and its channel (thinking, reply, tool call); only from a harness that sees no tokens, which is pithe text appearing in the step card's model section, the reply appearing bit by bit on the left
model_endagent/harnesses/simple_harness.py:143why it stopped, the stop token, the token count, tok/s, the MTP counts (from a vLLM server, perhaps which tokens were guesses), the raw output“stopped at …”, tok/s, how many MTP guesses were kept, the table in the Model tab
parsedagent/harnesses/simple_harness.py:149the thinking, the reply, the tool callsthe Harness section appears
tool_callagent/harnesses/simple_harness.py:221the name, the validated arguments, errors, whether it repeats, the environmentthe call, the validation result and the repeat warning in the Harness section
approval_requestagent/harnesses/simple_harness.py:236the call waiting for a confirmationthe Allow and Reject buttons in the chat; the loop diagram pulses
approvalagent/harnesses/simple_harness.py:240allowed, rejected, timed out or stoppedthe Harness section shows your decision
tool_startagent/harnesses/simple_harness.py:250the tool name, the environmentEnvironment lights up in the loop diagram
tool_resultagent/harnesses/simple_harness.py:267the result string, whether it succeeded, whether it was cut, the time it took, the images for youthe result in the Environment section, the tool chip in the chat; the charts in both
erroragent/harnesses/simple_harness.py:199the exception's type and message, and both languages of a backend's error (text)the error message in the chat and on the stop card
run_endagent/harnesses/simple_harness.py:202the stop reason, the final reply, the whole message listthe stop card; the message list becomes the next turn's history