feat(engine): #429 batch 1 — tool-execution cluster joins the event vocabulary - #433
Merged
Conversation
Slice 1c needs the notices out of agent_tools.zig, and every one of them went through say(), whose routing is not incidental: --json swallows a root's line, a pool-thread child has no writer and goes through the tick gate with a "[label] " prefix and a repair for the cut newline, and a format ending in \n releases held child ticks. A sink holds bytes, not a comptime format, so it cannot call say(). This is the same function with the line-ending test moved from the format to the last byte, and errors swallowed because an emit path has nowhere to return them. say() is left alone: its worker branch formats prefix and payload in ONE print into a fixed slot, and re-deriving that through a temporary would change where an over-long line gets cut. Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
…inks (#422) Eleven variants for the moments agent_tools.zig has been drawing inline: the call bracket (announced/started) and its closing bracket (result/ finished), a pre-run refusal, the parallel-batch tallies, and the meta-tool notices. The pairs exist because one moment is TWO wire lines and each line owns a sequence id — collapsing them would reserve one id and write two. durable() now reads the payload, not just the tag, and it has to: whether a tool moment reaches the wire depends on which tool it is (ask_user's bracket goes out as the `ask_user` event; a meta tool's result never had a wire shape). JsonSink is now defined as "write exactly the durable events" with a panic on the else arm, so a future durable variant cannot reach the wire without a shape and burn its id on nothing (#330). The terminal half lives in agent_tool_render.zig — the one file in the cluster that reaches the palette, like agent_stream_render.zig for the stream. Every function there is the old code path gate for gate, with the two byte-preserving caps (160 arg bytes, 100 preview bytes) named rather than inlined. Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
#422) The engine half keeps only what it owns: which moments happened, and the bookkeeping around them (the batch tally, argStreamedFully's dedup state, isMetaName). Every json/tty fork it used to carry is now a sink's choice, including the two suppressions that used to look symmetric but are not — the wire skips ask_user's bracket, the terminal skips prose that already streamed live. The `!self.sub` guards move into the renderer with the drawing they gated; goal_completed deliberately keeps none, matching the old call site (goalActive() is false for a subagent, so it is root-only anyway). sayToolResult keeps its out == null early-out at the emit site: that one is not presentation, it is "there is no frontend attached". One accepted behavior delta: sayToolUse's writes used to propagate and abort the batch on a failed stdout; a sink's emit returns void, so they are swallowed now, like every other converted emission since slice 1a. term.zig stays imported. Its use here is raw/nonblocking stdin for the Esc watcher, which is frontend INPUT and belongs to #430 — the same carve-out #429 already makes for agent_ws.zig. Proven by the eval harness: all 9 golden files byte identical, including the permission-prompt PTY golden whose ⚙/prompt/✓ interleaving is exactly what this touches. exec.zig, tools.zig and edit_verify.zig needed no change (every write there builds the tool RESULT, never the terminal), and agent_tool_gate.zig's lines are all prompt-block text, held for #430. Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
#330) EngineSink.emit stamps BEFORE dispatch, so a durable sink handed an event it will drop burns a #330 sequence id on nothing. jsonEmit drops everything when out == null, and slice 1c moved two emitters (sayToolUse, emitToolRejected) ahead of any writer check — so in `graff --json` every subagent tool call advanced the SHARED counter by two with no wire line, and in `graff acp` (json_mode on, root.out nulled) the root did the same for a whole session, inflating the persisted event_seq. A supervisor reads that jump as lost data, which is the one thing protocol_seq promises cannot happen. Fixed where the rule can be stated once instead of re-derived per call site: jsonSink hands a NON-durable vtable to an agent with no writer. Same emitter, same (absent) output, no reservation — and no g_gui_mu round trip per pool-thread tool call either. Not fixed by guarding the two emitters, which was the obvious move and is wrong: on main sayToolUse's TUI branch went through say(), whose out == null path is the pool-thread WORKER LINE, so an early return there would have deleted every subagent's ⚙ line from the terminal. sayToolResult's own guard stays: main had it too. Also, per review of the slice: the four `!self.sub` gates go back to their emit sites. Who may announce a fan-out or a meta notice is engine policy about who owns the terminal, not a drawing decision; keeping it in agent_tool_render meant the renderer back-read Agent state (widening the debt engine_sink.zig's header says to shrink) and, worse, meant a future serve/attach sink would start receiving subagent tallies the engine never used to produce, with no field in the payload to tell them apart. Two test tightenings from the same review: the cap assertions now pin the literals 160/100 the pre-#422 inline path spelled out rather than the new constants (a test that compares the code to itself cannot guard a conversion), and the 100-byte result cap gets its first exercise. Proven: new engine_sink test asserts protocol_seq.current() == 0 after a full tool bracket + rejection + text delta on a writerless agent (it read 6 before). 1003/1003 tests pass and all 9 eval goldens are byte identical. Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Batch 1 of #429 (tool-execution cluster, output side), plus the two new experiment gates from the detachment cluster.
The inventory finding
Of the five in-scope files, only
agent_tools.zigactually emits to the terminal.exec.zig/tools.zig/edit_verify.zigbuild tool result text on pool threads (no writer) and carry zero terminal-cluster imports;agent_tool_gate.zig's eight print sites are all parts of the interactive permission prompt — #430's territory per the hard exclusion, untouched. So #429's remaining work list is shorter than the import scan suggested.What landed
tool_call_announced/started,tool_result/finished,tool_rejected, parallel-batch brackets,completion_deferred,goal_completed,completion_text,todo_list_updated). Bracket pairs exist because one moment is two wire lines and each line owns a sequence id.durable()now reads the payload, not just the tag (ask_user's bracket isn't durable), lettingjsonEmitbe defined as "write exactly the durable events" with@panicon the else arm — a future durable variant can't reach the wire shapeless and burn its id (Embedder mode: run the harness outside the sandbox (hard no-local-tools gate, MCP-sourced coding tools, resumable serve/--json stream) #330). The review pass hardened this further: a frontendless agent's wire sink reserves no sequence ids.agent_tool_render.zig(TuiSink's half — the only cluster file that touches the palette),agent_output.sayText(say()'s runtime-string sibling),agent_tools.zigloses itsansiimport; its remainingtermuse is the Esc-watcher's raw stdin — the same input carve-out asagent_ws(Input inversion (Phase 1b): approvals, pickers, mid-turn asks, Esc ownership, readline queries #430).sayToolUse's stdout failures are swallowed by the sink like every conversion since slice 1a.New experiment gates (in the eval harness)
run_evals.sh(harness is now 9 golden files).check_latency.shwith flake-proof thresholds. This branch: p50 improved 0.91→0.44ms, p90 0.55ms vs 5.5ms limit, p99 14.9 vs 73.7 — no regression, huge headroom.Evidence
All 9 eval files byte-identical (including the new permission-prompt golden), tests 994 → 1003 (delta = exactly the new tests, incl. a transitive-reachability proof for the new render module), each commit builds independently, fmt clean, every file ≤600 (
agent_tools590,engine_sink483).Part of #422 / #429. Bound by #428.