Skip to content

fix(delegation): surface a child's undelivered steer instead of dropping it - #76805

Merged
teknium1 merged 3 commits into
NousResearch:mainfrom
TheSmokeDev:feat/steer-subagent
Aug 6, 2026
Merged

fix(delegation): surface a child's undelivered steer instead of dropping it#76805
teknium1 merged 3 commits into
NousResearch:mainfrom
TheSmokeDev:feat/steer-subagent

Conversation

@TheSmokeDev

@TheSmokeDev TheSmokeDev commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

The dropped signal

agent/turn_finalizer.py:683-685 already guards against losing a steer that queues after the final tool batch:

_leftover_steer = agent._drain_pending_steer()
if _leftover_steer:
    result["pending_steer"] = _leftover_steer

"If a /steer landed after the final assistant turn (no more tool batches to drain into), hand it back to the caller so it can be delivered as the next user turn instead of being silently lost."

Every interactive surface honors that contract — cli.py, gateway/run.py, and tui_gateway/server.py all requeue the leftover text as the next user turn.

The delegation layer doesn't. _run_single_child in tools/delegate_tool.py never reads result["pending_steer"], so the completion entry the parent receives carries no trace of it. And there's no sanctioned sender either: the registry exposes interrupt_subagent() but no redirection-side mirror, and session.steer can't reach a delegated child — the desktop opens children as lazy watch sessions with agent=None, so it returns 4010.

Net: you can kill a running child, but you can't redirect one — and if you could, the finish-first race would silently lose the text, which is exactly the loss the finalizer contract exists to prevent.

The fix — both halves of the contract

Sendersteer_subagent(subagent_id, text) in tools/delegate_tool.py, the redirection-side mirror of interrupt_subagent(): resolves the live child in _active_subagents and queues text via AIAgent.steer(). The in-flight tool call is never cut; the child sees the text as an out-of-band user message at its next iteration boundary. True means queued, not delivered. Fronted by a subagent.steer gateway RPC beside subagent.interrupt (tui_gateway/methods_session.py) so programmatic hosts — dashboard, voice layers, ACP bridges — have an in-tree caller. Subagent ids come from delegation.status, same as subagent.interrupt.

Receiver — missed-steer retention in _run_single_child: when the child's result carries pending_steer, the completion entry retains it as missed_steer with a note appended to the summary:

[steer did not land — the subagent finished before it could be delivered: <text>]

The parent can tell a steered child from one that finished on the old instructions, and re-issue the guidance instead of trusting it landed.

Docs

  • "Steering a Running Subagent" section in website/docs/user-guide/features/delegation.md (queued-vs-delivered semantics, the race, the missed_steer contract)
  • subagent.steer added to the method catalog in website/docs/developer-guide/programmatic-integration.md

Tests

tests/tools/test_subagent_steer.py:

  • registry level: text reaches the live child; unknown id / empty text / dead record / raising agent all degrade to False, never an exception
  • the race: a child that finishes with pending_steer set produces a completion entry carrying missed_steer + the summary note, status unaffected; a clean run leaves the entry untouched
  • RPC contract: 4000 (missing subagent_id), 4002 (empty text), queued and rejected envelopes

All green on the touched paths: tests/tools/test_subagent_steer.py (12), tests/tools/test_delegate.py + tests/run_agent/test_steer.py (89), tui_gateway protocol + delegation lifecycle (52).

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/tools Tool registry, model_tools, toolsets comp/tui Terminal UI (ui-tui/ + tui_gateway/) tool/delegate Subagent delegation needs-decision Awaiting maintainer decision before any implementation sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 2, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #70899 already exposes ID-addressed live-child steering in the TUI, while #76512 proposes parent-scoped model controls. This smaller subagent.steer RPC is an unscoped primitive, so the maintainer should choose the authority and RPC contract rather than treat these as duplicates.

@teknium1 teknium1 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.

Thanks for the focused live-child steering seam. The premise is real on current main: tools/delegate_tool.py:183-216 and tui_gateway/methods_session.py:2806-2814 provide interruption only.

Problems

  • tools/delegate_tool.py (proposed return bool(agent.steer(text))) can acknowledge a steer that never reaches the child. AIAgent.steer() queues text without checking for a remaining injection boundary (run_agent.py:3153-3169); the finalizer returns late text as pending_steer (agent/turn_finalizer.py:680-685), but _run_single_child does not retain it in its completion entry (tools/delegate_tool.py:2310-2417).
  • The proposed RPC is unscoped: current registry records have no owner/session field (tools/delegate_tool.py:2092-2107), and the endpoint accepts only an ID and text. The existing discussion correctly identifies #70899 and #76512 as requiring an explicit authority/RPC decision.

Suggested changes

  • Handle the final-answer race with a truthful missed/undelivered result and regression coverage.
  • Settle and implement the authority contract before exposing the RPC.
  • Add server.handle_request coverage for RPC validation and result shapes, alongside the helper tests.

This is an automated hermes-sweeper review.

Comment thread tools/delegate_tool.py Outdated
return False
try:
return bool(agent.steer(text))
except Exception as exc:

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.

AIAgent.steer() accepts by queuing _pending_steer, even after the final tool boundary; the turn finalizer then returns it as pending_steer. _run_single_child currently drops that field when it builds the completion entry, so this can return True for guidance the child never sees. Please make the final-answer race observable or reject it before reporting acceptance.

Comment thread tui_gateway/methods_session.py Outdated
@@ -2814,6 +2814,20 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"found": ok, "subagent_id": subagent_id})


@method("subagent.steer")

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.

This endpoint has no invoking-session/tree input, while active-child records on current main have no owner field. Please resolve the intended authority model with the related #70899/#76512 work before exposing an unscoped mutation RPC.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Aug 2, 2026
@TheSmokeDev
TheSmokeDev force-pushed the feat/steer-subagent branch from ebebbe1 to cc3e332 Compare August 2, 2026 18:55
@TheSmokeDev

Copy link
Copy Markdown
Contributor Author

Both problems addressed in cc3e332c8 (rebased onto current main):

  1. The final-answer race is now named, not swallowed. steer_subagent() documents queued-not-delivered; _run_single_child retains the finalizer's pending_steer hand-back in the completion entry as missed_steer and appends a note to the summary, so the parent sees a steer that never landed. Two regression tests drive the real delegate_task path (race present → named with status intact; clean run → untouched).

  2. The unscoped RPC is gone. Removed subagent.steer from the TUI gateway rather than argue authority here — that contract belongs with feat(tui): async delegation view — docked agents panel + live steering #70899/feat(delegation): add parent-scoped live controls #76512, and the first consumer (hermes-talk) calls the module function in-process, so this PR is now the primitive only.

tests/tools/test_subagent_steer.py 8/8; tests/tools/test_delegate.py 60/60.

@TheSmokeDev
TheSmokeDev force-pushed the feat/steer-subagent branch from cc3e332 to 6f6feff Compare August 2, 2026 21:57
@TheSmokeDev TheSmokeDev changed the title feat(delegate): steer_subagent — redirect a live child without stopping it feat(tools): steer_subagent — redirect a live delegated child without stopping it Aug 2, 2026
@TheSmokeDev

Copy link
Copy Markdown
Contributor Author

Housekeeping on the comment above: after posting it I squashed the branch to a single commit per the contributing guide's one-logical-change-per-PR rule and corrected the conventional-commit scope (delegate isn't in the documented scope list; it's tools). So the current head is 6f6feff9a, not the cc3e332c8 I referenced — same content, one commit instead of two. Also ran scripts/check-windows-footguns.py over both changed files: clean. Tests unchanged: 8 + 60 regression.

Related context, since it bears on how you'd want to take this: I've opened #77111, an RFC proposing a RealtimeVoiceProvider ABC, on the grounds that four open PRs are independently building duplex voice and AGENTS.md calls for an interface once 3+ PRs hit the same category.

This PR stands on its own — steer_subagent is useful to anything that delegates, voice or not — but if the ABC discussion goes anywhere, this is the kind of primitive a realtime surface would build on. Sequencing is yours; happy to hold this behind that decision if you'd rather settle the category first.

@TheSmokeDev
TheSmokeDev force-pushed the feat/steer-subagent branch from 6f6feff to a9ca4fb Compare August 3, 2026 03:54
TheSmokeDev added a commit to TheSmokeDev/hermes-talk that referenced this pull request Aug 3, 2026
Say 'tell that audit to focus on the token refresh instead' and the note
reaches the agent after its next tool call. Not an interrupt: the current
step always finishes, and a child already past its last tool call is
reported as such rather than left to look like it landed.

Only the attached lane can carry a steer, so the other two answer with
their own reason instead of a generic failure — the api_server lane
exposes stop and nothing else, and a detached one-shot has no inbound
channel at all. Both offer the thing that lane CAN do.

On the attached lane it prefers the host's own steer_subagent tool
(NousResearch/hermes-agent#76805) and falls back to resolving the same
delegation registry directly, since AIAgent.steer() and _active_subagents
have shipped in main far longer than the tool that addresses a child by
id. A genuine host error — paused delegation, a depth limit — is spoken,
never routed around.

23 tests, one per lane and per bridge branch.
@TheSmokeDev
TheSmokeDev force-pushed the feat/steer-subagent branch from a9ca4fb to 3488323 Compare August 3, 2026 14:53
@TheSmokeDev TheSmokeDev changed the title feat(tools): steer_subagent — redirect a live delegated child without stopping it fix(delegation): surface a child's undelivered steer instead of dropping it Aug 3, 2026
@TheSmokeDev

Copy link
Copy Markdown
Contributor Author

@teknium1 this one's ready for eyes. Rebased on current main, CI green (38 checks), +296/-1.

Short version: turn_finalizer.py:683 already returns undelivered steer text as result["pending_steer"] with a comment promising it's "never silently lost", and cli/gateway/tui_gateway all honor that. Delegation doesn't: _run_single_child drops it, and there's no way to steer a child at all (session.steer 4010s on lazy watch sessions, the registry only has interrupt_subagent).

This completes that contract for delegated children: steer_subagent() as the redirection-side mirror of interrupt_subagent(), a subagent.steer gateway RPC beside subagent.interrupt as the in-tree caller, and missed_steer retention on the completion entry so the finish-first race is named instead of swallowed. Docs in delegation.md + the RPC catalog. Full receipts in the description.

…ing it

The turn finalizer already hands back steer text that queued after the
final tool batch — result["pending_steer"], with the comment "hand it
back to the caller so it can be delivered as the next user turn instead
of being silently lost." Every interactive surface honors that contract
(cli.py, gateway/run.py, tui_gateway/server.py all requeue it). The
delegation layer doesn't: _run_single_child never reads it, so a steer
queued into a delegated child that finishes first vanishes with no trace
in the completion entry. There is also no sanctioned sender: the registry
has interrupt_subagent() but no redirection-side mirror, and session.steer
cannot reach children (lazy watch sessions have agent=None, so it 4010s).

Complete the contract for delegated children — both halves:

- steer_subagent(subagent_id, text): redirection-side mirror of
  interrupt_subagent(). Resolves the live child in _active_subagents and
  queues text via AIAgent.steer(). True means queued, not delivered.
- missed_steer retention: when the child's result carries pending_steer,
  _run_single_child names it on the completion entry (missed_steer field
  plus a summary note) so the parent can re-issue the guidance instead of
  trusting it landed. This is what makes adding a sender safe: without it
  the finish-before-drain race silently loses the text — the exact loss
  the finalizer contract exists to prevent.
- subagent.steer gateway RPC beside subagent.interrupt so programmatic
  hosts (dashboard, voice layers, ACP bridges) get an in-tree caller;
  catalogued in programmatic-integration.md.
- docs: "Steering a Running Subagent" section in delegation.md covering
  the queued-vs-delivered semantics.

Tests: registry-level steer coverage (delivery, unknown id, empty text,
dead record, raising agent), the finish-before-drain race retaining
missed_steer, and the RPC contract (4000/4002 validation, queued and
rejected envelopes).
@TheSmokeDev
TheSmokeDev force-pushed the feat/steer-subagent branch from 3488323 to 0c84ba7 Compare August 6, 2026 03:25
@TheSmokeDev

Copy link
Copy Markdown
Contributor Author

Final remediation is complete on 974cec6d; the branch is current, mergeable, and fresh CI is fully green.

  • Steering acceptance is linearized with completion: accepted pre-close text is consumed or retained as exact pending_steer/missed_steer; post-close calls reject.
  • Gateway authority is now bound to the exact commissioning transport object, live session-record generation, and session ID. Foreign transports, recycled/rebound sessions, missing runtime artifacts, and spoofed parameters fail closed.
  • Stale child cleanup is object-identity guarded; internal authority artifacts are omitted from public status.

Evidence: 30 focused steering tests, 518 gateway tests, 151 broad delegate/async tests, and 118 independent lifecycle/ownership tests passed; Ruff/compile/diff checks passed; independent final QA found no blocker/major.

@teknium1
teknium1 merged commit 9d4ef04 into NousResearch:main Aug 6, 2026
43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets comp/tui Terminal UI (ui-tui/ + tui_gateway/) needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/delegate Subagent delegation type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants