Skip to content

fix: deliver leftover /steer as out-of-band user message (#60543) - #60555

Open
astraltrekkin wants to merge 2 commits into
NousResearch:mainfrom
astraltrekkin:fix/steer-leftover-oob-marker
Open

astraltrekkin wants to merge 2 commits into
NousResearch:mainfrom
astraltrekkin:fix/steer-leftover-oob-marker

Conversation

@astraltrekkin

@astraltrekkin astraltrekkin commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes a /steer message being lost / delivered without its trust marker when it arrives after the final assistant turn.

/steer has three delivery paths. Two wrap the user's message in the [OUT-OF-BAND USER MESSAGE] marker so the model treats it as a genuine user instruction rather than tool output / prompt injection (apply_pending_steer_to_tool_results, and the pre-API drain in conversation_loop.py). The third — the "leftover" path, taken when a steer lands after the final assistant turn with no remaining tool batch to drain into — did not. turn_finalizer.py hands the raw steer back as result["pending_steer"], and both consumers delivered it verbatim as the next user turn, so the model saw bare /steer text instead of the marker.

On the gateway path there was a sharper failure: its downstream slash-command discard net drops any pending text starting with /, so a leftover steer whose text begins with / (e.g. steering with /stop the search) was silently discarded. Wrapping it in the marker means it no longer starts with /, so it survives and is delivered.

Approach: add one shared helper next to the existing steer helpers and route both consumers through it, rather than duplicating the wrap at each call site.

Related Issue

Fixes #60543

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • agent/agent_runtime_helpers.py — add format_leftover_steer_for_delivery() (format_steer_marker(text).strip(); the marker is the whole standalone message here, so the append-oriented leading newlines are stripped) and export it.
  • cli.py — CLI REPL leftover handler now delivers format_leftover_steer_for_delivery(_leftover_steer) instead of the raw text.
  • gateway/run.py — gateway leftover handler (TUI / Telegram / Slack / WhatsApp) routed through the same helper.
  • tests/run_agent/test_steer_leftover_marker.py — new regression suite (6 cases).

How to Test

  1. In the TUI, start a conversation that triggers parallel tool calls.
  2. Send /steer "..." so it lands after the final tool batch (the leftover window).
  3. Confirm it reaches the model wrapped as [OUT-OF-BAND USER MESSAGE], not as raw /steer text — and that a steer beginning with / is delivered rather than silently dropped.

Automated: tests/run_agent/test_steer_leftover_marker.py covers both delivery paths, the marker contract, and the gateway slash-command net. Each assertion was confirmed to fail against the pre-fix raw passthrough (red → green).

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — CI Python tests green on Linux (amd64 + arm64); targeted steer/finalizer/gateway suites (52) pass locally on macOS.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 26.5 (arm64), Python 3.12

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — new helper is documented via docstring; no user-facing docs affected — or N/A
  • N/A — no config keys added/changed (cli-config.yaml.example)
  • N/A — no architecture/workflow changes (CONTRIBUTING.md / AGENTS.md)
  • I've considered cross-platform impact (Windows, macOS) — change is platform-agnostic (no file I/O, process, or shell surface touched)
  • N/A — no tool behavior/schema changes

Screenshots / Logs

tests/run_agent/test_steer_leftover_marker.py — 6 passed. Full pytest tests/ -q result noted in the checklist above.

…usResearch#60543)

A /steer that lands after the final assistant turn — with no remaining tool
batch to drain into — is returned as result["pending_steer"] and delivered as
the next user turn. Both delivery sites forwarded it as raw text, so the model
received bare /steer text instead of the [OUT-OF-BAND USER MESSAGE] marker the
mid-batch and pre-API drain paths use. On the gateway path, a steer whose text
starts with "/" could additionally be swallowed by the slash-command discard net.

Add format_leftover_steer_for_delivery() beside the existing steer helpers and
route both consumers (cli.py, gateway/run.py) through it.

Tests: tests/run_agent/test_steer_leftover_marker.py (6 cases) cover both paths,
the marker contract, and the gateway slash-command net. 52 passing across the
steer, concurrent-interrupt, tui-gateway-queue and turn_finalizer suites.
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 7, 2026
israellot added a commit to YallaPlay/hermes-agent that referenced this pull request Jul 11, 2026
…pping it

A /steer that lands after the final tool batch (e.g. during the final
API call when the model returns text with no further tool calls) cannot
be injected into any tool result. run_conversation() hands it back in
result["pending_steer"]; gateway/run.py and cli.py re-deliver it as the
next user turn, but the desktop/web surface (tui_gateway) never read it
— the UI accepted the steer (showed 'queued'), then silently lost it.

Add _deliver_leftover_steer(): capture pending_steer in
_run_prompt_submit's run() closure and dispatch it in the turn tail,
after _drain_queued_prompt (an explicit queued/interrupt prompt wins).
If a fresh user turn already claimed the session, re-stash the steer
via agent.steer() so that turn's own drain picks it up.

Upstream context: NousResearch#60543 tracks the leftover-
steer mechanism for CLI/gateway (PR NousResearch#60555, unmerged); the tui_gateway
consumer gap is unreported upstream as of c552984.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for covering the verified CLI and gateway raw-delivery paths. Current main still has the raw passthroughs at cli.py:12884-12888 and gateway/run.py:20028-20032, and the marker approach preserves the existing trusted-steer contract from agent/prompt_builder.py:595-614.

Problems

  • The standard Ink TUI remains unfixed. It sends /steer through tui_gateway/server.py:8427-8449, while _run_prompt_submit() calls run_conversation() at tui_gateway/server.py:9103 and processes the result at 9148-9205 without reading pending_steer; the tail only drains queued prompts at 9376-9380. This still drops a leftover steer for the reported TUI flow.
  • The regression tests duplicate the intended delivery statements (tests/run_agent/test_steer_leftover_marker.py:198-202 and 229-250) instead of invoking cli.py or gateway/run.py, so they do not prove those production call sites are wired.

Suggested changes

  • Add the tui_gateway consumer after _drain_queued_prompt() so an explicit queued prompt retains priority.
  • Test the actual CLI, gateway, and TUI delivery seams, including a /stop … leftover through the gateway safety net.

Automated hermes-sweeper review.

pending_input: "queue.Queue[str]" = queue.Queue()
_leftover_steer = result.get("pending_steer") if result else None
if _leftover_steer:
pending_input.put(format_leftover_steer_for_delivery(_leftover_steer))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This mirrors the proposed CLI statement rather than invoking the CLI handler, so it still passes if cli.py forwards raw pending_steer. Please exercise a production delivery seam (or a helper actually called by it) to make the regression test wiring-sensitive.

@teknium1 teknium1 added the sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users label Jul 15, 2026
@astraltrekkin

Copy link
Copy Markdown
Contributor Author

@teknium1 Adressed both:

  1. Ink TUI gap: The leftover steer was still dropped in the Ink TUI. Fixed by delivering it after queued prompts, with the same OOB marker.
  2. Tests: they weren't wired to the real call sites. Updated them to hit the CLI / gateway / TUI seams, including the /stop leftover case

Cover the Ink TUI leftover path after queued prompts, and exercise the
real CLI/gateway/TUI seams so regressions catch raw pending_steer passthrough.

Co-authored-by: Cursor <cursoragent@cursor.com>
@null-runner

Copy link
Copy Markdown
Contributor

Verified on a clean origin/main checkout (worktree, patch applied via git apply).

Repro on the tui_gateway/desktop path (independent of the CLI/gateway repro in #60543): a /steer landing in the turn-teardown window (after message.complete is emitted, before running is reset to False) is stashed by agent.steer(); the turn ends without consuming it and turn_finalizer returns it in result["pending_steer"]. On main, gateway/run.py consumes result["pending_steer"] but tui_gateway/server.py has zero references to it (git grep pending_steer origin/main -- tui_gateway/server.py = 0), so on the Ink/desktop surface the leftover steer is silently dropped. _deliver_leftover_steer closes that path.

Semantics match the gateway path: all three seams (cli/gateway/tui) now wrap the leftover via the shared format_leftover_steer_for_delivery (OOB marker); queued prompts keep priority (_deliver_leftover_steer runs after _drain_queued_prompt and re-checks session["running"] under history_lock, mirroring resolve_gateway_leftover_steer yielding when pending/pending_event is set); the turn is claimed atomically to avoid a double-fire.

Tests: tests/run_agent/test_steer_leftover_marker.py = 11/11 pass, including the TUI seam tests. The rest of tests/tui_gateway/ is green except test_subagent_child_mirror::test_prompt_submit_rejected_while_child_run_active and test_projects_rpc::test_discover_repos_from_full_history, which are pre-existing flakes — they fail under the full suite on unpatched origin/main too and pass in isolation, unrelated to this change.

Minimal fix for #60543 on the tui path; #65205 covers the broader queue unification (agent-side TurnQueue).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/steer race condition: steer lost when arriving between tool batch drain and next API call

4 participants