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, and where they differ, the two are described side by side. 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”). Every part comes with the code that does it; a mark like agent.py:98 is a file and a line number, and hovering over it shows that line. The line numbers are looked up in the source when this page is built; GitHub Actions rebuilds it on every push to main, so they follow the code as it changes.
Page
Overview: two threads, one event list
Each question you send is one turn (one Run); 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:239). The two talk through just three things:
- At every stage the agent appends an event to the
eventslist (harness/agent.py:95); the page only draws that list. - Pressing Stop calls
Run.stop()(harness/agent.py:83). - Pressing Allow or Reject calls
Run.decide()(harness/agent.py:88).
The project has two parts. harness/ runs a turn: the loop, the tools, the model and what runs it, and it never imports Streamlit. app/ is what you see, on top of the harness: 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 harness/ does:
harness/agent.py: the harness's loop itself,Run.run()(harness/agent.py:98).harness/backends/backend.py: everything the harness and the page ask of whatever runs the model,Backend(harness/backends/backend.py:63):render(),encode()andgenerate(), and which fields of a token event are always there and which only when the backend knows them.LocalLLMinsimple_backend.pyis one backend, andvllm_backend.pyanother (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”).harness/backends/simple_backend.py: local inference, with a hand-written decoding loop,generate()(harness/backends/simple_backend.py:283), and sampling,_sample(); with MTP on, a drafter joins in (§10 “MTP: the drafter guesses, the model checks them all at once”).harness/backends/vllm_backend.py: theBackendfor a model on a vLLM server (harness/backends/vllm_backend.py:276), the one atVLLM_URLor the one the page starts in Docker. The prompt is still rendered and tokenized here, the token ids go to a vLLM server, and each token comes back with its log probability and the top 5 candidates; which candidates the sampling cut stays on the server.harness/backends/vllm_docker.py: withoutVLLM_URL, when the sidebar picks vLLM, it starts a vLLM server with Docker Compose fromdocker-compose/compose.yamland waits until it answers (harness/backends/vllm_docker.py:170), on a GPU with MTP speculative decoding on by default; picking transformers again stops it (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”).harness/families/: everything that goes with the model.gemma4.pyandqwen3_5.pyeach read their own model's output and explain its special tokens (§2 “Model families: switching models changes only this layer”).harness/tools.py: each tool's schema (for the model to read) and Python function (for the harness to run), all listed inTOOLS(harness/tools.py:336). One of them,run_python, runs the model's code in a Docker container built fromharness/sandbox/Dockerfile(§16 “Environment: really running the tool”).harness/media.py: the files attached to a question: it saves them in the conversation's folder, writes them into the user's message and decodes them for the model's processor (§7 “Images, sound and video: tokens in the context”).harness/i18n.py: the page's two languages, Traditional Chinese and English;pick()chooses the text for one of them (harness/i18n.py:14). The harness uses it too: the errors the model reads are in the language the page was in when the turn started.
In app/:
app/page.py: the layout, the sidebar settings, the model menu and the background thread; it redraws every 0.5 s while a run is live.app/render.py: folds the event list into steps (app/render.py:306) and draws them as HTML.app/guide.py: this page, built from the code it quotes.
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”).
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: Gemma uses
<|turn>user…<turn|>; Qwen uses ChatML's<|im_start|>user…<|im_end|>. - Tool calls: Gemma writes
<|tool_call>call:name{key:<|"|>value<|"|>}<tool_call|>; Qwen writes XML: one<function=name>inside<tool_call>, then a<parameter=name>for each argument, every value plain text. - Tool results: Gemma's result goes into the same model turn (
<|tool_response>…<tool_response|>); Qwen's goes into a newusermessage (<tool_response>…</tool_response>), followed by a new assistant turn. - Stop tokens: Gemma stops at
<turn|>after an answer and at<|tool_response>when it wants a tool, so the stop token tells which; Qwen stops at<|im_end|>either way, and only a tool call in the output tells them apart. - Thinking: Gemma puts
<|think|>at the start of the system turn and writes its reasoning between<|channel>thoughtand<channel|>; for Qwen, 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:
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”).
144self.family = families.for_model_type(AutoConfig.from_pretrained(_download(model_id, "config.json")).model_type) 145files = self.folder = _download(model_id) 146# The processor holds the tokenizer and, for a model that reads more than text, what turns files into its inputs. 147self.processor = AutoProcessor.from_pretrained(files) 148self.tokenizer = getattr(self.processor, "tokenizer", self.processor) 149self.model = AutoModelForMultimodalLM.from_pretrained(files, dtype=self.dtype, device_map=self.device).eval() 150self.modalities = self._modalities() 151self.media_token_ids = {kind: getattr(self.model.config, f"{kind}_token_id", None) for kind in self.modalities} 152self.load_seconds = time.perf_counter() - start 153 154# The generation config's stop tokens, plus the family's: a checkpoint may list only some of them. 155# Qwen3.5 ships no generation_config.json, and its config.json names <|endoftext|> but not <|im_end|>, 156# which ends every chat turn. 157eos = self.model.generation_config.eos_token_id 158self.eos_ids = set(eos if isinstance(eos, list) else [eos]) | self.token_ids(self.family.stop_tokens) 159# Control tokens count as special even when the tokenizer does not mark them (Qwen's <think>). 160self.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 (harness/families/__init__.py:23). A new family is a module like harness/families/qwen3_5.py, with a parser for the output and notes on the special tokens (in Chinese and in English), added to FAMILIES (harness/families/__init__.py:129); a matching model_type picks it automatically.
parse is code.268QWEN3_5 = Family( 269 key="qwen3_5", 270 name="Qwen3.5", 271 model_types=("qwen3_5",), 272 parse=parse_output, 273 thought_prefix="<think>\n", 274 channel_switch={"<think>": "thought", "</think>": "content", "<tool_call>": "tool_call", "</tool_call>": "content"}, 275 stop_tokens={ 276 "<|im_end|>": {"zh": "訊息結束;回覆和工具呼叫都停在這裡", "en": "end of message; replies and tool calls both stop here"}, 277 "<|endoftext|>": {"zh": "文字結束", "en": "end of text"}, 278 }, 279 reasoning_key="reasoning_content", 280 text_with_calls=True, # the template keeps a message's text in front of its <tool_call> 281 user_turn="<|im_start|>user", 282 special_groups=GROUPS, 283 thinking_help={ 284 "zh": "chat template 在 assistant 訊息開頭先寫好 `<think>`,模型先寫推理;關掉時寫一段空的 `<think></think>`。" 285 "0.8B 官方預設不開,開了容易一直想下去停不下來。", 286 "en": "The chat template opens the assistant message with `<think>`, so the model reasons first; off, it writes an empty `<think></think>`. " 287 "Off by default for the 0.8B model, which once it starts thinking often cannot stop.", 288 }, 289 tools_note={ 290 "zh": "chat template 把每個工具的 JSON Schema 原樣放進 system turn 的 `<tools>…</tools>`,後面附上 XML 呼叫格式的說明。", 291 "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.", 292 }, 293 model_note={ 294 "zh": "混合架構:24 層裡每 4 層有 3 層是 Gated DeltaNet(線性 attention,狀態大小固定,不隨脈絡變長),1 層是 gated attention。" 295 "在 transformers 裡沒裝 flash-linear-attention 時,它們用 PyTorch 參考實作,prefill 比較慢。", 296 "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. " 297 "In transformers, without flash-linear-attention installed, they run the PyTorch reference implementation, so prefill is slower.", 298 }, 299 # The checkpoint ships no generation_config.json; these are the model card's non-thinking text settings. 300 sampling={"temperature": 1.0, "top_p": 1.0, "top_k": 20}, 301 byte_level=True, 302 # Cap each video frame at the pixels the reference implementation (qwen-vl-utils) allows; transformers asks 303 # for this to be said outright until it becomes the default. 304 processor_kwargs={"cap_pixels_per_frame": True}, 305 file_notes=FILES, 306)
Model
The model folder: every file in models/
Everything from_pretrained loads comes from one folder per model in models/; here is each, for both models and Gemma's drafter.
_download() has snapshot_download() fetch a model into MODELS_DIR (harness/backends/simple_backend.py:47): 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 (harness/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.
50def _download(model_id: str, only: str | None = None) -> str: 51 """The model's folder in MODELS_DIR, after fetching the files it lacks (only `only`, if given), at the revision 52 families.REVISIONS pins it to. 53 54 from_pretrained(local_files_only=True) is no test of the cache: once config.json is cached, a tokenizer whose 55 own files are missing raises a ValueError (Gemma 4) or loads empty (Qwen3.5) rather than an OSError. 56 snapshot_download lists the model's files on the Hub and fetches only those the cache lacks; when the Hub 57 can't be reached it returns the cached folder, so a downloaded model still starts offline.""" 58 if os.path.isdir(model_id): # a checkpoint on disk rather than a Hugging Face id 59 return model_id 60 return snapshot_download(model_id, revision=families.REVISIONS.get(model_id), allow_patterns=only, cache_dir=MODELS_DIR)
Inside, models/ is laid out as Hugging Face's cache:
models--google--gemma-4-E2B-it/: one folder per repository, with the/in its id turned into--.refs/main: the revision (a commit hash) thatmainpointed to, written only by a download that asks formain, such ashf downloadwithout--revision; the app downloads the commit inREVISIONSand does not need it.snapshots/<revision>/: the files as the repository has them; this is the folderfrom_pretrainedis given. Each file in it is a link (a copy on Windows when links cannot be made).blobs/: the files themselves, named by their hash, so a file two revisions share is stored once. Files the Hub stores with Xet (the weights andtokenizer.json) sit in theblobs/at the top ofmodels/, shared across repositories too, and the model's ownblobs/links there.trees/<revision>.json: a cached list of that revision's files.CACHEDIR.TAG: marks the folder as a cache, so backup tools can skip it.
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 (harness/backends/simple_backend.py:109), and the weights load in the files' bfloat16 on a GPU but become float32 on a CPU, twice the memory.
When the model loads, which code reads which file:
config.json:AutoConfigreads it first, and itsmodel_typepicks the family (harness/backends/simple_backend.py:144); the specs at the top of the Model tab come from it too.tokenizer.json,tokenizer_config.json,chat_template.jinjaand the processor's settings:AutoProcessorreads them into one processor (harness/backends/simple_backend.py:147);render()runs the chat template (harness/backends/simple_backend.py:248).- The weights:
AutoModelForMultimodalLMbuilds the model fromconfig.jsonand fills it with the tensors of the.safetensorsfile by name (harness/backends/simple_backend.py:149). generation_config.json: the stop tokens (harness/backends/simple_backend.py:157) and the recommended sampling settings (harness/backends/simple_backend.py:206).README.md,LICENSEand.gitattributes: the model card, the license and the settings of the Hub's git, for people; the app does not read them.
Gemma 4 E2B (google/gemma-4-E2B-it, 9 files, about 10.3 GB):
config.json(5 kB):Gemma4ForConditionalGeneration, in three parts.text_configis 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_configis a 16-layer vision encoder that makes an image at most 280 tokens, andaudio_configa 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_tokenis<|turn>,stc_token<|tool_call>and so on).chat_template.jinja(19 kB): writes the<|turn>system/user/modelturns, 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_configis 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_configis a 12-layer vision encoder that images and video share; there is no sound encoder.mtp_num_hidden_layers: 1says the weights come with an MTP layer.- No
generation_config.json: the only stop token left isconfig.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 (harness/families/qwen3_5.py:300). model.safetensors-00001-of-00001.safetensors(1.7 GB): a single shard, named in the sharded format, withmodel.safetensors.index.jsonrecording 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_logand 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) andmerges.txt(3.4 MB) are the same vocabulary and merges in the older format.tokenizer_config.json(16.7 kB) takes<|im_end|>as itseos_tokenand also holds a chat template identical tochat_template.jinja.chat_template.jinja(7.8 kB): ChatML, with the tool schemas in<tools>…</tools>, a tool result as ausermessage and the thinking inside<think>(§6 “Context: rendering the whole conversation into one string”).preprocessor_config.jsonandvideo_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 (harness/backends/simple_backend.py:232).model.safetensors(158 MB): 77.7M parameters. 67M of them areembed_tokens(262,144 tokens × 256), used as the output table;pre_projectionnarrows the model's token embedding next to its hidden state (3,072 wide) to 256, andpost_projectionwidens it back to 1,536; the 4 layers themselves are only about 9M.masked_embedding.token_orderingis 262,144 integers listing the tokens cluster by cluster.tokenizer.jsonandtokenizer_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 (harness/backends/simple_backend.py:63). The notes themselves are on the family (Gemma: harness/families/gemma4.py:418; Qwen: harness/families/qwen3_5.py:305), so another checkpoint of the family shows its own numbers.
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, ...] = ()
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 the LocalLLM for the model picked in the sidebar (a VLLMBackend when the model runs on a vLLM server); the rest of the page takes it for just a Backend (§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. 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 (harness/backends/simple_backend.py:124). 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 (harness/backends/simple_backend.py:206). Switching models in the sidebar's menu starts a new conversation (app/page.py:420) and turns MTP back off, except on vLLM (Docker), where MTP is how the server was started; the first time MTP speculative decoding is switched on, current_llm() also loads the drafter (harness/backends/simple_backend.py:224).
133@st.cache_resource(max_entries=1, show_spinner=False) # one model in memory: switching models frees the last one 134def load_llm(model_id: str, url: str | None) -> Backend: 135 llm = vllm_backend.VLLMBackend(model_id, url) if url else LocalLLM(model_id) 136 llm.warmup() 137 return llm 138 139 140def release_model(): 141 """Let the model in memory go now, rather than once the next one has loaded beside it.""" 142 load_llm.clear() 143 gc.collect() 144 empty_cache()
Before it loads anything, current_llm() does two more things: right after transformers is picked again, it stops vLLM's container; when the sidebar picked vLLM (and VLLM_URL is not set), it first makes sure the vLLM server in Docker is up, and takes its URL (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”). 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, and a new tab or a reload also takes the model the container runs (app/page.py:277). MTP follows the server too, on or off, rather than start it again the other way. Only the tab that just picked leads, until current_llm() has started or stopped vLLM.
188def current_llm() -> Backend: 189 model_id = st.session_state.model_id 190 if st.session_state.pop("stop_vllm", False): # transformers was just picked again: vLLM's memory goes back first 191 with st.spinner(tr("停止 vLLM 的容器…", "Stopping vLLM's container…")): 192 vllm_docker.stop() 193 st.session_state.pop("engine_picked", None) # vLLM is stopped or about to start: from now on this tab follows it too 194 url = vllm_server(model_id)["url"] if docker_vllm() else VLLM_URL 195 if url: 196 loading = tr(f"連線到 {url} 的 vLLM 伺服器,載入 {model_id} 的 tokenizer…", f"Connecting to the vLLM server at {url} and loading {model_id}'s tokenizer…") 197 else: 198 loading = tr(f"載入 {model_id}(第一次要下載權重;之後約 10–20 秒)…這時候切換的設定,載入完才會生效。", 199 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.") 200 with st.spinner(loading): 201 llm = load_llm(model_id, url) 202 if st.session_state.sampling_for != model_id: # a model just chosen starts from its own recommended decoding, MTP off 203 # but on vLLM in Docker, where MTP is how the server was just started 204 for key, value in {**llm.info["defaults"], **({} if docker_vllm() else {"mtp": False})}.items(): 205 st.session_state[key] = value 206 st.session_state.sampling_for = model_id 207 if st.session_state.mtp and llm.drafter_id and llm.drafter is None: 208 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)…")): 209 llm.load_drafter() 210 return llm
The input box (app/page.py:590) 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:328): 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”).
threading.Lock in generation_slot(), shared by every tab through st.cache_resource.325def start_run(llm: Backend, text: str, uploads: list[tuple[str, bytes]] = ()) -> bool: 326 """Ask the question in a new run, with `uploads` (file name, contents) saved in the conversation's folder first.""" 327 slot = generation_slot() 328 if not slot.lock.acquire(blocking=False): 329 st.toast(tr("另一個 agent 正在執行(可能在別的分頁),請稍等。", "Another agent is running (maybe in another tab). Please wait.")) 330 return False 331 try: 332 attachments = [media.save(chat_folder(), name, data, llm.modalities) for name, data in uploads] 333 run = LiveRun(llm, st.session_state.history, text, attachments, chat_folder(), current_settings(llm), slot) 334 except Exception: 335 slot.lock.release() 336 raise 337 st.session_state.runs.append(run) 338 st.session_state.view = len(st.session_state.runs) - 1 339 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”).
292def current_settings(llm: Backend) -> RunSettings: 293 s = st.session_state 294 return RunSettings( 295 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())), 296 max_steps=s.max_steps, 297 thinking=s.thinking, 298 use_tools=s.use_tools, 299 tools=offered_tools(), 300 approve_tools=s.approve_tools, 301 lang=s.lang, 302 )
LiveRun creates the Run, passing self.events.append as its emit, along with the attachments and the conversation's folder, 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.
daemon=True: stopping the server does not wait for the agent to finish.227class LiveRun: 228 """One user turn: the harness thread and the events it has emitted so far.""" 229 230 def __init__(self, llm: Backend, history: list[dict], text: str, attachments: list[media.Attachment], files: Path, 231 settings: RunSettings, slot: Slot): 232 self.text = text 233 self.attachments = attachments 234 self.events: list[dict] = [] 235 self.absorbed = False 236 self.run = Run(llm, history, text, settings, self.events.append, attachments, files) 237 self._slot = slot 238 slot.run = self 239 self.thread = threading.Thread(target=self._work, daemon=True) 240 self.thread.start() 241 242 def _work(self): 243 try: 244 self.run.run() 245 finally: 246 if self._slot.run is self: 247 self._slot.run = None # do not keep the model alive after a switch to another one 248 self._slot.lock.release() 249 250 @property 251 def live(self) -> bool: 252 return self.thread.is_alive() 253 254 def data(self) -> dict: 255 return render.build(self.events[:])
Harness
Preparing a run: messages, tools, system prompt
self.messages is all the agent remembers; every step renders the whole of it again.
Run.__init__ appends this question to the conversation so far and keeps it as self.messages (harness/agent.py:71). 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.
66def __init__(self, llm: Backend, history: list[dict], user_text: str, settings: RunSettings, emit: Callable[[dict], None], 67 attachments: list[media.Attachment] = (), files: Path | None = None): 68 self.id = uuid.uuid4().hex[:12] 69 self.llm = llm 70 self.settings = settings 71 self.messages = [*history, {"role": "user", "content": media.user_content(user_text, list(attachments))}] 72 self.files = files # the conversation's folder, which run_python's sandbox sees as /data 73 self.tools = {name: t for name, t in TOOLS.items() if name in settings.tools} if settings.use_tools else {} 74 self._emit = emit 75 self._start = time.perf_counter() 76 self._stop = threading.Event() 77 self._timed_out = False 78 self._decisions: dict[str, bool] = {} 79 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 (harness/agent.py:35) and without. The first event, run_start, carries the model and the family, so the page knows whose format to draw the turn in.
stop_reason starts out as max_steps: when the loop uses up its steps without a break, the step limit is why it stopped.98def run(self): 99 s = self.settings 100 family = self.llm.family # how this model writes its turns, thinking and tool calls 101 tools = [t.schema() for t in self.tools.values()] or None 102 schemas = {t.name: t.parameters for t in TOOLS.values()} 103 system = {"role": "system", "content": SYSTEM_WITH_TOOLS if tools else SYSTEM_NO_TOOLS} 104 self.emit( 105 "run_start", run_id=self.id, model=self.llm.model_id, family=family.key, 106 settings=_settings_dict(s), tools=list(self.tools), 107 ) 108 109 seen: list[tuple[str, str]] = [] 110 format_errors = 0 111 prev_sequence = "" 112 stop_reason, final = "max_steps", ""
A tool's schema is a JSON Schema, wrapped in the function format that Hugging Face chat templates know:
83def schema(self) -> dict: 84 return { 85 "type": "function", 86 "function": {"name": self.name, "description": self.description, "parameters": self.parameters}, 87 }
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:
step_start has the page open a new step card; context goes out with the whole string the model really reads.115for step in range(1, s.max_steps + 1): 116 self.emit("step_start", step=step) 117 prompt = self.llm.render([system, *self.messages], tools, s.thinking) 118 encoded = self.llm.encode(prompt, media.parts(self.messages)) 119 # Media sit in user messages, all before what is new since the last step; so the new part is the same 120 # text in the prompt and in what the model reads, only further along by what the media expanded into. 121 added_from = len(encoded.text) - len(prompt) + common_prefix(prompt, prev_sequence) if prev_sequence else 0 122 self.emit( 123 "context", 124 step=step, 125 prompt=encoded.text, 126 n_tokens=encoded.n_tokens, 127 added_from=added_from, 128 media_tokens=encoded.media_tokens, 129 context_length=self.llm.context_length, 130 roles=_role_counts([system, *self.messages]), 131 )
render() calls the chat template that comes with the tokenizer. The format is the model's own, and the harness does not need to know it:
- Gemma 4: 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|>. - Qwen3.5: 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) opens the model's turn at the end, asking the model to write next: <|turn>model for Gemma; for Qwen, <|im_start|>assistant followed by <think> (thinking mode on) or an empty <think></think> (off).
248def render(self, messages: list[dict], tools: list[dict] | None, thinking: bool, generation_prompt: bool = True) -> str: 249 """The chat template turns messages and tool schemas into the one string the model reads. 250 With `generation_prompt` it also opens the model's turn, so the model writes next.""" 251 return self.tokenizer.apply_chat_template( 252 messages, tools=tools or None, tokenize=False, add_generation_prompt=generation_prompt, enable_thinking=thinking 253 )
Next encode() turns the string into the tokens the model really reads (harness/agent.py:118). 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.
57def common_prefix(a: str, b: str) -> int: 58 n = min(len(a), len(b)) 59 i = 0 60 while i < n and a[i] == b[i]: 61 i += 1 62 return i
The page's Context tab prints the string in the context event as it is (app/render.py:955), marks everything after added_from as new in this step, 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.
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.
195def _modalities(self) -> tuple[str, ...]: 196 """What the checkpoint reads besides text: each kind needs an encoder in the config and a part of the processor 197 to feed it. It is asked of the checkpoint, not the family, since checkpoints of one family can differ.""" 198 config, processor = self.model.config, self.processor 199 reads = { 200 "image": getattr(config, "vision_config", None) is not None and hasattr(processor, "image_processor"), 201 "audio": getattr(config, "audio_config", None) is not None and hasattr(processor, "feature_extractor"), 202 "video": getattr(config, "video_token_id", None) is not None and hasattr(processor, "video_processor"), 203 } 204 return tuple(kind for kind, ok in reads.items() if ok)
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 (harness/media.py:61). 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 text file such as a CSV or JSON comes with its first line (usually the column names). 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.
96def user_content(text: str, attachments: list[Attachment]) -> str | list[dict]: 97 """The user's message: the text alone, or the media first (as their models are trained to read them), then the 98 text, and a note on the files only run_python can open: where they are and how a text file begins. The note is 99 for the model, so it is in English.""" 100 if not attachments: 101 return text 102 files = [a for a in attachments if a.kind == "file"] 103 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] 104 note = "Attached for run_python, which reads them:\n" + "\n\n".join(described) if files else "" 105 words = "\n\n".join(t for t in (text, note) if t) 106 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: <|image|>, <|audio|> and <|video|> for Gemma; <|vision_start|><|image_pad|><|vision_end|> and <|video_pad|> for Qwen. encode() hands the processor all the media in the conversation, in the order they come, and the processor expands each placeholder into the tokens that file takes:
- Gemma 4: 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 asmm:ssand up to 70<|video|>; sound is one<|audio|>per 40 ms, at most 750, so only the first 30 seconds are heard. - Qwen3.5: 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>.
media_tokens counts how many tokens each kind of media takes; the step card's context section shows it.255def encode(self, prompt: str, parts: list[dict]) -> Encoded: 256 """The prompt as the model reads it. Without media that is the prompt's tokens. With media (`parts`, the 257 conversation's media in the order of their placeholders), the processor replaces each placeholder with the 258 tokens that image, clip or video takes, and makes the inputs of the encoders whose output goes there.""" 259 if not parts: 260 ids = self.tokenizer(prompt, add_special_tokens=False, return_tensors="pt").input_ids 261 return Encoded(prompt, ids, ids.shape[-1]) 262 files = {kind: [p["path"] for p in parts if p["type"] == kind] for kind in media.MEDIA} 263 inputs = {} 264 if files["image"]: 265 inputs["images"] = [media.load_image(path) for path in files["image"]] 266 if files["audio"]: 267 inputs["audio"] = [media.load_audio(path, self.processor.feature_extractor.sampling_rate) for path in files["audio"]] 268 if files["video"]: 269 videos = [media.load_video(path) for path in files["video"]] 270 inputs["videos"] = [frames for frames, _ in videos] 271 inputs["videos_kwargs"] = {"video_metadata": [metadata for _, metadata in videos]} 272 # A processor that takes a set number of frames (Gemma 4: 32) refuses a video with fewer; it gets all there are. 273 wanted, fewest = getattr(self.processor.video_processor, "num_frames", None), min(len(frames) for frames, _ in videos) 274 if wanted and fewest < wanted: 275 inputs["videos_kwargs"]["num_frames"] = fewest 276 out = self.processor(text=prompt, return_tensors="pt", **inputs, **self.family.processor_kwargs) 277 ids = out.pop("input_ids") 278 out.pop("attention_mask", None) # one sequence, no padding: every position counts, as with text 279 counts = {kind: int((ids == token).sum()) for kind, token in self.media_token_ids.items() if token is not None} 280 text = self.tokenizer.decode(ids[0], skip_special_tokens=False) 281 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 (harness/media.py:134). 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().
360def _encoded_media(self, inputs: dict) -> dict: 361 """Run the vision encoder over the prompt's images and videos before the prefill, as generate() does, and pass 362 its output in their place. Leaving it to the forward pass breaks videos on Gemma 4: transformers 5.20's 363 Gemma4ForConditionalGeneration takes num_frames_per_video but does not hand it on to the video encoder.""" 364 if not inputs or "mm_encoder_outputs" not in inspect.signature(self.model.forward).parameters: 365 return inputs 366 base, encoded = self.model.base_model, {} 367 for kind in ("image", "video"): 368 encoder = getattr(base, f"get_{kind}_features", None) 369 args = {k: inputs[k] for k in inspect.signature(encoder).parameters if k in inputs} if encoder else {} 370 if any(k.startswith("pixel_values") for k in args): 371 encoded[kind] = encoder(**args, return_dict=True) 372 if not encoded: 373 return inputs 374 # The pixels go; the rest stays, such as the grid sizes Qwen3.5's positions are computed from. 375 return {**{k: v for k, v in inputs.items() if not k.startswith("pixel_values")}, "mm_encoder_outputs": encoded}
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.
self._stop.is_set passed in is a function: the model calls it before every token to ask whether to stop.133done = None 134for ev in self.llm.generate(encoded, s.sampling, self._stop.is_set): 135 kind = ev.pop("type") 136 if kind == "done": 137 done = ev 138 else: 139 self.emit(kind, step=step, **ev) 140raw = done.pop("raw") 141self.emit("model_end", step=step, raw=raw, **done)
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 (harness/backends/simple_backend.py:353). Prefill time depends mostly on the prompt's length; the table in the Model tab has each step's prefill ms.
thought_prefix (<|channel>thought for Gemma, <think> for Qwen), the model starts out inside its thinking.283def generate(self, prompt: Encoded, sampling: Sampling, should_stop: Callable[[], bool]) -> Iterator[dict]: 284 """Yield a prefill event, one event per token, then a done event.""" 285 ids = prompt.ids.to(self.device) 286 channel = "thought" if prompt.text.endswith(self.family.thought_prefix) else "content" 287 drafting = sampling.mtp and self.drafter is not None 288 mtp = {"drafted": 0, "accepted": 0, "forwards": 0} if drafting else None 289 generated: list[int] = [] 290 text = "" 291 finish = "max_new_tokens" 292 293 with torch.inference_mode(): 294 # Models with multimodal RoPE (Qwen3.5) keep the position offset of the last prompt's images on the model 295 # and add it in every later forward pass; each prompt starts from its own. 296 if hasattr(self.model.base_model, "rope_deltas"): 297 self.model.base_model.rope_deltas = None 298 start = time.perf_counter() 299 out = self._forward(ids, None, 1, drafting, **self._encoded_media(prompt.media)) 300 prefill_ms = (time.perf_counter() - start) * 1000 301 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: harness/families/gemma4.py:394; Qwen: harness/families/qwen3_5.py:274) → 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.
303decode_start = time.perf_counter() 304tokens = self._drafted(out, sampling, mtp) if drafting else self._one_at_a_time(out, sampling) 305for token_id, details in tokens: 306 if should_stop(): 307 finish = "user_stopped" 308 break 309 generated.append(token_id) 310 311 piece = self.piece(token_id) 312 special = token_id in self.special_ids 313 if piece in self.family.channel_switch: 314 channel = self.family.channel_switch[piece] 315 token_channel = "control" 316 else: 317 token_channel = "control" if special else channel 318 319 # Decode the whole sequence and diff it, so characters split across byte tokens come out whole. 320 full = self.tokenizer.decode(generated, skip_special_tokens=False) 321 delta = "" if full.endswith("�") else full[len(text):] 322 if delta: 323 text = full 324 yield { 325 "type": "token", 326 "index": len(generated) - 1, 327 "id": token_id, 328 "piece": piece, 329 "text": delta, 330 "special": special, 331 "channel": token_channel, 332 **details, 333 }
A stop token, or reaching max_new_tokens (the sidebar's Max tokens per step), ends the loop.
335if token_id in self.eos_ids: 336 finish = "stop_token" 337 break 338if len(generated) == sampling.max_new_tokens: 339 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.
377def _one_at_a_time(self, out, s: Sampling) -> Iterator[tuple[int, dict]]: 378 """Plain decoding: sample a token, feed it back with the cache for the next position's logits, repeat.""" 379 while True: 380 token_id, details = self._sample(out.logits[0, -1].float().cpu(), s) 381 yield token_id, details 382 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).
341decode_s = time.perf_counter() - decode_start 342yield { 343 "type": "done", 344 "finish": finish, 345 "stop_piece": self.piece(generated[-1]) if finish == "stop_token" else None, 346 "n_tokens": len(generated), 347 "decode_ms": round(decode_s * 1000, 1), 348 "tok_per_s": round(len(generated) / decode_s, 1) if decode_s > 0 else None, 349 "mtp": mtp, 350 "raw": self.tokenizer.decode(generated, skip_special_tokens=False), 351}
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.
431def _sample(self, logits: torch.Tensor, s: Sampling, draft: int | None = None) -> tuple[int, dict]: 432 """Pick the next token and report the model's distribution and what the settings did to it. 433 A `draft` (the MTP drafter's guess) is kept with the probability the settings give it, which in greedy 434 decoding means when it is the top token; otherwise the pick comes from the other candidates. Either way 435 each token comes out exactly as often as without a drafter.""" 436 probs = torch.softmax(logits, -1) # the model's own distribution (T = 1, nothing cut off) 437 # Equally likely tokens go lowest id first, the one argmax and so generate() takes. topk leaves their order open 438 # (on the CPU it can list the higher id first), and in bfloat16 two logits are equal more often than one would think. 439 top = sorted(zip(*probs.topk(TOP_N)), key=lambda c: (-float(c[0]), int(c[1]))) 440 441 if s.temperature <= 0: # T -> 0 is greedy decoding 442 chosen = int(top[0][1]) 443 kept = 1 444 p_sampled = 1.0 445 else: 446 scaled = logits / s.temperature 447 if s.top_k > 0: 448 cand_logits, cand_ids = scaled.topk(min(s.top_k, scaled.numel())) 449 else: 450 cand_logits, cand_ids = scaled.sort(descending=True) 451 cand_probs = torch.softmax(cand_logits, -1) 452 # Nucleus: keep the smallest prefix whose probability reaches top_p (always at least one token). 453 kept = int(((cand_probs.cumsum(-1) - cand_probs) < s.top_p).sum()) 454 cand_ids, cand_probs = cand_ids[:kept], cand_probs[:kept] / cand_probs[:kept].sum() 455 weights = cand_probs 456 at = (cand_ids == draft).nonzero().flatten() if draft is not None else [] 457 if len(at) and float(torch.rand(())) < float(cand_probs[at[0]]): 458 pick = int(at[0]) 459 else: 460 if len(at): # the guess was turned down: draw from the other candidates 461 weights = cand_probs.clone() 462 weights[at[0]] = 0 463 pick = int(torch.multinomial(weights, 1)) 464 chosen = int(cand_ids[pick]) 465 p_sampled = float(cand_probs[pick]) 466 467 p = float(probs[chosen]) 468 # Candidates are a prefix of the tokens sorted by probability, so rank decides who survived. 469 return chosen, { 470 "p": p, 471 "logprob": float(torch.log(probs[chosen])) if p > 0 else None, 472 "rank": int((probs > probs[chosen]).sum()) + 1, 473 "p_sampled": p_sampled, 474 "kept": kept, 475 "top": [ 476 {"id": int(i), "piece": self.piece(int(i)), "p": float(v), "kept": rank < kept} 477 for rank, (v, i) in enumerate(top) 478 ], 479 }
What it returns are 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:612): 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 (harness/backends/backend.py:59): another backend, a serving engine say, may not know them, and what it 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”).
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 (harness/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).
224def load_drafter(self): 225 """Load the MTP drafter that goes with this model (families.DRAFTERS), if any, and warm it up.""" 226 with self._drafter_lock: 227 if self.drafter is not None or self.drafter_id is None: 228 return 229 start = time.perf_counter() 230 folder = _download(self.drafter_id) 231 self.drafter = AutoModelForCausalLM.from_pretrained(folder, dtype=self.dtype, device_map=self.device).eval() 232 self.draft_tokens = self.drafter.generation_config.num_assistant_tokens # guesses per forward pass 233 self.warmup(mtp=True) 234 cfg = self.drafter.config.get_text_config() 235 self.info["drafter"] = { 236 "model_id": self.drafter_id, 237 "parameters": self.drafter.num_parameters(), 238 "layers": cfg.num_hidden_layers, 239 "hidden_size": cfg.hidden_size, 240 "draft_tokens": self.draft_tokens, 241 "load_seconds": round(time.perf_counter() - start, 1), 242 "folder": describe_folder(folder, self.family.drafter_file_notes), 243 }
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.
417def _draft(self, token_id: int, hidden: torch.Tensor, shared: dict, position: int) -> list[int]: 418 """The drafter's greedy guesses for the tokens after `token_id`. It has no cache of its own: it attends to the 419 model's keys and values, so every guess sits at the position of `token_id`, and what it has guessed so far 420 comes in through its input, the embedding of the last guess next to the drafter's last hidden state.""" 421 embed = self.model.get_input_embeddings() 422 positions = torch.tensor([[position]], device=self.device) 423 guesses = [] 424 while len(guesses) < self.draft_tokens and token_id not in self.eos_ids: 425 x = torch.cat([embed(torch.tensor([[token_id]], device=self.device)), hidden], -1) 426 out = self.drafter(inputs_embeds=x, position_ids=positions, shared_kv_states=shared) 427 token_id, hidden = int(out.logits[0, -1].argmax()), out.last_hidden_state 428 guesses.append(token_id) 429 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.
384def _drafted(self, out, s: Sampling, stats: dict) -> Iterator[tuple[int, dict]]: 385 """MTP decoding. After each token the drafter guesses the next few, and one forward pass over the token and the 386 guesses gives the model's distribution at every guessed position. _sample checks the guesses in order: each 387 one it keeps is a token for free, the first one it turns down is replaced by the model's own pick, and the 388 rest are dropped. When every guess holds, the last position gives one more token.""" 389 past = out.past_key_values 390 past.activate_past_recording() # so the sliding-window layers can still drop guesses that were turned down 391 hidden, shared = out.hidden_states[-1][:, -1:], out.shared_kv_states 392 token_id, details = self._sample(out.logits[0, -1].float().cpu(), s) 393 while True: 394 yield token_id, details 395 guesses = self._draft(token_id, hidden, shared, past.get_seq_length()) 396 out = self._forward(torch.tensor([[token_id, *guesses]], device=self.device), past, len(guesses) + 1, True) 397 logits = out.logits[0].float().cpu() 398 stats["forwards"] += 1 399 stats["drafted"] += len(guesses) 400 for n, guess in enumerate(guesses): 401 token_id, details = self._sample(logits[n], s, guess) 402 if token_id != guess: 403 details["draft"] = {"piece": self.piece(guess), "accepted": False} 404 break 405 stats["accepted"] += 1 406 yield token_id, {**details, "draft": {"piece": self.piece(guess), "accepted": True}} 407 else: 408 n = len(guesses) 409 token_id, details = self._sample(logits[n], s) 410 # Keep the cache and the drafter's inputs up to the last kept guess (or the token, when none held). 411 dropped = len(guesses) - n 412 past.crop(-dropped) 413 hidden = out.hidden_states[-1][:, n : n + 1] 414 shared = {k: (key[:, :, : key.shape[2] - dropped], value[:, :, : value.shape[2] - dropped]) 415 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.
456at = (cand_ids == draft).nonzero().flatten() if draft is not None else [] 457if len(at) and float(torch.rand(())) < float(cand_probs[at[0]]): 458 pick = int(at[0]) 459else: 460 if len(at): # the guess was turned down: draw from the other candidates 461 weights = cand_probs.clone() 462 weights[at[0]] = 0 463 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.
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 are about the local one; behind the same interface there can be a vLLM server instead.
The prefill, the decoding loop, the sampling and MTP in the sections above are all LocalLLM in simple_backend.py. Yet the harness and the page never ask for LocalLLM; they use the model only through a Backend (harness/backends/backend.py:63): 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.
Protocol is only a type: LocalLLM need not inherit from it, and having these methods and attributes is enough. The tests check that LocalLLM, VLLMBackend and the tests' FakeLLM are each a Backend.63class Backend(Protocol): 64 """What runs the model. The page holds one at a time (app.page.current_llm) and runs one turn on it at a time.""" 65 66 model_id: str 67 family: families.Family # how the model writes its turns, thinking and tool calls (families/) 68 modalities: tuple[str, ...] # what it reads besides text: "image", "audio", "video" 69 context_length: int # the most tokens it reads at once 70 info: dict # what the Model tab shows, and in "defaults" the recommended temperature, top_p and top_k 71 drafter_id: str | None # the MTP drafter that goes with the model (families.DRAFTERS), if any 72 drafter: object | None # that drafter once load_drafter() has loaded it; Sampling.mtp turns it on only then 73 74 def warmup(self) -> None: 75 """Pay what the first question would otherwise wait for, such as compiling kernels.""" 76 77 def load_drafter(self) -> None: 78 """Load the MTP drafter, if the model has one and it is not loaded yet.""" 79 80 def render(self, messages: list[dict], tools: list[dict] | None, thinking: bool, generation_prompt: bool = True) -> str: 81 """The chat template's string for the messages and tool schemas, which is what the model reads; with 82 `generation_prompt` it also opens the model's turn. The harness finds what is new in a step by comparing 83 this string with the last step's, so it has to be the prompt itself, not something like it.""" 84 85 def encode(self, prompt: str, parts: list[dict]) -> Encoded: 86 """The prompt as the model reads it, with each image, clip or video in `parts` (media.parts) expanded into 87 the tokens it takes.""" 88 89 def generate(self, prompt: Encoded, sampling: Sampling, should_stop: Callable[[], bool]) -> Iterator[dict]: 90 """Generate the model's turn, asking should_stop() before every token. It yields, in order: 91 92 - {"type": "prefill", "n_tokens", "ms"}: the prompt has been read, in `ms` milliseconds; 93 - {"type": "token", ...} for each token, with TOKEN_FIELDS and the TOKEN_DETAILS the backend knows; 94 - {"type": "done", "finish", "stop_piece", "n_tokens", "decode_ms", "tok_per_s", "mtp", "raw"}: `finish` 95 is "stop_token", "max_new_tokens" or "user_stopped"; `stop_piece` is the stop token's piece; `mtp` is 96 {"drafted", "accepted", "forwards"} with MTP on (and "kept_at", "turned_down_at" from a backend that 97 learns the guesses only then), else None; and `raw` is the whole output, special tokens included, which 98 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:135). From then on current_llm(), the sidebar and Run (§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.
48TOKEN_FIELDS = ("index", "id", "piece", "text", "special", "channel") 49# What a token event carries only when the backend knows it: 50# p, logprob the token's probability in the model's own distribution (temperature 1, nothing cut off), and its log 51# rank the token's place in that distribution, 1 for the most likely 52# p_sampled its probability in the distribution it was drawn from, after temperature, top-k and top-p 53# kept how many candidates top-k and top-p left 54# top the 5 most likely tokens, each with id, piece, p, and kept (whether top-k and top-p left it) when known 55# draft with MTP, what the drafter guessed here and whether the model kept it; absent where it guessed nothing. 56# A backend that learns only at the end of a step which tokens were guesses (vllm_backend.py) says so in 57# done's mtp instead (kept_at, turned_down_at), and render.py marks those tokens; it may not know what a 58# turned-down guess was (piece None). 59TOKEN_DETAILS = ("p", "logprob", "rank", "p_sampled", "kept", "top", "draft")
The page draws what it gets (app/render.py:698): 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:672). The page would rather show less than fill in a number from something else.
The three backends:
LocalLLM(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 ofTOKEN_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 equalsgenerate()'s; it reads the images, sound and video the checkpoint has encoders for.VLLMBackend(vllm_backend.py): the weights are on a vLLM server. Each token comes back with its log probability and those of the most likely tokens; the rest depends on how the server was started (below), and the media the model reads are those the checkpoint has encoders for and the server takes (harness/backends/vllm_backend.py:326).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 can be any one at VLLM_URL, or one the page starts itself: without VLLM_URL, when the sidebar's What runs the model picks vLLM, harness/backends/vllm_docker.py starts it with Docker Compose from docker-compose/compose.yaml (harness/backends/vllm_docker.py:170). 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. 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 (harness/backends/vllm_docker.py:132), 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:140); picking transformers again, or quitting the app, stops the container (harness/backends/vllm_docker.py:237, harness/backends/vllm_docker.py:249).
VLLMBackend's prompt is just as exact. It downloads every file of the checkpoint but the weights into models/, at the same commit REVISIONS pins (harness/backends/vllm_backend.py:145), so render() and encode() use the very tokenizer and chat template LocalLLM does. What goes to the server is token ids, not text, so the server reads exactly the string in the Context tab (§6 “Context: rendering the whole conversation into one string”). 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.
538def _sampling_params(self, prompt: Encoded, s: Sampling) -> dict: 539 """Each sidebar setting, and every other sampling setting set to do nothing, so that a default the server took 540 from the checkpoint (a repetition penalty, say) cannot change the distribution the page shows.""" 541 return { 542 "max_tokens": max(1, min(s.max_new_tokens, self.context_length - prompt.n_tokens)), # vLLM refuses a request that cannot fit 543 "temperature": max(s.temperature, 0.0), # 0 is greedy there as here 544 "top_p": s.top_p, 545 "top_k": s.top_k, # 0 is no limit there as here 546 "min_p": 0.0, 547 "repetition_penalty": 1.0, 548 "presence_penalty": 0.0, 549 "frequency_penalty": 0.0, 550 "stop_token_ids": sorted(self.eos_ids), 551 }
The server streams the tokens back as server-sent events. 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 LocalLLM's p.
598def _details(self, token_id: int, logprobs: dict, n: int, s: Sampling) -> dict: 599 """What /v1/completions said about the n-th token of a chunk: its log probability, and those of the 5 most likely 600 tokens, keyed "token_id:<id>". The token's rank is known only when it is one of those 5.""" 601 token_logprobs, top_logprobs = logprobs.get("token_logprobs") or [], logprobs.get("top_logprobs") or [] 602 if n >= len(token_logprobs) or token_logprobs[n] is None: 603 return {} 604 logprob = token_logprobs[n] 605 listed = top_logprobs[n] if n < len(top_logprobs) and top_logprobs[n] else {} 606 # The chosen token is listed too, even when it is not among the 5. 607 listed = {int(k.removeprefix("token_id:")): v for k, v in listed.items() if k.startswith("token_id:")} 608 top = sorted(listed.items(), key=lambda c: (-c[1], c[0]))[:TOP_N] 609 rank = 1 + sum(v > logprob for _, v in top) if any(i == token_id for i, _ in top) else None 610 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 (harness/backends/vllm_backend.py:231). 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.
664def _token_details(self, token_id: int, logprob: float, listed: dict[int, float], rank: int | None, 665 mask: list[int] | None, s: Sampling) -> dict: 666 """The details of a token event, from what the server said about the token: its log probability, those of the 667 tokens listed with it, its rank and its sampling mask when it sent them. What the numbers may be shown as 668 depends on the server's --logprobs-mode. Only the model's own log probabilities are p, logprob and the top 5. 669 Processed ones are the distribution the token was drawn from, so the token's is its sampling probability, and 670 the tokens top-k and top-p cut come back as UNREPORTED. Logits are neither.""" 671 details = {"rank": rank} if rank else {} 672 if self.logprobs_mode == "raw_logprobs": 673 # Equally likely tokens go lowest id first, as in LocalLLM. 674 ranked = sorted(listed.items(), key=lambda c: (-c[1], c[0])) 675 if rank is not None and rank > TOP_N: # listed only because it was chosen 676 ranked = [c for c in ranked if c[0] != token_id] 677 top = [{"id": i, "piece": self.piece(i), "p": math.exp(v)} for i, v in ranked[:TOP_N]] 678 details.update(p=math.exp(logprob), logprob=logprob, top=top) 679 elif self.logprobs_mode == "processed_logprobs": 680 details["p_sampled"] = math.exp(logprob) 681 if mask is not None: 682 details["kept"] = len(set(mask)) 683 elif self.logprobs_mode.startswith("processed") and any(v <= UNREPORTED for v in listed.values()): 684 # Some of the most likely tokens were cut, and those left are more likely still, so all of them are listed. 685 details["kept"] = sum(v > UNREPORTED for v in listed.values()) 686 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 whether it takes each kind of media the checkpoint reads, writing down why not for those it refuses (harness/backends/vllm_backend.py:387). 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
LocalLLM. --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-outshows no mask, so at load it is also asked for a greedy token, and a refusal says it has masks on (harness/backends/vllm_backend.py:372). Greedy decoding needs no server: one candidate left, drawn with probability 1 (harness/backends/vllm_backend.py:688).- Images, sound and video: they go next to the placeholders the chat template wrote (an image upright as PNG, sound decoded to WAV, a video as the file,
harness/backends/vllm_backend.py:202), and the server expands each placeholder into the file's tokens.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 (§7 “Images, sound and video: tokens in the context”); a different expansion at generation time is an error.
449def encode(self, prompt: str, parts: list[dict]) -> Encoded: 450 """The prompt's token ids, which are what the server reads. With media (`parts`, in the order of their 451 placeholders), the server expands each placeholder into the tokens its file takes, so it is asked how first, with 452 a one-token request; the text and the count are of the prompt as the server reads it, and the ids stay the ones 453 the chat template wrote, which generate() sends again with the files.""" 454 ids = self.tokenizer(prompt, add_special_tokens=False).input_ids 455 if not parts: 456 return Encoded(prompt, ids, len(ids)) 457 if unread := {p["type"] for p in parts} - set(self.modalities): # media.save makes those plain files, so this is a bug 458 raise ValueError(f"The vLLM server takes no {', '.join(sorted(unread))} here, only {', '.join(self.modalities) or 'text'}.") 459 files = [content_part(p, self.audio_rate) for p in parts] 460 expanded = self._generate_once(ids, files, return_token_ids=True)["prompt_token_ids"] 461 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) 462 return Encoded(self.tokenizer.decode(expanded, skip_special_tokens=False), ids, len(expanded), 463 {"content_parts": files, "prompt_token_ids": expanded}, dict(counts))
Speculative decoding is decided when the server starts (--speculative-config). A server at VLLM_URL decides for itself, and the sidebar's MTP switch is disabled. vLLM (Docker) on a GPU takes the model's setting in families.VLLM_SPECULATIVE (harness/families/__init__.py:39): Gemma 4 with its drafter, 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 (harness/backends/vllm_docker.py:116). vLLM 0.32's CPU image cannot decode either model speculatively, so on a CPU the 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, 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:354), and what a turned-down guess was, the server does not say.
243def drafted_tokens(accepted: list[int], drafted: list[int], n_tokens: int) -> dict | None: 244 """Which of a step's tokens were guesses the model kept (kept_at) and which replace a guess it turned down 245 (turned_down_at), from vLLM's per-step counts (--per-request-spec-decode-metrics detailed). The first token comes 246 from the prefill; after it, each verify step gives the guesses it kept and then one token of the model's own, 247 which replaces the next guess when it kept fewer than were drafted. vLLM does not count a step without guesses, 248 so when the steps do not account for every token, nobody can tell which tokens were guessed: None.""" 249 kept, turned_down, at = [], [], 1 250 for n_kept, n_drafted in zip(accepted, drafted): 251 kept += range(at, at + n_kept) 252 if n_kept < n_drafted: 253 turned_down.append(at + n_kept) 254 at += n_kept + 1 255 last = accepted[-1] + 1 if accepted else 1 256 if not at - last < n_tokens <= at: # only a stop token or the token limit may cut the last step short 257 return None 258 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]}
What else differs on the page:
- Prefill is the time to the first token, the network included. 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;
LocalLLMreads the whole prompt from the start every step (§8 “Model: prefill, then one token at a time”). - Greedy output need not match
generate()token for token: vLLM batches requests and has kernels of its own. Matchinggenerate()exactly isLocalLLM's promise only. - 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 (
app/render.py:1038), and above the input box the page says which cannot be attached. The model folder lists the weights as on the server only (§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.
Harness
Harness: splitting the output into thinking, tool calls and the reply
The model only writes text; which part is a tool call, the harness reads with the family's parser.
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.
144prefix = family.thought_prefix if prompt.endswith(family.thought_prefix) else "" 145parsed = family.parse(prefix + raw, schemas, s.lang) 146calls = parsed["tool_calls"] if tools else [] 147self.emit("parsed", step=step, thinking=parsed["thinking"], content=parsed["content"], tool_calls=calls)
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: (1) cut out the thinking between <|channel>thought and <channel|>; (2) find every <|tool_call>call:name{…}<tool_call|> with a regular expression (harness/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.
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, and anything else is a number, true / false or null.
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: 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.
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()}
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
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.
149if done["finish"] == "user_stopped": 150 stop_reason, final = "user_stopped", parsed["content"] 151 self.messages.append({"role": "assistant", "content": final}) 152 break 153if not calls: 154 stop_reason = "max_new_tokens" if done["finish"] == "max_new_tokens" else "no_tool_call" 155 final = parsed["content"] 156 self.messages.append({"role": "assistant", "content": final}) 157 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_stoppedorapproval_timeout(harness/agent.py:187). MAX_FORMAT_ERRORS(3) tool calls in a row that cannot be parsed →format_errors.- Any exception from the model or a tool →
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.
run_end always goes out last, with the stop reason, the final reply and the whole message list.189 if format_errors >= MAX_FORMAT_ERRORS: 190 stop_reason = "format_errors" 191 break 192except Exception as e: # surface model or tool crashes in the UI instead of a silent hang 193 # A backend's error says what went wrong in both languages (vllm_backend.ServerError), and the page shows 194 # the one it is in; other errors are Python's own, in English. 195 both = getattr(e, "text", None) 196 texts = {"text": {lang: f"{type(e).__name__}: {t}" for lang, t in both.items()}} if isinstance(both, dict) else {} 197 self.emit("error", message=f"{type(e).__name__}: {e}", **texts) 198 stop_reason = "error" 199 200self.emit("run_end", stop_reason=stop_reason, final=final, messages=self.messages)
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>.
The two families differ in two ways here, both decided by the family: text the model writes before a tool call is kept by Qwen's template, while Gemma's moves it after the tool results and closes the turn there, so Gemma's is dropped (text_with_calls); and the thinking has to go in the field the template reads it from, reasoning for Gemma and reasoning_content for Qwen (reasoning_key).
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).
159ids = [f"call_{step}_{i}" for i in range(len(calls))] 160assistant = { 161 "role": "assistant", 162 # Some templates cannot keep text in front of a tool call (see Family.text_with_calls). 163 "content": parsed["content"] if family.text_with_calls else "", 164 "tool_calls": [ 165 {"id": cid, "type": "function", "function": {"name": c["name"], "arguments": c["arguments"] or {}}} 166 for cid, c in zip(ids, calls) 167 ], 168} 169if parsed["thinking"]: 170 assistant[family.reasoning_key] = parsed["thinking"] 171self.messages.append(assistant) 172# The template rewrites the model's turn when it re-renders history (sorted arguments, 173# its own thought markup), so compare the next prompt with this rendering, not prompt + raw. 174prev_sequence = self.llm.render([system, *self.messages], tools, s.thinking, generation_prompt=False)
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.
202def _handle_call(self, step: int, cid: str, call: dict, seen: list) -> str: 203 """Validate, approve and execute one tool call. Returns the observation text for the model.""" 204 lang = self.settings.lang 205 tool = self.tools.get(call["name"]) 206 args, errors = call["arguments"] or {}, [] 207 if call["error"]: 208 errors = [pick(lang, f"無法解析工具呼叫:{call['error']}", f"Could not parse the tool call: {call['error']}")] 209 elif tool is None: 210 known = ", ".join(self.tools) 211 errors = [pick(lang, f"沒有 {call['name']} 這個工具,可用的工具是 {known}", f"There is no tool {call['name']}; the tools are {known}")] 212 else: 213 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.
89def validate(self, args: dict, lang: str = "zh") -> tuple[dict, list[str]]: 90 """Check arguments against the schema; return them with defaults filled in, plus any errors in `lang`.""" 91 props = self.parameters.get("properties", {}) 92 errors = [pick(lang, f"缺少必要參數 {k}", f"Missing required argument {k}") for k in self.parameters.get("required", []) if k not in args] 93 for key, value in args.items(): 94 spec = props.get(key) 95 if spec is None: 96 errors.append(pick(lang, f"沒有 {key} 這個參數", f"There is no argument {key}")) 97 elif not TYPE_CHECKS[spec["type"]](value): 98 got = type(value).__name__ 99 errors.append(pick(lang, f"{key} 應該是 {spec['type']},收到 {got}", f"{key} should be {spec['type']}, got {got}")) 100 elif "enum" in spec and value not in spec["enum"]: 101 errors.append(pick(lang, f"{key} 只能是 {spec['enum']}", f"{key} must be one of {spec['enum']}")) 102 filled = {k: spec["default"] for k, spec in props.items() if "default" in spec} 103 filled.update(args) 104 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.
215key = (call["name"], json.dumps(args, sort_keys=True, ensure_ascii=False)) 216repeated = key in seen 217seen.append(key) 218self.emit( 219 "tool_call", 220 step=step, 221 call_id=cid, 222 name=call["name"], 223 arguments=args, 224 raw_args=call["raw_args"], 225 errors=errors, 226 repeated=repeated, 227 environment=tool.environment if tool else None, 228) 229if errors: 230 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.
232if self.settings.approve_tools: 233 self.emit("approval_request", step=step, call_id=cid, name=call["name"], arguments=args) 234 approved = self._wait_for_decision(cid) 235 # Nobody answered when the wait timed out or the run was stopped; only then is it not a rejection. 236 stopped = cid not in self._decisions and not self._timed_out 237 self.emit("approval", step=step, call_id=cid, approved=approved, timed_out=self._timed_out, stopped=stopped) 238 if not approved: 239 if self._timed_out: 240 error = pick(lang, "等待使用者確認逾時,這個工具呼叫沒有執行", "No one confirmed the tool call in time, so it did not run") 241 elif stopped: 242 error = pick(lang, "使用者停止了這次執行,這個工具呼叫沒有執行", "The user stopped this run, so the tool call did not run") 243 else: 244 error = pick(lang, "使用者拒絕執行這個工具呼叫", "The user refused to run this tool call") 245 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 (harness/agent.py:33, 5 minutes). A timeout counts as a rejection and stops the whole turn, so a closed tab cannot hold on to the model.
267def _wait_for_decision(self, cid: str) -> bool: 268 deadline = time.monotonic() + APPROVAL_TIMEOUT 269 with self._decided: 270 while cid not in self._decisions and not self._stop.is_set(): 271 left = deadline - time.monotonic() 272 if left <= 0: # treat as rejected and end the run 273 self._timed_out = True 274 self._stop.set() 275 break 276 self._decided.wait(timeout=min(left, 0.5)) 277 return self._decisions.get(cid, False)
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.
671for call in render.pending_approvals(data): 672 with st.container(border=True): 673 # An argument over several lines (code) is shown below the call rather than squeezed into its JSON. 674 blocks = {k: v for k, v in call["arguments"].items() if isinstance(v, str) and "\n" in v.strip()} 675 args = json.dumps({k: "…" if k in blocks else v for k, v in call["arguments"].items()}, ensure_ascii=False) 676 st.markdown(tr("**Agent 想執行這個工具,要允許嗎?**", "**The agent wants to run this tool. Allow it?**") + f" \n`{call['name']}({args})`") 677 for code in blocks.values(): 678 st.code(code) 679 allow, reject = st.columns(2) 680 allow.button(tr("允許", "Allow"), key=f"allow-{i}-{call['call_id']}", type="primary", width="stretch", 681 on_click=run.run.decide, args=(call["call_id"], True)) 682 reject.button(tr("拒絕", "Reject"), key=f"reject-{i}-{call['call_id']}", width="stretch", 683 on_click=run.run.decide, args=(call["call_id"], False))
Environment
Environment: really running the tool
Only after the three checks is the Python function called; 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": …}.
247self.emit("tool_start", step=step, call_id=cid, name=call["name"], environment=tool.environment) 248start = time.perf_counter() 249try: 250 result, ok = tool.fn(**args, **({"files": self.files} if tool.takes_files else {})), True 251except ToolError as e: 252 result, ok = {"error": e.text[lang]}, False 253except Exception as e: 254 result, ok = {"error": f"{type(e).__name__}: {e}"}, False 255return 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 (harness/tools.py:179).calculator: computes over an AST allowlist and never callseval(). 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 ofround, which computes a power inside, so an expression like101010cannot hang the thread.search_wikipedia/read_wikipedia: call the Wikipedia API with httpx (harness/tools.py:197), the same pair of actions, search and read, that the ReAct paper used on HotpotQA.run_python: runs the code the model writes in a Docker container and returns what it prints and the charts it draws (harness/tools.py:305).
ToolError at any node outside the allowlist.128def _eval(node): 129 if isinstance(node, ast.Expression): 130 return _eval(node.body) 131 if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool): 132 return node.value 133 if isinstance(node, ast.Name) and node.id in CONSTS: 134 return CONSTS[node.id] 135 if isinstance(node, ast.BinOp) and type(node.op) in BIN_OPS: 136 left, right = _eval(node.left), _eval(node.right) 137 # Check the size before computing: ((10**999)**999)**999 would otherwise hang. 138 if isinstance(node.op, ast.Pow) and right > 0 and _digits(left) * right > MAX_DIGITS: 139 raise ToolError(f"結果超過 {MAX_DIGITS} 位數", f"The result has more than {MAX_DIGITS} digits") 140 return BIN_OPS[type(node.op)](left, right) 141 if isinstance(node, ast.UnaryOp) and type(node.op) in UNARY_OPS: 142 return UNARY_OPS[type(node.op)](_eval(node.operand)) 143 if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in FUNCS and not node.keywords: 144 args = [_eval(a) for a in node.args] 145 if node.func.id == "factorial" and args and args[0] > 500: 146 raise ToolError("factorial 的參數太大", "The argument of factorial is too large") 147 # round(5, -10**9) computes 10 ** 10**9 inside int.__round__, where the check on powers above cannot see it. 148 if node.func.id == "round" and len(args) > 1 and abs(args[1]) > MAX_DIGITS: 149 raise ToolError("round 的位數參數太大", "The digits argument of round is too large") 150 return FUNCS[node.func.id](*args) 151 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 harness/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 (harness/tools.py:233: 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.
242def sandbox_command(name: str, files: Path | None) -> list[str]: 243 """docker run for one piece of code, read from stdin. The container has no network, a read-only file system 244 except a small /tmp, a cap on memory, CPU and processes, no Linux capabilities, the host's own user (never root), 245 and the conversation's files read-only at /data; --rm deletes it afterwards. Its charts come out on stdout, 246 after FIGURES_MARK.""" 247 cmd = [ 248 "docker", "run", "--rm", "-i", "--name", name, "--network", "none", 249 "--memory", "512m", "--memory-swap", "512m", "--cpus", "1", "--pids-limit", "64", 250 "--read-only", "--tmpfs", "/tmp:rw,size=64m", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", 251 "--user", sandbox_user(), "-e", "HOME=/tmp", "-e", f"AGENT_LOOP_FIGURES={FIGURES_MARK}", "-w", "/tmp", 252 ] 253 if files is not None: 254 cmd += ["-v", f"{Path(files).resolve()}:/data:ro"] 255 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 (harness/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… (harness/tools.py:282) 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 (harness/tools.py:258); if it is not installed, not running, or the image is not built or is older than harness/sandbox/Dockerfile (going by its agent-loop-sandbox.version label), the sidebar says what to do, and the turn goes on without run_python.
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.
257def _observe(self, step: int, cid: str, name: str, result: dict | ToolResult, ok: bool, ms: float) -> str: 258 # The model reads a ToolResult's data; its images (run_python's charts) are only for the page to show. 259 result, images = (result.data, result.images) if isinstance(result, ToolResult) else (result, []) 260 text = json.dumps(result, ensure_ascii=False) 261 truncated = len(text) > MAX_RESULT_CHARS 262 if truncated: 263 text = text[:MAX_RESULT_CHARS] + "…(truncated)" 264 self.emit("tool_result", step=step, call_id=cid, name=name, ok=ok, result=text, truncated=truncated, ms=round(ms, 1), images=images) 265 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.
176for cid, call in zip(ids, calls): 177 result = self._handle_call(step, cid, call, seen) 178 if call["error"]: 179 format_errors += 1 180 else: 181 format_errors = 0 182 self.messages.append({"role": "tool", "tool_call_id": cid, "name": call["name"], "content": result}) 183 if self._stop.is_set(): 184 break 185 186if self._stop.is_set(): 187 stop_reason = "approval_timeout" if self._timed_out else "user_stopped" 188 break 189if format_errors >= MAX_FORMAT_ERRORS: 190 stop_reason = "format_errors" 191 break
Then it is back to the top of the for loop, and when the next step renders, 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:
- Gemma 4:
<|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. - Qwen3.5:
<|im_start|>userfollowed by<tool_response>…</tool_response>, so the result is a user message; a new<|im_start|>assistantfollows, 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.
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.
801@st.fragment(run_every=POLL_SECONDS if polling else None) 802def workspace(): 803 if (llm := model()) is None: # let go meanwhile: the full rerun that follows draws the next one 804 st.rerun() 805 absorb_finished() 806 live_run = next((run for run in st.session_state.runs if run.live), None) 807 other = other_tab_run() 808 if polling and live_run is None and other is None: 809 st.rerun() # the run just ended: redraw everything once and stop polling 810 with st.container(key="workspace"): 811 chat, viz = st.columns([5, 7], gap="large") 812 with chat: 813 chat_panel(llm, live_run, other) 814 with viz: 815 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, model_end marks the tokens that were guesses when a server says so only then (§11 “What runs the model: the Backend interface, transformers here or a vLLM server”), 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.
306def build(events: list[dict]) -> dict: 307 data = {"model": None, "family": None, "settings": {}, "steps": [], "end": None, "error": None, "last": None, "t": 0} 308 for ev in events: 309 kind = ev["type"] 310 data["last"], data["t"] = kind, ev["t"] 311 if kind == "run_start": 312 data["model"], data["family"] = ev["model"], families.get(ev["family"]) 313 data["settings"] = ev["settings"] 314 elif kind == "step_start": 315 data["steps"].append({ 316 "n": ev["step"], "t0": ev["t"], "t1": ev["t"], "context": None, "prefill": None, 317 "tokens": [], "done": None, "parsed": None, "calls": {}, 318 }) 319 elif kind == "run_end": 320 data["end"] = ev 321 elif kind == "error": 322 data["error"] = ev.get("text") or ev["message"] 323 elif data["steps"]: 324 step = data["steps"][-1] 325 step["t1"] = ev["t"] 326 if kind == "token": 327 step["tokens"].append(ev) 328 elif kind in ("context", "prefill", "parsed"): 329 step[kind] = ev 330 elif kind == "model_end": 331 step["done"] = ev 332 step["tokens"] = with_drafts(step["tokens"], ev.get("mtp") or {}) 333 elif kind == "tool_call": 334 step["calls"][ev["call_id"]] = {**ev, "approval": None, "started": False, "result": None} 335 elif kind == "approval_request": 336 step["calls"][ev["call_id"]]["approval"] = "pending" 337 elif kind == "approval": 338 if ev["approved"]: 339 approval = "approved" 340 elif ev.get("timed_out"): 341 approval = "timeout" 342 elif ev.get("stopped"): 343 approval = "stopped" 344 else: 345 approval = "rejected" 346 step["calls"][ev["call_id"]]["approval"] = approval 347 elif kind == "tool_start": 348 step["calls"][ev["call_id"]]["started"] = True 349 elif kind == "tool_result": 350 step["calls"][ev["call_id"]]["result"] = ev 351 return data
Which box of the loop diagram lights up and which arrow flows is decided by the last event:
506state["active"], state["edge"] = { 507 "step_start": ("context", "env-context" if step and step["n"] > 1 else None), 508 "context": ("context", "context-model"), 509 "prefill": ("model", "context-model"), 510 "token": ("model", None), 511 "model_end": ("harness", "model-harness"), 512 "parsed": ("harness", "model-harness"), 513 "tool_call": ("harness", None), 514 "approval_request": ("harness", None), 515 "approval": ("harness", None), 516 "tool_start": ("env", "harness-env"), 517 "tool_result": ("env", "env-context") if started else ("harness", None), 518 "run_end": ("stop", "harness-stop"), 519 "error": ("stop", None), 520}.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.
348def absorb_finished(): 349 """When a run ends, its full message list becomes the history for the next turn.""" 350 for run in st.session_state.runs: 351 if not run.live and not run.absorbed: 352 end = next((e for e in reversed(run.events) if e["type"] == "run_end"), None) 353 if end: 354 st.session_state.history = end["messages"] 355 run.absorbed = True
The stop button calls Run.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.
83def stop(self): 84 self._stop.set() 85 with self._decided: 86 self._decided.notify_all() 87 88def decide(self, call_id: str, approved: bool): 89 with self._decided: 90 self._decisions[call_id] = approved 91 self._decided.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.
| Event | Sent from | Carries | What the page does with it |
|---|---|---|---|
run_start | harness/agent.py:105 | the model and family, the turn's settings, the available tools | whose format to draw the turn in; the step limit, the decoding notes |
step_start | harness/agent.py:116 | the step number | opens a new step card; Context lights up in the loop diagram |
context | harness/agent.py:123 | the 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 role | the step card's context section, the Context tab |
prefill | harness/backends/simple_backend.py:301 | the prompt's token count, the prefill time | the Model box in the loop diagram, prefill ms in the Model tab |
token | harness/backends/simple_backend.py:325 | id, 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 allow | the token's heat color, underline and popover; the reply appearing bit by bit on the left |
model_end | harness/agent.py:141 | why 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 |
parsed | harness/agent.py:147 | the thinking, the reply, the tool calls | the Harness section appears |
tool_call | harness/agent.py:219 | the name, the validated arguments, errors, whether it repeats, the environment | the call, the validation result and the repeat warning in the Harness section |
approval_request | harness/agent.py:233 | the call waiting for a confirmation | the Allow and Reject buttons in the chat; the loop diagram pulses |
approval | harness/agent.py:237 | allowed, rejected, timed out or stopped | the Harness section shows your decision |
tool_start | harness/agent.py:247 | the tool name, the environment | Environment lights up in the loop diagram |
tool_result | harness/agent.py:264 | the result string, whether it succeeded, whether it was cut, the time it took, the images for you | the result in the Environment section, the tool chip in the chat; the charts in both |
error | harness/agent.py:197 | the 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_end | harness/agent.py:200 | the stop reason, the final reply, the whole message list | the stop card; the message list becomes the next turn's history |