diff --git a/CHANGELOG.md b/CHANGELOG.md index 2236b708..b8c5a7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## v0.50.215 — 2026-04-26 + +### Added +- **Real `/steer` command** — wires `/steer ` through the agent's thread-safe `agent.steer()` method rather than falling back to interrupt. Steer text is stashed in `_pending_steer` and injected into the next tool-result boundary without interrupting the current run, giving the agent a mid-turn course correction. New `/api/chat/steer` POST endpoint with five graceful fallback reasons (`no_cached_agent`, `agent_lacks_steer`, `session_not_found`, `not_running`, `stream_dead`) — any fallback transparently falls back to the existing interrupt+queue mechanism. (`api/routes.py`, `api/streaming.py`, `static/commands.js`, `static/messages.js`, `static/i18n.js`) Closes #720 follow-up [#1066 @nesquena] +- **Steer leftover delivery** — if the agent finishes its turn before hitting a tool boundary, the stashed steer text is drained and emitted as a `pending_steer_leftover` SSE event; the frontend queues it as a next-turn message, mirroring the CLI's existing leftover path. (`api/streaming.py`, `static/messages.js`) [#1066] + +### Fixed +- **Pending files preserved on steer→interrupt fallback** — the busy-mode steer path in `send()` now defers `S.pendingFiles=[]` until after `_trySteer()` returns, so staged file attachments are not lost when the steer endpoint falls back to interrupt+queue. (`static/messages.js`) + ## v0.50.214 — 2026-04-26 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 38ff5387..523f73a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ > Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI. > Everything you can do from the CLI terminal, you can do from this UI. > -> Last updated: v0.50.214 (April 26, 2026) — 2299 tests collected +> Last updated: v0.50.215 (April 26, 2026) — 2319 tests collected > Tests: 2107 collected (`pytest tests/ --collect-only -q`) > Source: / diff --git a/TESTING.md b/TESTING.md index ea140b7f..cbb814ab 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. > -> Automated coverage: 2276 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation. +> Automated coverage: 2319 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation. > Run: `pytest tests/ -v --timeout=60` > > Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash. diff --git a/api/routes.py b/api/routes.py index ecdcbd39..f7389af8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1345,6 +1345,10 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/chat": return _handle_chat_sync(handler, body) + if parsed.path == "/api/chat/steer": + from api.streaming import _handle_chat_steer + return _handle_chat_steer(handler, body) + # ── Cron API (POST) ── if parsed.path == "/api/crons/create": return _handle_cron_create(handler, body) diff --git a/api/streaming.py b/api/streaming.py index a4b0e360..c6218119 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1936,6 +1936,23 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 0) or 0 usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0 # (reasoning trace already attached + saved above, before s.save()) + # Leftover-steer delivery: if a /steer was queued (via + # api/chat/steer) but the agent finished its turn before + # reaching a tool-result boundary that would consume it, + # the text is still stashed in agent._pending_steer. Drain + # it now and emit a pending_steer_leftover SSE event so the + # frontend can queue it for the next turn — same fallback + # path as the CLI in cli.py:8788-8794. + try: + _drain_pending_steer = getattr(agent, '_drain_pending_steer', None) + _leftover = _drain_pending_steer() if _drain_pending_steer else None + if _leftover: + put('pending_steer_leftover', { + 'session_id': session_id, + 'text': str(_leftover), + }) + except Exception: + logger.debug("Failed to drain pending steer for session %s", session_id) raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls} put('done', {'session': redact_session_data(raw_session), 'usage': usage}) # Emit metering stats for the header TPS label @@ -2098,6 +2115,82 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta # ============================================================ +def _handle_chat_steer(handler, body: dict) -> bool: + """Inject a /steer payload into the active agent for a session. + + Mirrors the CLI's `/steer ` command (cli.py:6140-6155): + - Look up the cached AIAgent for the session (PR #1051's + SESSION_AGENT_CACHE). + - Verify a stream is currently active for this session. + - Call agent.steer(text) — thread-safe, stashes text in + _pending_steer for application at the next tool-result boundary. + + The agent's loop calls _apply_pending_steer_to_tool_results() at the + end of every tool batch and appends the steer text to the last tool + result's content with a marker, so the model sees the steer as part + of the tool output on its next iteration. The user's stream is NOT + interrupted. + + If no agent is cached, the agent is too old to support steer, or no + stream is active, return {"accepted": False, "fallback": ""} + so the frontend can fall back to interrupt or queue mode. The + fallback path is the existing behaviour from PR #1062. + + Returns 200 with {"accepted": bool, "fallback": str|None, + "stream_id": str|None}. + """ + from api.helpers import j, bad + from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK + + sid = str((body or {}).get("session_id", "") or "").strip() + text = str((body or {}).get("text", "") or "").strip() + if not sid: + return bad(handler, "session_id required") + if not text: + return bad(handler, "text required") + + with SESSION_AGENT_CACHE_LOCK: + cached = SESSION_AGENT_CACHE.get(sid) + if not cached: + # No active agent for this session — caller falls back to interrupt + return j(handler, {"accepted": False, "fallback": "no_cached_agent", + "stream_id": None}) + agent = cached[0] + if not hasattr(agent, "steer"): + # Older hermes-agent that pre-dates the steer() method + return j(handler, {"accepted": False, "fallback": "agent_lacks_steer", + "stream_id": None}) + + # Verify the agent is currently running. Use the session's + # active_stream_id rather than calling load_session_locked() which + # would block on the streaming thread's lock. + try: + s = get_session(sid) + except KeyError: + return j(handler, {"accepted": False, "fallback": "session_not_found", + "stream_id": None}) + active_stream_id = getattr(s, "active_stream_id", None) or None + if not active_stream_id: + return j(handler, {"accepted": False, "fallback": "not_running", + "stream_id": None}) + with STREAMS_LOCK: + stream_alive = active_stream_id in STREAMS + if not stream_alive: + # Active stream id is stale — stream has ended; caller falls back + return j(handler, {"accepted": False, "fallback": "stream_dead", + "stream_id": None}) + + try: + accepted = bool(agent.steer(text)) + except Exception as exc: + logger.debug("agent.steer() raised for session=%s: %s", sid, exc) + return j(handler, {"accepted": False, "fallback": "steer_error", + "stream_id": active_stream_id}) + + return j(handler, {"accepted": accepted, "fallback": None, + "stream_id": active_stream_id}) + + def cancel_stream(stream_id: str) -> bool: """Signal an in-flight stream to cancel. Returns True if the stream existed. diff --git a/static/commands.js b/static/commands.js index bbb9f973..e7d7cfdf 100644 --- a/static/commands.js +++ b/static/commands.js @@ -573,23 +573,68 @@ async function cmdInterrupt(args){ } /** - * /steer — Inject a steering hint mid-task. - * Currently falls back to interrupt behaviour because the WebUI cannot - * inject messages into an in-flight agent thread. Shows a toast to - * inform the user that true steering is not yet available. + * /steer — Inject a steering hint mid-task without interrupting. + * + * Calls POST /api/chat/steer which looks up the cached AIAgent for this + * session and calls agent.steer(text). The agent's run loop appends the + * steer text to the next tool-result message so the model sees it on its + * next iteration — same pathway as the CLI's /steer command. + * + * Falls back to interrupt mode when the agent isn't running, isn't cached, + * or doesn't support steer (older hermes-agent versions). */ async function cmdSteer(args){ const msg=(args||'').trim(); if(!msg){showToast(t('cmd_steer_no_msg'));return;} if(!S.busy||!S.activeStreamId){showToast(t('no_active_task'));return;} if(!S.session){showToast(t('no_active_session'));return;} - // True steer (inject without cancelling) requires agent-side support - // that is not yet available in the WebUI. Fall back to interrupt. + await _trySteer(msg, /*explicitSteer=*/true); +} + +/** + * Shared implementation for /steer and the busy_input_mode='steer' path. + * + * Tries the real steer endpoint first. On any non-accept response (no cached + * agent, agent lacks steer, stream dead, etc.) falls back to interrupt mode: + * queue the message + cancel the stream so the existing drain re-sends. + * + * @param {string} msg - The steer text. + * @param {boolean} explicitSteer - True if the user explicitly invoked /steer + * (vs the busy-mode auto-fallback). Affects toast wording only. + */ +async function _trySteer(msg, explicitSteer){ + let result=null; + try{ + result=await api('/api/chat/steer',{ + method:'POST', + body:JSON.stringify({session_id:S.session.session_id,text:msg}), + }); + }catch(e){ + // Network or server error — fall back to interrupt + result={accepted:false, fallback:'network_error'}; + } + if(result&&result.accepted){ + showToast(t('cmd_steer_delivered'),2500); + return; + } + // Fall back to interrupt: queue the message + cancel the stream so the + // drain in setBusy(false) re-sends it as a fresh turn. queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'}); updateQueueBadge(S.session.session_id); S.pendingFiles=[];renderTray(); if(typeof cancelStream==='function'){await cancelStream();} - showToast(t('cmd_steer_fallback'),2500); + // Toast wording differs based on why we're falling back so the user + // understands what just happened. + const reason=(result&&result.fallback)||'unknown'; + if(explicitSteer){ + showToast(t('cmd_steer_fallback'),2500); + } else if(reason==='no_cached_agent'||reason==='not_running'||reason==='stream_dead'){ + // Busy mode hit the steer path before the agent was ready — + // interrupt is the natural fallback, no need to call out steer. + showToast(t('busy_interrupt_confirm'),2000); + } else { + showToast(t('busy_steer_fallback'),2500); + } } async function cmdTitle(args){ diff --git a/static/i18n.js b/static/i18n.js index 467d00c3..f2c8c3b8 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -108,7 +108,9 @@ const LOCALES = { cmd_interrupt_no_msg: 'Usage: /interrupt ', cmd_interrupt_confirm: 'Interrupted — sending new message', cmd_steer_no_msg: 'Usage: /steer ', - cmd_steer_fallback: 'Steer is not yet available — interrupted and queued instead', + cmd_steer_fallback: 'Steer unavailable — interrupted and queued instead', + cmd_steer_delivered: 'Steer delivered — agent will see it on its next tool result', + steer_leftover_queued: 'Steer queued for next turn', busy_steer_fallback: 'Steer not available — interrupted instead', busy_interrupt_confirm: 'Interrupted — sending new message', settings_label_busy_input_mode: 'Busy input mode', @@ -734,7 +736,9 @@ const LOCALES = { cmd_interrupt_no_msg: 'Использование: /interrupt <сообщение>', cmd_interrupt_confirm: 'Прервано — отправка нового сообщения', cmd_steer_no_msg: 'Использование: /steer <сообщение>', - cmd_steer_fallback: 'Steer пока недоступен — прервано и поставлено в очередь', + cmd_steer_fallback: 'Steer недоступен — прервано и поставлено в очередь', + cmd_steer_delivered: 'Steer доставлен — агент увидит его в следующем ответе инструмента', + steer_leftover_queued: 'Steer поставлен в очередь на следующий ход', busy_steer_fallback: 'Steer недоступен — прервано', busy_interrupt_confirm: 'Прервано — отправка нового сообщения', settings_label_busy_input_mode: 'Режим ввода при занятости', @@ -1343,7 +1347,9 @@ const LOCALES = { cmd_interrupt_no_msg: 'Uso: /interrupt ', cmd_interrupt_confirm: 'Interrumpido \u2014 enviando nuevo mensaje', cmd_steer_no_msg: 'Uso: /steer ', - cmd_steer_fallback: 'Steer no disponible a\u00fan \u2014 interrumpido y encolado', + cmd_steer_fallback: 'Steer no disponible \u2014 interrumpido y encolado', + cmd_steer_delivered: 'Steer entregado \u2014 el agente lo ver\u00e1 en su pr\u00f3ximo resultado de herramienta', + steer_leftover_queued: 'Steer en cola para el pr\u00f3ximo turno', busy_steer_fallback: 'Steer no disponible \u2014 interrumpido', busy_interrupt_confirm: 'Interrumpido \u2014 enviando nuevo mensaje', settings_label_busy_input_mode: 'Modo de entrada ocupada', @@ -1921,7 +1927,9 @@ const LOCALES = { cmd_interrupt_no_msg: 'Verwendung: /interrupt ', cmd_interrupt_confirm: 'Unterbrochen \u2014 neue Nachricht wird gesendet', cmd_steer_no_msg: 'Verwendung: /steer ', - cmd_steer_fallback: 'Steer noch nicht verf\u00fcgbar \u2014 unterbrochen und eingereiht', + cmd_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 unterbrochen und eingereiht', + cmd_steer_delivered: 'Steer geliefert \u2014 der Agent sieht es bei seinem n\u00e4chsten Tool-Ergebnis', + steer_leftover_queued: 'Steer f\u00fcr n\u00e4chsten Durchgang eingereiht', busy_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 unterbrochen', busy_interrupt_confirm: 'Unterbrochen \u2014 neue Nachricht wird gesendet', settings_label_busy_input_mode: 'Eingabemodus bei Besch\u00e4ftigung', @@ -2289,7 +2297,9 @@ const LOCALES = { cmd_interrupt_no_msg: '\u7528\u6cd5\uff1a/interrupt <\u6d88\u606f>', cmd_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u53d1\u9001\u65b0\u6d88\u606f', cmd_steer_no_msg: '\u7528\u6cd5\uff1a/steer <\u6d88\u606f>', - cmd_steer_fallback: 'Steer \u5c1a\u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad\u5e76\u52a0\u5165\u961f\u5217', + cmd_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad\u5e76\u52a0\u5165\u961f\u5217', + cmd_steer_delivered: 'Steer \u5df2\u4ea4\u4ed8 \u2014 \u4ee3\u7406\u5c06\u5728\u4e0b\u4e00\u4e2a\u5de5\u5177\u7ed3\u679c\u4e2d\u770b\u5230', + steer_leftover_queued: 'Steer \u5df2\u52a0\u5165\u4e0b\u8f6e\u961f\u5217', busy_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad', busy_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u53d1\u9001\u65b0\u6d88\u606f', settings_label_busy_input_mode: '\u5fd9\u788c\u8f93\u5165\u6a21\u5f0f', @@ -3212,7 +3222,9 @@ const LOCALES = { cmd_interrupt_no_msg: '\u7528\u6cd5\uff1a/interrupt <\u8a0a\u606f>', cmd_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u767c\u9001\u65b0\u8a0a\u606f', cmd_steer_no_msg: '\u7528\u6cd5\uff1a/steer <\u8a0a\u606f>', - cmd_steer_fallback: 'Steer \u5c1a\u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad\u4e26\u52a0\u5165\u4f47\u5217', + cmd_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad\u4e26\u52a0\u5165\u4f47\u5217', + cmd_steer_delivered: 'Steer \u5df2\u9001\u9054 \u2014 \u4ee3\u7406\u5c07\u5728\u4e0b\u4e00\u500b\u5de5\u5177\u7d50\u679c\u4e2d\u770b\u5230', + steer_leftover_queued: 'Steer \u5df2\u52a0\u5165\u4e0b\u4e00\u8f2a\u4f47\u5217', busy_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad', busy_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u767c\u9001\u65b0\u8a0a\u606f', settings_label_busy_input_mode: '\u5fd9\u788c\u8f38\u5165\u6a21\u5f0f', diff --git a/static/index.html b/static/index.html index 1f05fe7e..1d63ca13 100644 --- a/static/index.html +++ b/static/index.html @@ -669,7 +669,7 @@ -
Controls what happens when you send a message while the agent is running. Queue waits for the current task; Interrupt cancels and starts fresh; Steer sends a correction (currently falls back to interrupt).
+
Controls what happens when you send a message while the agent is running. Queue waits for the current task; Interrupt cancels and starts fresh; Steer injects a mid-turn correction without interrupting (falls back to interrupt when the agent is not yet cached or the stream has ended).