AgentTask: the tool-calling turn loop as a task, with the CLI and its console on it - #935
Conversation
ToolCallingTask is one round: it returns the model's text and the calls it wants, and stops. Every host driving a conversation has had to write the part that runs those calls, feeds the results back and goes round again — and the invariants there are easy to miss. AgentTask is that loop. `messages` goes in and comes back out, so the host still owns the conversation; what moves here is the machinery: - Every `tool_use` is answered. An unknown tool, arguments failing the tool's own schema, a throw inside it, a person declining it — each is an error result the model reads and can recover from. Dropping the call looks cheaper and orphans the `tool_use`, which the provider rejects on the NEXT round, one turn away from the mistake. - Tool-call ids are made unique against the whole conversation, so a model that restarts at `call_0` each turn cannot attach this turn's result to an earlier turn's call. - Tools run in order, because one may block on a person. - A round that produced neither text nor a usable call records nothing: an empty assistant message is not a reply and poisons a replayed prefix. - The turn's text is summed from each round's settled output rather than from the deltas forwarded, so a provider that reports text only on its finish event is not reported as an empty answer. Tools resolve the way ToolDefinition already documented but nothing yet read: an explicit `type`, else a supplied `execute`, else the task registry — by `taskType` when the tool is presented under another name. `taskType` moves onto ToolDefinition itself, since that is the field a runner needs and ToolDefinitionWithTaskType only narrowed it to required. Approval reuses the entitlement taxonomy rather than adding a second list: a tool whose backing class reaches beyond INFERENCE_ENTITLEMENTS is put to the IHumanConnector as a `confirm`, carrying what it reaches and with what arguments. `requiresApproval` overrides per tool in both directions, and a headless run says `approval: "never"`. With approval called for and no connector registered the call is refused, not run — a gate that fails open is not a gate, and the refusal reaches the model as an ordinary result. A host function tool defaults to no approval: it cannot arrive by name in graph JSON, so supplying one is already a host decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN
A line-based REPL: read a line, run one AgentTask turn, carry `messages` from that turn's output into the next turn's input. That is the whole of the loop's state here — nothing in the CLI knows what a tool result or a tool-call id looks like. Streamed to stdout rather than through the Ink run UI. `withCli` clears the frame on completion and calls process.exit on failure, both right for a command whose life is one graph run and both fatal to a conversation: the answer would vanish when the turn ended, and one bad turn would end the session. Text written to stdout stays in scrollback, which is what a transcript is. Three supporting pieces: - `PromptHumanConnector` — the connector for anything prompting BETWEEN runs rather than during one. `InkHumanConnector` hands a request to a mounted HumanInteractionHost and throws when there is none, so nothing outside a graph run could ask a person anything. This draws its own prompt and gives the screen back. It reads `humanPromptModel`, the same function the Ink panel and the web console read, so the three cannot disagree about whether a request is a form or an approval — the case that matters, since a confirm drawn as a form leaves no way to say no. No `followUp`: a modal prompt settles the question it asked, so no response is ever `done: false`. The conformance suite checks that a multiTurn:false connector does not carry one. - `--tools` has no default. What an agent may call decides what it can reach, and inheriting a set nobody chose is how a chat session ends up able to write files. - A turn's readline interface lives only as long as its question. A long-lived one keeps listeners on stdin while an approval prompt renders its own app over the same terminal, and two readers of one stdin drop keystrokes into whichever happens to be listening. Also here, both found by building on them: - `TaskRunApp` read `event.text` off a stream chunk; the field is `textDelta`, so the streaming-output panel has been rendering nothing. The local type was loose enough to hide it. - A connector that throws no longer ends an agent turn. Approval that cannot be asked for refuses the call and tells the model, rather than killing a conversation over one tool. The tool still does not run; an abort still propagates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN
workglow agent chat on it
There was a problem hiding this comment.
🟡 Changes recommended
PromptHumanConnector does not properly honor AbortSignal during interactive prompts, and there are a couple of correctness/robustness issues in tool-result/approval-card handling that should be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds a reusable, task-graph-native agent turn loop (AgentTask) that repeatedly calls a tool-capable model, executes requested tools in-order, and feeds tool results back until an answer (or maxRounds). It also introduces workglow agent chat, a CLI REPL that uses AgentTask and a new PromptHumanConnector to support approvals/prompts between runs.
Changes:
- Add
AgentTaskand supporting tool-execution/approval machinery (AgentToolExecution) to@workglow/ai. - Extend tool definitions with
taskType(and related schema support) and wireAgentTaskinto AI task registration/exports. - Add CLI chat loop +
PromptHumanConnectorimplementation and tests, plus a fix for streamed text rendering in the CLI UI.
File summaries
| File | Description |
|---|---|
| packages/test/src/test/human/PromptHumanConnector.conformance.test.ts | Adds conformance coverage for the new between-runs CLI human connector. |
| packages/test/src/test/ai/AgentTask.test.ts | Adds a comprehensive test suite for AgentTask invariants (tool results, id uniquification, approvals, streaming). |
| packages/ai/src/task/ToolCallingUtils.ts | Extends ToolDefinition with taskType and requiresApproval. |
| packages/ai/src/task/ToolCallingTask.ts | Updates the tool definition JSON schema to include taskType. |
| packages/ai/src/task/registerAiTasks.ts | Registers AgentTask as part of AI task registration. |
| packages/ai/src/task/index.ts | Exports AgentTask and AgentToolExecution from the task barrel. |
| packages/ai/src/task/AgentToolExecution.ts | Implements tool invocation, approval gating, and tool-result shaping for agent tool calls. |
| packages/ai/src/task/AgentTask.ts | Implements the multi-round agent turn loop as a task with streaming text output and tool execution. |
| examples/cli/src/ui/TaskRunApp.tsx | Fixes streamed text rendering to use textDelta instead of a non-existent text field. |
| examples/cli/src/ui/PromptHumanConnector.ts | Adds a between-runs CLI IHumanConnector that renders approvals/forms via prompts. |
| examples/cli/src/test/chatCommands.test.ts | Tests parsing of /exit, /reset, /help, and unknown command handling. |
| examples/cli/src/test/agentChat.test.ts | Tests the REPL loop behavior (history carry, reset, help, failure resilience). |
| examples/cli/src/human.ts | Re-exports PromptHumanConnector and its renderer types from the CLI package. |
| examples/cli/src/commands/agent.ts | Adds workglow agent chat command and wiring (--tools, --no-approval, model picker). |
| examples/cli/src/agent/runAgentChat.ts | Implements the REPL loop atop AgentTask, streaming deltas to stdout and handling interrupts. |
| examples/cli/src/agent/chatTranscript.ts | Adds transcript formatting logic to keep non-model output on column 0. |
| examples/cli/src/agent/chatCommands.ts | Adds command classification logic for the REPL. |
| .claude/CLAUDE.md | Documents AgentTask and workglow agent chat behavior/invariants for contributors. |
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
All three were real. **PromptHumanConnector did not honour AbortSignal past its first line.** `throwIfAborted()` at the top of `send`, then an await on a prompt that has no way to be cancelled: Ctrl-C during an approval left the Ink app on screen and the promise pending forever. The connector now races the prompt against the signal and rejects, which is what `IHumanConnector` requires, and hands the signal down so the prompt tears its own app down rather than only being abandoned. A renderer resolving `undefined` on abort would not do: that is indistinguishable from Esc, which is a `cancel` a caller may act on. The conformance suite already claimed `abortMidElicit` and passed — on the stand-in, not on the connector. Its fake renderer answered through the mock connector, which honours abort itself, so the assertion never reached the code under test. The stand-in now gets a signal that never aborts, and removing the race fails the test at a 20-second timeout. **The unknown-tool answer was unbounded.** `Unknown tool "x". Available: …` names every registered tool and bypassed `maxToolResultChars`, so a large registry could fill the window — and re-fill it on every round the model kept guessing. Every path into a `tool_result` now goes through the same clamp, not just the one carrying a tool's output. **An unregistered task type was labelled a host function.** The approval card's reach fell back to "this tool is a host function" whenever no backing class was found, which is also what a misspelled or unregistered `taskType` looks like — a different situation, described wrongly, on the one card a person reads before approving. Three answers now, and the third names the type that is missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN
There was a problem hiding this comment.
🟡 Changes recommended
examples/cli/src/ui/render.ts’s abort handling leaves stale AbortSignal listeners that can fire after normal completion and double-unmount/accumulate across prompts.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 19/19 changed files
- Comments generated: 1
- Review effort level: Lite
`releaseOnAbort` attached an abort listener and never removed it. The harmless-looking half is `resolve(undefined)` on an already-settled promise; the half that costs is `clear()`, which writes an erase sequence to stdout. Left attached past a normal answer, it fires the next time the run aborts and wipes whatever is on screen then — in agent chat, where one AbortController spans the turn and the transcript lives in scrollback, that is the conversation, erased by a Ctrl-C that arrives after an approval was answered. A run holding one signal across several prompts also stacked a listener per prompt, so an abort erased once for each question it had asked, and Node warns past ten. `releaseOnAbort` now returns its detach, every settle path calls it, and it moves to its own module so the rule can be tested without a terminal: five tests over a fake instance, two of which fail if the detach goes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN
The console needed no channel it did not already have. A chat is a run that keeps asking a person something, and asking a person something is what the run-event channel already carries — up as a `human_request`, back down fd 4. So the same command serves both surfaces: - Each turn runs through `withCli` instead of `task.run`, so a session the console started reports its rows and its text like every other command. `interactive: false` keeps the Ink run UI out of it — on a terminal that UI clears its frame when a run completes, which is the transcript this session is writing. - The next message is asked for as an ordinary `elicit` whose field carries `format: "chat-message"`. That marker is how a renderer knows to draw a composer rather than the one-line field every string port gets, and to fold the answer into a transcript rather than leave it showing as a form somebody once filled in. - A reported session does NOT install `PromptHumanConnector`. The channel installs its own with itself, and overriding it would point a console session's approvals at an Ink prompt on a process whose stdout is a pipe. Console side: `chatTranscript` zips what the console sent against the `AgentTask` rows it saw — one turn per message, since a turn is only ever started by a message, so the pairing cannot slip. Derived rather than a second copy of the run: a turn still running shows what it has said so far, one that finished silently contributes no empty bubble. `WithCliTaskHandle.run` also gains the `runConfig` argument its implementation has always threaded through to `task.run` and to the Ink renderer. The caller that needs it is one running a task inside something longer than a command, where the registry and the abort signal belong to the session rather than to the process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN
Builder's agent chat is the loop's other caller, and its tools are
closures that draw a card and block on a person. Three things it needs
that a task-backed tool never did:
**A host function is told which call it is serving.** `execute` now takes
a `ToolExecuteContext` carrying the tool-use id and the run's signal. A
transcript keys its cards on that id, so a tool that cannot see it either
guesses or cannot draw at all — and an answer that arrives against the
wrong card is worse than no answer.
**A tool can report a failure in its own words.** The runner's wrapper
("<tool> failed: …") is right for a bug and wrong for an outcome the tool
means to report: a person declining an action, a workflow that ran and
errored. `ToolCallError`'s message reaches the model exactly as written,
including whatever the tool wants it to do next. An ordinary throw still
gets the wrapper, so a bug still reads as one.
`execute`'s return widens to `unknown`, matching what the runner already
did with it: a string is the tool result verbatim, anything else is
serialized.
**The transcript is visible while the turn is still running.** A
`snapshot` of `messages` after every message the turn records, so a card
exists from the moment the model asks for the call rather than once the
turn is over. A snapshot rather than an object-delta on the port: an
array delta is folded as an upsert list, so successive whole-list
snapshots append into a transcript several times its own length — the
output port would have carried ten messages for a four-message turn.
Also pinned: a function tool survives arriving through graph JSON. A host
whose tools are closures builds its graph in memory and never serializes
it, and a validator refusing a function value would break that a long way
from where it was written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN
workglow agent chat on it
ToolCallingTaskis one round — it returns the model's text and the calls it wants, and stops. Every host driving a conversation has had to write the part that runs those calls, feeds the results back and goes round again.AgentTaskis that loop;workglow agent chatis the first thing to use it, in the terminal and in the web console.messagesgoes in and comes back out, so the host still owns the conversation. What moves into the library is the machinery, and it is machinery that is easy to get subtly wrong.Why a task rather than a package
A separate
@workglow/agentwas the earlier plan, for two reasons that turned out to be wrong.@workglow/aialready peer-depends on@workglow/task-graph—ToolCallingTaskimportsWorkflowandGraphAsTaskfrom it directly — and the loop is not host-runtime code: it runs in a browser today, in builder's renderer, and touches no Node API.Being a task is also what it buys:
TaskGraphJsonx-streamportown()bridges each round's usage into the run totalThe four invariants
tool_useis answered. An unknown tool, arguments failing the tool's own schema, a throw inside it, a person declining it — each becomes an error result the model reads and can recover from. Dropping the call looks cheaper and orphans thetool_use, which the provider rejects on the next round: a failure one turn away from its cause. Every one of those answers is bounded by the samemaxToolResultCharsbudget as a tool's own output.call_0every turn would otherwise attach this turn's result to an earlier turn's call.One more that only shows up on a non-streaming provider: the turn's
textis summed from each round's settled output, not from the deltas forwarded. A provider reporting its text only on the finish event streams nothing, and counting deltas would report an empty answer whilemessagescarried the real one.Tool resolution, and what a host function is owed
ToolDefinitionhas documentedtype/execute/configsince it was written, and nothing in the repo read them. This is the first consumer: an explicittypedecides, else a suppliedexecute, else the task registry — bytaskTypewhen a host presents a tool under a name the registry does not know.Three things a host function needs that a task-backed tool never did, each asked for by the second consumer (builder, whose tools are closures that draw a card and block on a person):
ToolExecuteContext— the tool-use id its answer belongs to, and the run's signal. A transcript keys its cards on that id, so a tool that cannot see it either guesses or cannot draw at all.ToolCallError— a failure reported in the tool's own words. The runner's<tool> failed:wrapper is right for a bug and wrong for an outcome the tool means to report; "the user declined, ask what they would rather do" is guidance for the model, not a stack trace. An ordinary throw still gets the wrapper.snapshotofmessagesafter every message the turn records, so a card exists from the moment the model asks for the call rather than once the turn is over. A snapshot rather than an object-delta on the port: an array delta is folded as an upsert list, so successive whole-list snapshots would append into a transcript several times its own length — the port carried ten messages for a four-message turn before this was found.Also pinned: a function tool survives arriving through
createGraphFromGraphJSON. A host whose tools are closures builds its graph in memory and never serializes it, and a validator refusing a function value would break that a long way from where it was written.Approval reuses the taxonomy instead of adding a list
A tool whose backing class reaches beyond
INFERENCE_ENTITLEMENTSis put to the registeredIHumanConnectoras aconfirmbefore it runs, carrying what it reaches and with what arguments, so approving is a judgement about what will happen.requiresApprovalon the tool overrides in both directions;approval: "never"turns the gate off for a headless run.The display half needs no new vocabulary: a tool that asks a person is
HumanInputTask, resolving the connector from the registryown()already propagates. A test drives anelicitthrough the loop with its form supplied viaToolDefinition.config— which the model never sees.workglow agent chatA REPL: read a line, run one turn, carry
messagesforward. Nothing in the CLI knows what a tool result or a tool-call id looks like.Streamed to stdout, not through the Ink run UI.
withCliclears the frame on completion and callsprocess.exiton failure — both right for a command whose life is one graph run, both fatal to a conversation.PromptHumanConnectoris the connector for anything prompting between runs rather than during one.InkHumanConnectorneeds a mountedHumanInteractionHostand throws without one, so until now nothing outside a graph run could ask a person anything. It has nofollowUp— a modal prompt settles the question it asked — and the conformance suite enforces that amultiTurn: falseconnector carries none. Fourth implementation ofIHumanConnector, same suite as the other three.--toolshas no default. What an agent may call decides what it can reach; inheriting a set nobody chose is how a chat session ends up able to write files.The same command serves the web console
The prediction was a new
PanelDataarm, since every existing one is read-only. Wrong question: a chat is a run that keeps asking a person something, and asking a person something is what the run-event channel already carries — up as ahuman_request, back down fd 4.So: each turn goes through
withCli(interactive: false) so a console session reports its rows and text like any command, and the next message is an ordinaryelicitwhose field carriesformat: "chat-message"— the marker the console keys on to draw a composer instead of a one-line field.chatTranscriptzips what the console sent against theAgentTaskrows it saw, one turn per message, so the conversation is derived rather than a second copy of the run.One trap, and it is the reason to read that diff: a reported session must not install
PromptHumanConnector. The channel installs its own connector with itself, and overriding it points a console session's approvals at an Ink prompt on a process whose stdout is a pipe.Review rounds
Four Copilot findings across two rounds, all real, all fixed, all threads resolved. One is worth a reviewer's attention beyond its fix:
PromptHumanConnectorignoredAbortSignalpast the first line ofsend, and the conformance suite was asserting that and passing on the wrong code — its stand-in renderer answered throughMockHumanConnector, which honours abort itself, so the rejection came from the mock and never reached the connector.sendnow races each prompt against the signal; the stand-in gets a signal that never aborts, so only that race can satisfy the assertion.The others: the unknown-tool
tool_resultnamed every registered tool and bypassed the result budget (every path into atool_resultnow shares one clamp); the approval card labelled an unregistered task type a host function; andreleaseOnAbortnever detached its listener on a normal answer, so a later abort would fireclear()— an erase sequence to stdout — and wipe whatever was on screen, which in agent chat is the transcript.Verification
On the current head:
bun run build:types— 43/43bun run lint(--type-aware --deny-warnings),bun run format-check— cleanbunx vitest run packages/test/src/test/{ai,human} examples/cli/src packages/ai/src— 2,246 passed, 4 expected fail, 0 failures; plus 1,803 overpackages/test/src/test/ai+packages/ai/srcafter the contract additionsLoad-bearing behaviour was sabotage-checked rather than assumed — each of these fails when the code it covers is broken, and only those: the approval gate and its three reach answers, the unknown-tool answer and its clamp, id uniquification, delta forwarding,
taskTyperesolution, the non-streaming-provider path, the throwing-connector refusal, the abort race and the listener detach, the transcript snapshots, theToolExecuteContextid, theToolCallErrorwording, and the chat loop's history carry,/resetand console-vs-terminal connector choice.One CI note, since it is in the run history:
test-discoveryfailed once on819190dc4insidebun install—onnxruntime-node's postinstall gotENETUNREACH, 16 seconds in, before any test body ran, while the same commit's other jobs installed fine. It passed on one re-run, and that re-run is spent.Related
AgentTask(−698 +433). Red until this merges and a release is cut: its only runtime import from unreleased libs isToolCallError.🤖 Generated with Claude Code
https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN