Skip to content

feat(subagents): stream normalized turn events - #410

Merged
Waishnav merged 53 commits into
mainfrom
feat/subagent-events
Oct 26, 2026
Merged

Waishnav merged 53 commits into
mainfrom
feat/subagent-events

Conversation

@Waishnav

@Waishnav Waishnav commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Until now a running subagent was a black box. LocalAgentRuntime.run() only returned when the turn ended, the host had no way to see what the worker was doing, and nothing identified an individual turn, so there was nothing to tie a subagent's work to later review changes. On top of that, agents show hid why a turn failed: hosts saw Claude agent turn failed. while the provider's real error ("the selected model does not exist", "usage limit reached") stayed inside the error cause.

This PR gives every turn an app-owned trn_ id and a small, normalized event stream defined in DevSpace's own terms. The types are turn_started, message, tool_started, tool_completed (tool, short summary, status) and turn_completed (completed/failed/stopped). The manager allocates the turn id and emits the two lifecycle events itself. Each driver maps its native frames into activity through a new onEvent callback, so no provider frame enters the domain model. Before persisting, the manager redacts and bounds each event. Tool summaries keep only the identifying argument (a command, path, or query), never full inputs or outputs. A turn keeps at most 500 events and 256 KiB, with the last slot reserved for turn_completed. Only an agent's 20 most recent turns keep their event logs. The log is exposed through a new agent.events daemon method (protocol v8) and devspace agents events <id> [--turn <id>] [--follow], written as the existing XML fragments. The daemon protocol is request/response, so --follow polls every 500 ms for events after the last seq it has seen. It also pins the turn it started on, so it stays on that turn even if the agent is continued. show, ls, run, and wait now include turn="trn_…". Failed turns also persist the provider's own message, redacted and capped at 1,000 characters, shown as <cause> in XML and error.detail in JSON.

Streaming support is reported by capabilities.turns.streaming. It sits next to interrupt to match the existing nested shape. Claude maps complete SDK assistant/tool_result messages. Pi maps tool_execution_* and message_end RPC frames. Codex maps app-server item/started/item/completed for commands, file changes, MCP tools, and web search. ACP maps tool_call/tool_call_update and joins agent_message_chunk deltas into one message per stretch of text. OpenCode v1 and v2 report streaming: false: both run the turn as one blocking prompt call, and their SSE event stream would be its own change. They still get turn_started/turn_completed from the manager. Claude and Pi were verified live. Codex and OpenCode were not, because both accounts are out of quota. Codex and ACP are covered by fake-transport tests: a fake codex app-server process and the ACP notification path.

I did not change the open_workspace card. The MCP server has no connection to the agent daemon today, since subagents run only through the CLI and daemon. The card is also a one-time snapshot of open_workspace with no refresh path. Showing live subagents there would need two things: the server opening a read-only client to the daemon (or the store) during open_workspace, and a way for the card to refresh, which today means a new app-callable tool. That is a product decision, not a drive-by. Once both this PR and #407 land, WorkspaceActivityJournal.currentTurn(workspaceId) can be backed directly by agent.events. It would list the workspace's agents, take each one's latestTurnId, and page events by afterSeq. The trn_ id is the stable key a review checkpoint can record to link changes back to the turn that made them.

Live smoke used the built CLI (node dist/cli.js) with a temp DEVSPACE_CONFIG_DIR, a throwaway git repo, and Pi 1.4.0 with an isolated PI_CODING_AGENT_DIR (xai/grok-4.5). The state dir had to live under a short /tmp path, because a long scratchpad path exceeds the Unix-socket length limit; that limit predates this PR. Trimmed transcripts are below. The Claude turn shows its sandbox rejecting a read and edit outside the workspace (status="failed"). The Pi turn shows parallel tool calls being correlated. A Pi turn stopped during sleep 30 ended the follower with turn_completed status="stopped" code="PROVIDER_CANCELLED"; Pi sends no tool_execution_end for the cancelled tool, so that tool_started stays unpaired. A Claude turn with a bogus model showed the new cause:

<agent id="agt_82131682" status="failed" turn="trn_2dcbaf62" code="PROVIDER_EXECUTION_ERROR" retryable="false">Claude agent turn failed.<cause>Claude returned an error result: There's an issue with the selected model (claude-nonexistent-9). It may not exist or you may not have access to it.</cause></agent>
agents events --follow during a real Claude turn (events 9–12 elided)
<event seq="1" type="turn_started" turn="trn_cd9eb384" at="2026-10-25T21:49:09.229Z"/>
<event seq="2" type="tool_started" turn="trn_cd9eb384" tool="Read">/home/waishnav/math.js</event>
<event seq="3" type="tool_completed" turn="trn_cd9eb384" tool="Read" status="failed">/home/waishnav/math.js</event>
<event seq="4" type="tool_started" turn="trn_cd9eb384" tool="Bash">cat ./math.js</event>
<event seq="5" type="tool_completed" turn="trn_cd9eb384" tool="Bash" status="completed">cat ./math.js</event>
<event seq="6" type="tool_started" turn="trn_cd9eb384" tool="Bash">node -e "import(\"./math.js\").then(m =&gt; console.log(m.add(2, 3)))"</event>
<event seq="7" type="tool_completed" turn="trn_cd9eb384" tool="Bash" status="completed">node -e "import(\"./math.js\").then(m =&gt; console.log(m.add(2, 3)))"</event>
<event seq="8" type="message" turn="trn_cd9eb384">Now I'll fix the bug by changing the subtraction to addition:</event>
<event seq="13" type="tool_started" turn="trn_cd9eb384" tool="Edit"><repo>/math.js</event>
<event seq="14" type="tool_completed" turn="trn_cd9eb384" tool="Edit" status="completed"><repo>/math.js</event>
<event seq="15" type="tool_started" turn="trn_cd9eb384" tool="Bash">node -e "import(\"./math.js\").then(m =&gt; console.log(m.add(2, 3)))"</event>
<event seq="16" type="tool_completed" turn="trn_cd9eb384" tool="Bash" status="completed">node -e "import(\"./math.js\").then(m =&gt; console.log(m.add(2, 3)))"</event>
<event seq="17" type="message" turn="trn_cd9eb384">Fixed the `add` function by changing `a - b` to `a + b`, and the test now correctly returns 5.</event>
<event seq="18" type="turn_completed" turn="trn_cd9eb384" status="completed" at="2026-10-25T21:49:27.040Z"/>
agents events --follow during a real Pi turn
<event seq="1" type="turn_started" turn="trn_34b8f3b4" at="2026-10-25T21:49:39.459Z"/>
<event seq="2" type="message" turn="trn_34b8f3b4">I'll read `math.js` and run the add check next.</event>
<event seq="3" type="tool_started" turn="trn_34b8f3b4" tool="read"><repo>/math.js</event>
<event seq="4" type="tool_started" turn="trn_34b8f3b4" tool="bash">node -e 'import("./math.js").then(m =&gt; console.log(m.add(2, 3)))'</event>
<event seq="5" type="tool_completed" turn="trn_34b8f3b4" tool="read" status="completed"><repo>/math.js</event>
<event seq="6" type="tool_completed" turn="trn_34b8f3b4" tool="bash" status="completed">node -e 'import("./math.js").then(m =&gt; console.log(m.add(2, 3)))'</event>
<event seq="7" type="message" turn="trn_34b8f3b4">`add` is subtracting; fixing that and re-running the check.</event>
<event seq="8" type="tool_started" turn="trn_34b8f3b4" tool="edit"><repo>/math.js</event>
<event seq="9" type="tool_started" turn="trn_34b8f3b4" tool="bash">node -e 'import("./math.js").then(m =&gt; console.log(m.add(2, 3)))'</event>
<event seq="10" type="tool_completed" turn="trn_34b8f3b4" tool="edit" status="completed"><repo>/math.js</event>
<event seq="11" type="tool_completed" turn="trn_34b8f3b4" tool="bash" status="completed">node -e 'import("./math.js").then(m =&gt; console.log(m.add(2, 3)))'</event>
<event seq="12" type="message" turn="trn_34b8f3b4">Fixed `add` so it returns `a + b` instead of `a - b`, and the check now prints `5`.</event>
<event seq="13" type="turn_completed" turn="trn_34b8f3b4" status="completed" at="2026-10-25T21:49:46.724Z"/>

Migration 11 adds turn_id and error_detail to local_agent_turns, latest_turn_id and error_detail to local_agent_sessions, and a local_agent_turn_events table, backfilling trn_<rowid> ids for existing turns. The migration runner rejects unknown versions, so any other open PR that claims version 11 will need renumbering when the two meet.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added devspace agents events <id> to view an agent turn’s activity, with --follow to watch until it ends and --turn to select an earlier turn.
    • Agent activity can include messages and tool updates, with turn IDs and sequence numbers. Providers without streaming report turn start and completion.
  • Improvements
    • Failed-agent results can include a redacted, truncated explanation from the provider.
    • Activity history is limited and sensitive information is redacted.

Waishnav and others added 30 commits October 3, 2026 13:46
…rovider instance name

A profile named like its provider instance won target resolution on start but
was treated as a raw provider run on continue, dropping its instructions and
its disabled check. Resolve the profile first on continue too.

Also make the migrated local_agent_sessions.driver column NOT NULL so migrated
databases enforce the same invariant as fresh ones.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A cached probe could keep routing new runtimes to the previous protocol
generation after OpenCode was upgraded while the daemon stayed up. Runtime
creation already spawns a server, so one extra --version call is negligible.

Also report spawn failures other than ENOENT with their cause.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… message

OpenCode 2 records a turn that fails before any assistant output (for example
an expired provider login) as a failed idle message. The assistant-only listing
hid it and the run ended with a misleading protocol error. Read the failed idle
message and surface its error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Abort the local OpenCode request before awaiting the provider's abort so a
stalled provider call cannot keep the turn pending. Do not submit a prompt to
Pi, Claude, Codex, or ACP once stop was requested during setup. Give ACP
providers a grace period to answer session/cancel before dropping the process.
A failed Codex interrupt no longer reports the turn as stopped.

The manager bounds how long stop waits (below the client deadline) and reports
a conflict instead of success when the turn kept running or finished normally.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Drain Pi's stderr and report its tail when the process dies, handle stdin
errors on the connection instead of letting EPIPE crash the daemon, and drop
events left over from an earlier turn. A stopped or timed-out turn aborts Pi
and discards a process that does not settle, and a stop requested before the
prompt never reaches Pi.

Resolving a model now prefers an exact model id over provider/model parsing,
and resuming a bare session id from before the RPC runtime fails explicitly
instead of silently opening an empty session.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…h startup

A named instance using a legacy ACP driver (driver: "cursor") lost its flavor
and started without the provider's required arguments, while a built-in id
paired with another driver was wrongly given ACP flavor config. Availability
now resolves the ACP executable through the same function the driver uses,
and configured built-in ACP ids are no longer listed twice or dropped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The RPC runtime waits for agent_settled, which the previously pinned 0.80.3
does not emit, so turns on the bundled CLI would only end at the timeout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Preflight only looked for the bundled package, rejecting a system pi that the
runtime would happily launch. Also update stale Pi docs for the RPC runtime.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Each run now gets a trn_ id from the manager, persisted with the turn
history. Drivers report activity through onEvent; the manager owns
turn_started/turn_completed and stores a bounded, redacted log per turn.
Failed turns also keep the provider's own error detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds agent.events to the daemon protocol (v8) and
`devspace agents events <id> [--turn <id>] [--follow]`, which polls
for events after the last seen seq. show, ls, run and wait now carry the
turn id, and failed turns print the provider's cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude maps complete SDK assistant/tool_result messages; Pi maps its
tool_execution and message_end RPC frames. Both report
turns.streaming, and OpenCode reports false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex maps app-server item/started and item/completed for commands,
file changes, MCP tools and web search. ACP maps session/update
tool_call frames and joins agent_message_chunk deltas into messages.
Codex turn failures now keep the provider's error message as the cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Waishnav

Copy link
Copy Markdown
Owner Author

Re the outside-diff finding (local-agent-daemon-protocol.ts:279): fixed in d3734ee. LocalAgentWaitResult now has an optional turnId. waitResultFromTurn sets it from the selected turn, waitResultFromAgent sets it from the agent's latestTurnId, and decodeAgentWaitResults keeps it, so agents wait shows turn="…". The manager test now asserts the ids match each agent's latest turn (f5a3fa8), and a protocol test covers decoding.

Waishnav and others added 18 commits October 26, 2026 09:51
…stance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-v1-v2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…apabilities-cancel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c-acp

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A continued v2 turn that fails before output no longer returns the
previous turn's assistant message, and the version probe reads output
after stdio closes rather than on exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stop that outlives the cancel grace period closes the runtime, which
must not end other agents' turns sharing the same workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local request now ends only once the native abort (v1) or interrupt
(v2) succeeds. A failed or stalled one keeps the turn running, so the
manager reports a stop conflict instead of a false stopped state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l' into refactor/generic-acp

# Conflicts:
#	src/local-agent-acp.test.ts
#	src/local-agent-acp.ts
Cursor, Copilot and Grok derive their sandbox flags from the write mode;
replacing their arguments would drop those restrictions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DevSpace cannot bound what an arbitrary ACP agent writes, since it may
act without a permission request. Restricted write modes are refused
instead of being silently unenforced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Waishnav
Waishnav added this pull request to stack #417 October 26, 2026 13:06
@Waishnav
Waishnav removed this pull request from stack #417 October 26, 2026 13:12
Waishnav and others added 2 commits October 26, 2026 22:28
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Passwords may contain '@', and a query string may contain '@' after a port;
redact up to the authority's last '@' and never past '/', '?' or '#'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Waishnav
Waishnav changed the base branch from refactor/pi-rpc to main October 26, 2026 17:32
@Waishnav
Waishnav merged commit 962e604 into main Oct 26, 2026
2 of 4 checks passed
alexph-dev pushed a commit to alexph-dev/devspace that referenced this pull request Oct 26, 2026
feat(subagents): stream normalized turn events
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