feat(tui): async delegation view — docked agents panel + live steering - #70899
feat(tui): async delegation view — docked agents panel + live steering#70899JoaoMarcos44 wants to merge 12 commits into
Conversation
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
Phase 1 (read-only docked panel): - delegation.async_list RPC projects the async registry to the TUI - $asyncDelegations store + applyAsyncList; 5s gateway tick populates it - agentsPanel.tsx merges live in-turn subagents with background async rows - extract shared STATUS_GLYPH so panel and /agents overlay never diverge - mount above the composer next to LiveTodoPanel; ^a opens the full tree Phase 2 (live steering, @id): - send_to_subagent reuses the child's existing AIAgent.steer() drain, so the steer lands as a fresh out-of-band user turn on the last tool message at the iteration boundary — role alternation and prompt cache stay intact. No new core-loop code. - subagent.send RPC; composer routes "@<id> text" only when the token resolves to a live subagent, so ordinary prompts fall through untouched. Tests: 53 new (backend 15 incl. 50-thread concurrency + role-legality; frontend 38 across merge logic, steer resolution, panel view, store). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Follow-up pushed in ec66bc9 to close the gaps found during verification:
Validation: 17 backend tests + 39 focused TUI tests passed; Video was not attached: current Windows environment has no terminal capture backend (ffmpeg/asciinema unavailable). Existing local Ink demo was launched for smoke validation, but remains untracked and was not added to the PR. |
|
Expanded demo with provider-verified evidence: the video now covers the attached panel, live steering, cache-safe flow, Anthropic metrics (6,896 tokens generated; 6,896 read in subsequent calls), cost/latency, and HTTP 400 negative control. Estimated total cost: US$0.0102052. Direct OpenAI usage is explicitly marked as untested due to the absence of credentials. Commit: 27e04e7d3. Artifacts: async-delegation-demo.mp4, async-delegation-provider-evidence.json, and relatorio-cobertura-video-async-delegation.md. |
27e04e7 to
bc9cb2e
Compare
|
Removed async-delegation-demo.mp4 from the PR and rewritten branch history with force-with-lease, as requested. Added relatorio-controle-negativo-async-delegation.md explaining the intentional Anthropic HTTP 400 negative control, scope, privacy, and cost. New head: bc9cb2e. |
OutThisLife
left a comment
There was a problem hiding this comment.
Reviewed by checking the branch out and rendering AgentsPanelView through real Ink (renderSync) across a few realistic scenes, plus tracing the RPC/store wiring against main. npx tsc --noEmit and eslint on the touched files are clean, and all 39 new frontend tests pass locally — the backend half of this is the strong half. The docked panel itself isn't ready yet.
The backend approach is right and I want to say so first: reusing the child's existing AIAgent.steer() slot and the apply_pending_steer_to_tool_results drain means steering lands on the last tool result at an iteration boundary with no new core-loop code, so role alternation and the cached prefix survive. Extracting STATUS_GLYPH into lib/subagentGlyph.ts so the panel and the /agents overlay can't drift is exactly the right call, and keeping agentRows.ts / subagentSteer.ts pure and ink-free makes them properly testable.
Blocking — the panel
Here is the actual rendered output at 72 columns:
── typical: one live, one background running, one done ─────────────────
▾ agents · 2 running · 1 done ^a tree
1 ● map auth handshake edge cases 42s read_file
2 ● fixer patch token-bucket refill race 2m 10s running
3 ✓ tests sweep flaky gateway suite 2m 5s result ready ⏎
That case looks good. The problems show up in the cases that will actually occur.
1. Finished rows never leave, and the panel is fixed chrome. list_async_delegations() returns running and completed records, retained up to _MAX_RETAINED_COMPLETED = 50. The panel renders every one of them, and unlike LiveTodoPanel (which rides inside the transcript ScrollBox) this one is mounted between the transcript and PromptZone, so it permanently eats terminal rows:
── accumulated history: 12 finished background delegations ─────────────
▾ agents · 0 running · 12 done ^a tree
1 ✓ fixer background task 1 1m 10s result ready ⏎
2 ✓ fixer background task 2 1m 11s result ready ⏎
… 10 more …
Twelve background delegations into a session, twelve permanent rows sit above your composer saying "result ready" for results that re-entered the conversation minutes ago. Worst case is ~50. Finished rows need to age out (or the panel needs to cap and show "+N more"), and the ⏎ suffix promises a keypress that does nothing.
2. Goals aren't truncated. row.goal renders into a bare <Text> with no wrap="truncate-end", so one background delegation with a realistic goal is five lines of fixed chrome:
── long goal (real delegate_task goals are paragraphs) ─────────────────
▾ agents · 1 running · 0 done ^a tree
1 ● fixer Investigate the intermittent 429 responses coming from the
OpenRouter provider adapter during high-concurrency batch runs,
reproduce with a synthetic load harness, and propose a retry/backoff
change that does not break the existing credential-pool rotation
logic. 2m 10s running
The continuation lines also lose the row indent. This is worse than it looks for batches: dispatch_async_delegation_batch builds combined_goal as "3 parallel subagents: <40 chars>; <40 chars>; <40 chars>", so every fan-out produces one of these walls. The neighbouring surface already solves this — ListRow in agentsOverlay.tsx uses compactPreview(goal, width - 28 - depth * 2) plus wrap="truncate-end". Match it.
3. finalizing renders as a red error glyph on the success path. _finalize() sets record["status"] = "finalizing" while durable persistence and queue publication run, then flips to the terminal status. finalizing isn't in STATUS_GLYPH, so statusGlyph() falls through to the error entry, and the header counts it in neither bucket:
── transient finalizing status ─────────────────────────────────────────
▾ agents · 0 running · 0 done ^a tree
1 ⚠ fixer patch token-bucket refill race 2m 10s finalizing
Every successful background delegation flashes a red ⚠ next to a header claiming zero agents. The defensive ?? STATUS_GLYPH.error fallback is fine for genuinely unknown cross-version statuses, but finalizing is a status this code path produces on purpose and should map to something non-alarming.
4. ^a tree advertises a keybinding that doesn't exist. There is no ctrl-A handler anywhere in the TUI — isCtrl(key, ch, …) is only wired for c and x in useInputHandlers.ts, and patchOverlayState({ agents: true }) is reached solely from /agents in app/slash/commands/ops.ts. Either add the binding or label the affordance with the command that actually works.
5. Clicking "^a tree" also collapses the panel. The ^a tree <Text onClick> is a child of the header <Box onClick={onToggle}>, and dispatchClick in hermes-ink/src/ink/hit-test.ts walks up through parents until a handler calls stopImmediatePropagation(). So one click opens the overlay and toggles collapse. The convention for nested clickables is already established in appChrome.tsx:573, activeSessionSwitcher.tsx:538, and appLayout.tsx:301 — call event.stopImmediatePropagation?.() in onOpenTree.
6. The panel never shows an agent id, so you can't steer from it. @<id> text is the headline feature, and the row renders index, glyph, role, goal, elapsed, and detail — no id. The /agents list rows don't show ids either; only the detail pane does. So the loop is: open /agents, arrow to the agent, read the id out of the detail pane, close the overlay, type @b7c2 …. The panel should print the short id (it's already the row key), and the header is the natural place for a @id to steer hint.
7. Live and async rows aren't deduped. buildAgentRows keys live rows live:<subagent_id> and async rows async:<delegation_id>, and a background fan-out is one async record covering N children that also arrive individually as live subagents via subagent.start. A background batch of three renders as three live rows plus a fourth row carrying the concatenated batch label — four rows for three agents.
Blocking — polling and payload
8. applyAsyncList re-renders the TUI on every tick, unconditionally. It does $asyncDelegations.set(...) with a fresh array every poll whether or not anything changed, so LiveAgentsPanel (subscribed via useStore) re-renders every 1.5s forever, including on a completely idle session with zero delegations. There's a comment about precisely this ~15 lines above the new call site in useMainApp.ts:
Only patch when something actually changed.
patchUiStatealways produces a new state object, which notifies every$uiStatesubscriber; patching unconditionally on each 1.5s poll re-renders the whole TUI and causes idle flicker.
Same hazard, same file. Bail out when the snapshot is unchanged. Related: delegation.async_list is now polled from two places — useMainApp at 1.5s and the existing tick in createGatewayEventHandler — so pick one.
9. The RPC ships the whole record. list_async_delegations() strips only the two closures, so every 1.5s poll carries context (arbitrary agent-supplied text, can be large), goals, toolsets, session_key, origin_session_id, and parent_session_id for up to 50 records, to render six fields. Project server-side to what the panel actually uses.
Non-blocking
- Please drop the evidence artifacts from the diff:
relatorio-cobertura-video-async-delegation.md,relatorio-controle-negativo-async-delegation.md,async-delegation-provider-evidence.json, andscripts/async_delegation_provider_evidence.py. The last one is a one-off harness that spends real money against whatever Anthropic OAuth credential is resolved and writes a JSON file to the repo root; it isn't something we want to carry inscripts/. Put the numbers in the PR body instead. The infographic is fine. appLayout.tsxline 238 is a no-op reformat of theLiveTodoPanelline — revert it to keep the diff honest.steer_fnon the singulardispatch_async_delegationhas no production caller; all background delegation goes throughdispatch_async_delegation_batch. Harmless symmetry withinterrupt_fn, just noting it's untriggered.@<id>has no/helpentry, no completion, and no docs, so the only way to discover it is this PR description.- The steer text itself never appears in the transcript — you get
delivered → @b7c2but no record of what you sent. agentsPanel.test.tsxcallsAgentsPanelView({...})as a plain function and walks the returned element tree, which is why none of items 1-5 were caught. Rendering through Ink and asserting on the frame would have surfaced the wrapping, thefinalizingglyph, and the row accumulation.
Happy to re-review once the panel bounds its own height, truncates, stops advertising ^a, and shows the ids that make @<id> usable. The steering backend I'd take close to as-is.
Numbers move to the PR body. The provider evidence harness spent real money against a resolved OAuth credential and wrote to the repo root; it is not something to carry in scripts/. Infographic is kept.
Blocking items from review: 1. Panel had no height bound and completed rows never left. Cap at PANEL_MAX_ROWS=5 and retire finished rows after DONE_LINGER_MS=60s. 2. Goals rendered untruncated into fixed chrome. Row is now a single line with wrap="truncate". 3. "finalizing" fell through statusGlyph() to the error entry on the success path. Added its own entry and counted it as in-flight. 4. Header advertised "^a tree", which has no handler. Label is now "/agents", the command that actually works. 5. The tree affordance was nested inside the header's onClick, so one click both opened the overlay and collapsed the panel. onOpenTree now calls stopImmediatePropagation. 6. Rows carried no agent id, making @<id> undiscoverable from the panel. Rows print the short id and the header shows "@id steer". 7. A background fan-out rendered as N live rows plus a batch row. dropCoveredBatches drops the batch record when its children are already present as live subagents. 8. applyAsyncList set a fresh array every 1.5s poll, re-rendering the TUI unconditionally. Guarded by a snapshot comparison. 9. The async_list RPC shipped whole records (context, goals, toolsets, session keys) for up to 50 entries. Projected server-side to the six fields the panel renders. Non-blocking: - appLayout.tsx no longer carries the no-op reformat; the diff there is a pure +5 addition. - @<id> now has a /help entry and completion support. - The steer text is echoed into the transcript, so there is a record of what was sent, not just "delivered → @id". - agentsPanel tests render through Ink with renderSync and assert on the painted frame instead of walking the returned element tree.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the substantial follow-up work; the bounded panel, projection, and real-Ink coverage address the earlier panel review well.
Problems
tools/delegate_tool.py:205-234treatsAIAgent.steer()returning true as a delivered steer. That method only queues_pending_steer(run_agent.py:3093-3127). If the child returns its final answer before another tool boundary, the runtime exposes the text aspending_steer(agent/turn_finalizer.py:615-620), but_run_single_child()builds its result fromfinal_responseand does not preserve it (tools/delegate_tool.py:2310-2419). The TUI can therefore showdeliveredfor steering the child never sees.- The branch predates the RPC split: current main owns the adjacent delegation handlers in
tui_gateway/methods_session.py:2773-2810after f67ca22, rather thantui_gateway/server.py. This needs a deliberate salvage, not a direct application.
Suggested changes
- Make the RPC reject or explicitly route a steer that cannot reach a future child decision point, and add a real child-final-response race regression.
- Rehome the RPC work into the current methods module during salvage.
Automated hermes-sweeper review.
Resolves the RPC split conflict flagged in review (NousResearch#70899): delegation.async_list, subagent.send, delegation.send, and _project_async_delegation move from tui_gateway/server.py into tui_gateway/methods_session.py, alongside where main already relocated delegation.status/pause and subagent.interrupt (f67ca22). Also merges the steer_fn/subagent_ids fields this branch added onto main's progress_fn/stale-monitor bookkeeping in tools/async_delegation.py and tools/delegate_tool.py — both sets of fields are independent and additive.
…C rehome Addresses the two blockers from teknium1's review (NousResearch#70899): - tools/delegate_tool.py:_run_single_child now checks result["pending_steer"] (turn_finalizer's leftover-steer handoff) and surfaces it as entry["missed_steer"] instead of silently dropping it. subagent.send/delegation.send can only report best-effort acceptance in real time — they can't know whether the child's current LLM call is its last (no future tool-call boundary to drain into). missed_steer is the correction once the true outcome is known: it rides the completion event (tools/async_delegation.py) and gets rendered into the re-injection block the model actually reads (tools/process_registry.py:_format_async_delegation), so a steer that never reached the child is visible instead of vanishing behind an uncontested "delivered: true". - _project_async_delegation, delegation.async_list, subagent.send, and delegation.send move from tui_gateway/server.py into tui_gateway/methods_session.py, next to delegation.status/pause and subagent.interrupt (already relocated there by main in f67ca22). _project_async_delegation itself stays a plain helper in server.py — HandlerRegistry.install() only rebinds @method-decorated functions onto server.py's globals, so a bare helper defined in the split module is invisible to a handler running with server.py's globals as its __globals__. Also merges main's stale-monitor/progress_fn additions in tools/async_delegation.py and tools/delegate_tool.py with this branch's steer_fn/subagent_ids fields (both sets are independent and additive), and drops the now-dead PasteSnippet/pasteSnips references in useSubmission.ts — main superseded paste snippets with the inline- attachments token system (expandTokens/tokensRef) while this branch was stale. Regression coverage: _run_single_child surfaces/omits missed_steer correctly, the completion event and its formatted re-injection text carry it end to end.
|
Update after maintainer review: we're taking this in, expanded. The plumbing here (stores, delegation.async_list/send RPCs, steer routing incl. the missed_steer race handling, replay snapshots) reviewed very well and will be salvaged with your authorship preserved. On top of it we're building out the presentation into a full tabbed "Mission Control" overlay (dashboard + live feed unified with the delegation live-transcript logs, timeline lanes, detail pane with budget meters, steer modal with quick actions, spawn tree, replay diff) — the docked strip stays as the ambient surface. Leaving this PR open as the salvage base; it needs a rebase (currently CONFLICTING) which we'll handle during the build, and the committed |
|
The expanded Mission Control direction sounds excellent. A unified dashboard/live feed, timelines, budgets, steering, spawn tree, and replay view could be a genuine killer feature for Hermes. This is not a request for the contributor to broaden this PR; it already addresses its stated TUI problem well. The question is for the maintainers at the product-architecture level: whether this Mission Control work and Hermes' existing Desktop/Dashboard subagent surfaces are intended to become one product or continue as separate implementations. I want to ask about the intended product boundary before the TUI implementation becomes the de facto owner of subagent orchestration. #74375 was closed partly because Mission Control was identified as the natural home for future subagent model selection, but the scope announced here is currently TUI-specific and does not include configuring the persistent model/provider/reasoning defaults used by future children. Displaying a running child's model in the detail pane is not the same capability as choosing what the next child will run on. Hermes also already has parallel pieces of this product:
If the full control set lands only here, the TUI becomes substantially more capable than Desktop and Dashboard for subagent orchestration, while those existing surfaces remain separate partial implementations. I cannot tell whether that divergence is intentional, temporary, or simply outside the announced scope. Could the maintainers outline the intended direction?
Even a rough roadmap or ownership boundary would help contributors avoid extending several increasingly divergent versions of the same product. Mission Control looks extremely promising; the missing piece is whether it is meant to unify Hermes' subagent experience across surfaces or make the TUI the one fully featured orchestration client. |
SummaryOne PR addresses #70894. #70899 substantially covers both reported causes with a bounded docked agents panel backed by async-delegation snapshots and live @id steering, including explicit handling of the terminal pending-steer race, but it remains a partial fix because the requested ^a path is absent and the branch requires conflict-aware salvage onto current main. Related pull requests
Suggested consolidationKeep open with a salvage path on #70899: preserve its reviewed RPC, store, panel, steering, missed_steer, replay, and test work while rebasing the conflicting branch onto current main, removing infographic/*.png, and integrating the planned Mission Control presentation. This follows the contributor's keep_open verdict and subsequent maintainer direction; there are no duplicate PRs to close. Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I70894(["issue #70894 (open)"])
P70899["PR #70899 (open)"]
P70899 -->|best fix| I70894
class I70894 open
class P70899 open
class P70899 best
class P70899 target
click I70894 "https://github.com/NousResearch/hermes-agent/issues/70894"
click P70899 "https://github.com/NousResearch/hermes-agent/pull/70899"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 1 pull request and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 146 kB of PR diffs, 12 kB of issue/PR text, 13 kB of discussion (10 comments), 2 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
Summary
Adds an inline, docked agents panel above the composer plus a live steering channel so a running subagent can be redirected by id (
@b7c2 switch approach) without killing it. Watch background delegations next to the conversation; steer one mid-flight.Closes #70894.
Motivation
Background delegation and the subagent tree were disconnected: the parked turn was a one-line hint, and the rich tree was a full-screen
/agentsmodal that hid the conversation. There was no way to watch background agents inline, and the only lever on a wrong-headed background fixer wasx(kill). This adds visibility and a steering lever.~80% of the work is reprojection of data that already exists. The one genuinely new capability — inbound steering — reuses the child's existing
AIAgent.steer()drain and iteration-boundary interrupt check, so no new core-loop code is introduced.What changed
Phase 1 — docked read-only panel (low risk)
delegation.async_listRPC: pure read projection of the async-delegation registry (interrupt_fnalready stripped server-side).$asyncDelegationsstore +applyAsyncList; the existing 5s gateway tick populates it alongsidedelegation.status.agentsPanel.tsx: merges live in-turn subagents (turnStore.subagents, freshest tool + elapsed) with background/async rows into one list. Renders nothing when there are no agents.STATUS_GLYPHinto a shared module so the panel and the/agentsoverlay never diverge visually.LiveTodoPanel; header click collapses;^aopens the full tree.Phase 2 — live steering (
@id)send_to_subagentmirrorsinterrupt_subagent: looks the child up in the live registry and callsAIAgent.steer(text). The child drains it at its next iteration boundary and appends it as a delimited out-of-band user turn on the last tool message — never spliced mid-tool — so role alternation and the prompt cache stay intact.subagent.sendRPC.@<id> textto steering only when the token resolves to a live subagent (resolveSteerTargetId), so an ordinary prompt likeemail @john laterfalls through to a normal turn untouched. Feedback toast:delivered → @b7c2/@b7c2 already finished.Design decisions (deliberate scope calls)
send_to_subagent— the live registry carries no session key andinterrupt_subagentis already unguarded (holding the id is authority). Same trust model; a guard would require a new registry field. Noted as follow-up./agentsoverlay; the docked panel delivers the core value — visibility — without that risk.Testing
155 tests across 7 suites, all green (127 frontend + 28 backend).
agentRows.test.tsPANEL_MAX_ROWSbound ·DONE_LINGER_MSretirement ·dropCoveredBatchesasyncDelegationE2E.test.tsxtests/test_async_delegation_view.pysend_to_subagent· 50-thread concurrency · role-legality · status transitions · server-side projectionsubagentSteer.test.ts@idparsing · id/prefix resolution · ambiguity refusal · normal-text passthroughagentsPanel.test.tsxresult ready ⏎·/agentslabel · tree-click isolationsubmissionCore.test.tsdelegationStoreAsync.test.tsapplyAsyncListpopulate · null tolerance · replace-not-append · snapshot guardPanel tests render through Ink with
renderSyncand assert on the painted frame, not on the returned element tree — so the height bound, truncation and dedupe are checked as the terminal actually shows them.Regression: the pre-existing failures (
terminal*/editor/memoryTS suites and thetests/tools/test_async_delegation.pycases) were re-confirmed identical on a baseline worktree with this branch absent — a Python 3.14_initializerstdlib incompatibility plus unrelated suites, with zero references to the new code in any traceback. None of the failing files are touched by this PR. Build ✓ · lint ✓ · typecheck ✓.Provider evidence — steering does not break the prompt cache
The design claim that a steer injected at the tool-iteration boundary preserves the cached prefix was measured against the live provider (Anthropic direct,
claude-haiku-4-5-20251001, OAuth), not just asserted in mocks:cache_warmcache_readcache_safe_steeringThe steering call reads back the same 6896-token prefix as the plain read — injecting the steer message costs 78 extra input tokens and zero cache invalidation. Total spend: $0.0102052 against a $0.05 cap.
Negative control: an orphan
tool_result(role alternation deliberately broken) was rejected with HTTP 400BadRequestError. That is precisely the failure the queue-drain-at-iteration-boundary design avoids, so the guard is load-bearing rather than defensive decoration.Files (26 changed · +3114 / −29 · 25 code + 1 infographic)
Backend:
tools/async_delegation.py·tools/delegate_tool.py·tui_gateway/server.pyFrontend:
components/agentsPanel.tsx·components/agentsOverlay.tsx·components/appLayout.tsx·lib/agentRows.ts·lib/subagentGlyph.ts·lib/subagentSteer.ts·app/delegationStore.ts·app/turnStore.ts·app/submissionCore.ts·app/useSubmission.ts·app/useMainApp.ts·app/createGatewayEventHandler.ts·hooks/useCompletion.ts·content/hotkeys.ts·gatewayTypes.tsTests: the 7 suites above.
🖼️ Architecture at a glance