Skip to content

AgentTask: the tool-calling turn loop as a task, with the CLI and its console on it - #935

Merged
sroussey merged 6 commits into
mainfrom
claude/agent-review-migration-8elz3s
Sep 11, 2026
Merged

AgentTask: the tool-calling turn loop as a task, with the CLI and its console on it#935
sroussey merged 6 commits into
mainfrom
claude/agent-review-migration-8elz3s

Conversation

@sroussey

@sroussey sroussey commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

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. AgentTask is that loop; workglow agent chat is the first thing to use it, in the terminal and in the web console.

messages goes 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/agent was the earlier plan, for two reasons that turned out to be wrong. @workglow/ai already peer-depends on @workglow/task-graphToolCallingTask imports Workflow and GraphAsTask from 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:

Composition an agent is a node in a workflow, and serialises as TaskGraphJson
Streaming text deltas reach the caller through the ordinary x-stream port
Accounting own() bridges each round's usage into the run total
Nesting tools run as owned children, so a progress UI already draws them

The four invariants

  • Every tool_use is 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 the tool_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 same maxToolResultChars budget as a tool's own output.
  • Tool-call ids are unique across the whole conversation. A model that restarts at call_0 every turn would otherwise attach this turn's result to an earlier turn's call.
  • Tools run in order, because one may block on a person and the next must not race the answer.
  • A round that said nothing records nothing. An empty assistant message is not a reply, and it poisons a prefix replayed for caching.

One more that only shows up on a non-streaming provider: the turn's text is 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 while messages carried the real one.

Tool resolution, and what a host function is owed

ToolDefinition has documented type / execute / config since it was written, and nothing in the repo read them. This is the first consumer: an explicit type decides, else a supplied execute, else the task registry — by taskType when 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.
  • 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 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_ENTITLEMENTS is put to the registered IHumanConnector as a confirm before it runs, carrying what it reaches and with what arguments, so approving is a judgement about what will happen.

  • requiresApproval on the tool overrides in both directions; approval: "never" turns the gate off for a headless run.
  • No connector registered, or a connector that throws → the call is refused, not run. A gate that fails open is not a gate, and the refusal reaches the model as an ordinary tool result. An abort still propagates.
  • A host function defaults to no approval: it cannot arrive by name in graph JSON, so supplying one is already a host decision.
  • The reach line has three answers, not two — a class that was found, a host function, and a task type that is not registered, which is a misspelling heading for a failed call rather than a host function.

The display half needs no new vocabulary: a tool that asks a person is HumanInputTask, resolving the connector from the registry own() already propagates. A test drives an elicit through the loop with its form supplied via ToolDefinition.config — which the model never sees.

workglow agent chat

A REPL: read a line, run one turn, carry messages forward. 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. withCli clears the frame on completion and calls process.exit on failure — both right for a command whose life is one graph run, both fatal to a conversation.

PromptHumanConnector is the connector for anything prompting between runs rather than during one. InkHumanConnector needs a mounted HumanInteractionHost and throws without one, so until now nothing outside a graph run could ask a person anything. It has no followUp — a modal prompt settles the question it asked — and the conformance suite enforces that a multiTurn: false connector carries none. Fourth implementation of IHumanConnector, same suite as the other three.

--tools has 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 PanelData arm, 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 a human_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 ordinary elicit whose field carries format: "chat-message" — the marker the console keys on to draw a composer instead of a one-line field. chatTranscript zips what the console sent against the AgentTask rows 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: PromptHumanConnector ignored AbortSignal past the first line of send, and the conformance suite was asserting that and passing on the wrong code — its stand-in renderer answered through MockHumanConnector, which honours abort itself, so the rejection came from the mock and never reached the connector. send now 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_result named every registered tool and bypassed the result budget (every path into a tool_result now shares one clamp); the approval card labelled an unregistered task type a host function; and releaseOnAbort never detached its listener on a normal answer, so a later abort would fire clear() — 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/43
  • bun run lint (--type-aware --deny-warnings), bun run format-check — clean
  • bunx vitest run packages/test/src/test/{ai,human} examples/cli/src packages/ai/src2,246 passed, 4 expected fail, 0 failures; plus 1,803 over packages/test/src/test/ai + packages/ai/src after the contract additions
  • Every earlier head went all 15 CI checks green

Load-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, taskType resolution, the non-streaming-provider path, the throwing-connector refusal, the abort race and the listener detach, the transcript snapshots, the ToolExecuteContext id, the ToolCallError wording, and the chat loop's history carry, /reset and console-vs-terminal connector choice.

One CI note, since it is in the run history: test-discovery failed once on 819190dc4 inside bun installonnxruntime-node's postinstall got ENETUNREACH, 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

  • workglow-dev/builder#468 — agent chat on AgentTask (−698 +433). Red until this merges and a release is cut: its only runtime import from unreleased libs is ToolCallError.
  • The design record is in the prd repo.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JEFYAGb9D3mWfyhYkmeAvN

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
@sroussey sroussey changed the title AgentTask: the tool-calling turn loop as a task AgentTask: the tool-calling turn loop as a task, and workglow agent chat on it Sep 9, 2026
@sroussey
sroussey requested a lite review from Copilot September 9, 2026 21:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 AgentTask and supporting tool-execution/approval machinery (AgentToolExecution) to @workglow/ai.
  • Extend tool definitions with taskType (and related schema support) and wire AgentTask into AI task registration/exports.
  • Add CLI chat loop + PromptHumanConnector implementation 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.

Comment thread examples/cli/src/ui/PromptHumanConnector.ts
Comment thread packages/ai/src/task/AgentTask.ts
Comment thread packages/ai/src/task/AgentToolExecution.ts Outdated
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread examples/cli/src/ui/render.ts Outdated
`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
@sroussey sroussey changed the title AgentTask: the tool-calling turn loop as a task, and workglow agent chat on it AgentTask: the tool-calling turn loop as a task, with the CLI and its console on it Sep 9, 2026
@sroussey
sroussey merged commit e742af5 into main Sep 11, 2026
15 checks passed
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.

3 participants