Skip to content

A tool call's whole life on the wire - #936

Open
sroussey wants to merge 1 commit into
mainfrom
claude/agent-review-migration-8elz3s
Open

A tool call's whole life on the wire#936
sroussey wants to merge 1 commit into
mainfrom
claude/agent-review-migration-8elz3s

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

A host drawing a card for one tool call could only reconstruct it by diffing successive copies of the conversation AgentTask publishes. The ask appears when the assistant message lands, and the outcome when the results land — as one batch, after the last of them, with nothing in between and no way to tell which call is running now.

Each call now reports where it has got to, as a tool-call stream event:

pending the model asked for it, carrying the arguments
running the loop took the call up
completed / failed settled, carrying the text the model reads back

Why this was not missed earlier

Builder's tools are closures it owns, so its adapter fills the card in from inside the tool. That works exactly as far as the host owning every tool it passes. A host whose tools are task types it named has nothing to fill a card in from, and neither does one relaying a turn to a protocol — where a per-call lifecycle is the shape on the other side of the wire, not an extra.

Three decisions worth a reviewer's attention

The settled text is read back off the result block, not built a second time. What the card says the call produced is then the same string the model is about to read, clamp included, and cannot drift from it. Rebuilding it instead fails three tests.

Every call passes all three states — including the ones nothing executes for. An unknown tool, arguments its schema rejects, a call nobody approves: all of them go pending → running → failed. A host then draws one lifecycle rather than one per way a call can end. running covers waiting on a person for the same reason: an approval is part of making the call, and a host drawing its own approval already knows it asked.

Metadata, on phase's exact terms — emitted on stream_chunk, never accumulated into a port, absent from finish, no status flip. A task is not streaming its output because something it called started. StreamEvent gains a variant; nothing switches exhaustively over that union, so the change is additive.

All of a round's calls are announced before any of them runs, since the model asked for them together and a host can lay out the whole set rather than watching cards appear in an order that is the loop's business rather than the model's.

Verification

  • bun run build:types — 43/43
  • bun run lint (--type-aware --deny-warnings), bun run format-check — clean
  • packages/test/src/test/ai + packages/ai/src1,808 passed
  • packages/test/src/test/graph + packages/task-graph/src1,036 passed (this touches core stream types, so that section ran too)
  • AgentTask.test.ts — 30 passed (25 existing, 5 new)

Each new test fails when the code it covers is broken, and only those — checked by breaking each in turn: disabling the StreamProcessor case fails 5, collapsing failed into completed fails 3, moving the pending emission inside the run loop fails exactly the ordering test, and rebuilding the result text instead of reading the block back fails 3.

One trap found on the way, left commented at the site: Extract<StreamEvent, { status: "completed" }> silently yields never, because the settled member's status is the wider "completed" | "failed" and no narrower constraint matches it. The extraction keys on result instead.

Related

  • workglow-dev/builder#468 — builder's cards move onto these events.
  • The libs CLAUDE.md paragraph that told readers to draw tool cards from the messages snapshots is corrected; snapshots stay what they were for, mirroring the conversation.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN


Generated by Claude Code

A host drawing a card for one tool call could only reconstruct it by
diffing successive copies of the conversation the turn publishes: the ask
appears when the assistant message lands, and the outcome when the
results land — as one batch, after the last of them, with nothing in
between and no way to tell which call is running now.

Builder did not hit this because its tools are closures it owns, so the
adapter fills the card in from inside the tool. A host whose tools are
task types it named has nothing to fill it in from, and neither does one
relaying the turn to a protocol.

So each call now reports where it has got to: `pending` when the model
asks (carrying the arguments), `running` when the loop takes it up, and
`completed` or `failed` when it settles, carrying the same string the
model is about to read — read back off the result block rather than
built a second time, so a card cannot drift from the answer or miss its
clamp.

Every call passes all three states, including the ones nothing executes
for — an unknown tool, arguments its schema rejects, a call nobody
approves — so a host draws one lifecycle rather than one per way a call
can end. `running` covers waiting on a person for the same reason: an
approval is part of making the call.

Metadata on the same terms as `phase`: emitted on `stream_chunk`, never
accumulated into a port, absent from `finish`, and no status flip. A task
is not streaming its output because something it called started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN
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.

2 participants