Skip to content

feat(engine): #429 batch 1 — tool-execution cluster joins the event vocabulary - #433

Merged
justrach merged 4 commits into
mainfrom
feat/422-engine-slice1c
Aug 6, 2026
Merged

feat(engine): #429 batch 1 — tool-execution cluster joins the event vocabulary#433
justrach merged 4 commits into
mainfrom
feat/422-engine-slice1c

Conversation

@justrach

@justrach justrach commented Aug 6, 2026

Copy link
Copy Markdown
Owner

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.zig actually emits to the terminal. exec.zig/tools.zig/edit_verify.zig build 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

  • 11 new event variants for the tool lifecycle (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), letting jsonEmit be defined as "write exactly the durable events" with @panic on 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.
  • New 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.zig loses its ansi import; its remaining term use is the Esc-watcher's raw stdin — the same input carve-out as agent_ws (Input inversion (Phase 1b): approvals, pickers, mid-turn asks, Esc ownership, readline queries #430).
  • One documented behavior delta: sayToolUse's stdout failures are swallowed by the sink like every conversion since slice 1a.

New experiment gates (in the eval harness)

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_tools 590, engine_sink 483).

Part of #422 / #429. Bound by #428.

justrach and others added 4 commits August 6, 2026 12:39
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>
@justrach
justrach merged commit 87630b0 into main Aug 6, 2026
6 checks passed
justrach added a commit that referenced this pull request Aug 6, 2026
integration: the prime-adoption batch + engine slice 1c (merges #433 #434 #435 #436 #437 #438)
@justrach
justrach deleted the feat/422-engine-slice1c branch August 6, 2026 09:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant