mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(steer): /steer <prompt> injects a mid-run note after the next tool call (#12116)
* feat(steer): /steer <prompt> injects a mid-run note after the next tool call Adds a new slash command that sits between /queue (turn boundary) and interrupt. /steer <text> stashes the message on the running agent and the agent loop appends it to the LAST tool result's content once the current tool batch finishes. The model sees it as part of the tool output on its next iteration. No interrupt is fired, no new user turn is inserted, and no prompt cache invalidation happens beyond the normal per-turn tool-result churn. Message-role alternation is preserved — we only modify an existing role:"tool" message's content. Wiring ------ - hermes_cli/commands.py: register /steer + add to ACTIVE_SESSION_BYPASS_COMMANDS. - run_agent.py: add _pending_steer state, AIAgent.steer(), _drain_pending_steer(), _apply_pending_steer_to_tool_results(); drain at end of both parallel and sequential tool executors; clear on interrupt; return leftover as result['pending_steer'] if the agent exits before another tool batch. - cli.py: /steer handler — route to agent.steer() when running, fall back to the regular queue otherwise; deliver result['pending_steer'] as next turn. - gateway/run.py: running-agent intercept calls running_agent.steer(); idle-agent path strips the prefix and forwards as a regular user message. - tui_gateway/server.py: new session.steer JSON-RPC method. - ui-tui: SessionSteerResponse type + local /steer slash command that calls session.steer when ui.busy, otherwise enqueues for the next turn. Fallbacks --------- - Agent exits mid-steer → surfaces in run_conversation result as pending_steer so CLI/gateway deliver it as the next user turn instead of silently dropping it. - All tools skipped after interrupt → re-stashes pending_steer for the caller. - No active agent → /steer reduces to sending the text as a normal message. Tests ----- - tests/run_agent/test_steer.py — accept/reject, concatenation, drain, last-tool-result injection, multimodal list content, thread safety, cleared-on-interrupt, registry membership, bypass-set membership. - tests/gateway/test_steer_command.py — running agent, pending sentinel, missing steer() method, rejected payload, empty payload. - tests/gateway/test_command_bypass_active_session.py — /steer bypasses the Level-1 base adapter guard. - tests/test_tui_gateway_server.py — session.steer RPC paths. 72/72 targeted tests pass under scripts/run_tests.sh. * feat(steer): register /steer in Discord's native slash tree Discord's app_commands tree is a curated subset of slash commands (not derived from COMMAND_REGISTRY like Telegram/Slack). /steer already works there as plain text (routes through handle_message → base adapter bypass → runner), but registering it here adds Discord's native autocomplete + argument hint UI so users can discover and type it like any other first-class command.
This commit is contained in:
parent
f9667331e5
commit
2edebedc9e
12 changed files with 826 additions and 2 deletions
|
|
@ -438,3 +438,74 @@ def test_rollback_restore_resolves_number_and_file_path():
|
|||
assert resp["result"]["success"] is True
|
||||
assert calls["args"][1] == "bbb222"
|
||||
assert calls["args"][2] == "src/app.tsx"
|
||||
|
||||
|
||||
# ── session.steer ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_session_steer_calls_agent_steer_when_agent_supports_it():
|
||||
"""The TUI RPC method must call agent.steer(text) and return a
|
||||
queued status without touching interrupt state.
|
||||
"""
|
||||
calls = {}
|
||||
|
||||
class _Agent:
|
||||
def steer(self, text):
|
||||
calls["steer_text"] = text
|
||||
return True
|
||||
|
||||
def interrupt(self, *args, **kwargs):
|
||||
calls["interrupt_called"] = True
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.steer",
|
||||
"params": {"session_id": "sid", "text": "also check auth.log"},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
assert "result" in resp, resp
|
||||
assert resp["result"]["status"] == "queued"
|
||||
assert resp["result"]["text"] == "also check auth.log"
|
||||
assert calls["steer_text"] == "also check auth.log"
|
||||
assert "interrupt_called" not in calls # must NOT interrupt
|
||||
|
||||
|
||||
def test_session_steer_rejects_empty_text():
|
||||
server._sessions["sid"] = _session(agent=types.SimpleNamespace(steer=lambda t: True))
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.steer",
|
||||
"params": {"session_id": "sid", "text": " "},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
assert "error" in resp, resp
|
||||
assert resp["error"]["code"] == 4002
|
||||
|
||||
|
||||
def test_session_steer_errors_when_agent_has_no_steer_method():
|
||||
server._sessions["sid"] = _session(agent=types.SimpleNamespace()) # no steer()
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.steer",
|
||||
"params": {"session_id": "sid", "text": "hi"},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
assert "error" in resp, resp
|
||||
assert resp["error"]["code"] == 4010
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue