diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 266d587b0743..f664c47070e4 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -1218,12 +1218,19 @@ class HermesACPAgent(acp.Agent): with state.runtime_lock: if state.is_running and state.current_prompt_text: state.interrupted_prompt_text = state.current_prompt_text - state.cancel_event.set() - try: - if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"): - state.agent.interrupt() - except Exception: - logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True) + # Publish cancellation and hard-stop the agent before another + # prompt can acquire this lock and mistake the turn for + # redirectable work. + state.cancel_event.set() + try: + if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"): + state.agent.interrupt() + except Exception: + logger.debug( + "Failed to interrupt ACP session %s", + session_id, + exc_info=True, + ) logger.info("Cancelled session %s", session_id) async def fork_session( @@ -1352,6 +1359,26 @@ class HermesACPAgent(acp.Agent): elif rewrite_idle: user_text = steer_text user_content = steer_text + elif ( + text_only_prompt + and isinstance(user_content, str) + and not user_text.startswith("/") + ): + # Some ACP clients implement "stop and send" as two protocol calls: + # cancel the active prompt, then submit plain correction text. Keep + # the cancelled request attached so deictic follow-ups ("not that + # file") still have an explicit target. + interrupted_prompt = "" + with state.runtime_lock: + if not state.is_running and state.interrupted_prompt_text: + interrupted_prompt = state.interrupted_prompt_text + state.interrupted_prompt_text = "" + if interrupted_prompt: + user_text = ( + f"{interrupted_prompt}\n\n" + f"User correction/guidance after interrupt: {user_text}" + ) + user_content = user_text # Intercept slash commands — handle locally without calling the LLM. # Slash commands are text-only; if the client included images/resources, @@ -1366,23 +1393,54 @@ class HermesACPAgent(acp.Agent): await self._send_usage_update(state) return PromptResponse(stop_reason="end_turn") - # If Zed sends another regular prompt while the same ACP session is - # still running, queue it instead of racing two AIAgent loops against - # the same state.history. /steer and /queue are handled above and can - # land immediately. + # If the client sends another regular text prompt while this ACP session + # is running, route it through the core active-turn redirect. Rich media + # and older runtimes retain the proven next-turn queue fallback. + redirected = False + queued_depth: int | None = None with state.runtime_lock: if state.is_running: - queued_text = user_text or "[Image attachment]" - state.queued_prompts.append(queued_text) - depth = len(state.queued_prompts) - if self._conn: - update = acp.update_agent_message_text( - f"Queued for the next turn. ({depth} queued)" + if ( + text_only_prompt + and isinstance(user_content, str) + and getattr( + state.agent, + "_supports_active_turn_redirect", + False, ) - await self._conn.session_update(session_id, update) - return PromptResponse(stop_reason="end_turn") - state.is_running = True - state.current_prompt_text = user_text or "[Image attachment]" + is True + and hasattr(state.agent, "redirect") + ): + try: + redirected = bool(state.agent.redirect(user_content)) + except Exception: + logger.debug( + "ACP active-turn redirect failed for %s", + session_id, + exc_info=True, + ) + if not redirected: + queued_text = user_text or "[Image attachment]" + state.queued_prompts.append(queued_text) + queued_depth = len(state.queued_prompts) + else: + state.is_running = True + state.current_prompt_text = user_text or "[Image attachment]" + + if redirected: + if self._conn: + update = acp.update_agent_message_text( + "Redirected the active turn with your correction." + ) + await self._conn.session_update(session_id, update) + return PromptResponse(stop_reason="end_turn") + if queued_depth is not None: + if self._conn: + update = acp.update_agent_message_text( + f"Queued for the next turn. ({queued_depth} queued)" + ) + await self._conn.session_update(session_id, update) + return PromptResponse(stop_reason="end_turn") logger.info("Prompt on session %s: %s", session_id, user_text[:100]) diff --git a/agent/agent_init.py b/agent/agent_init.py index 2094dd2b4f7f..a6d3b0481411 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -720,6 +720,8 @@ def init_agent( agent._execution_thread_id: int | None = None # Set at run_conversation() start agent._interrupt_thread_signal_pending = False agent._client_lock = threading.RLock() + agent._model_request_active = threading.Event() + agent._supports_active_turn_redirect = True # /steer mechanism — inject a user note into the next tool result # without interrupting the agent. Unlike interrupt(), steer() does @@ -731,6 +733,13 @@ def init_agent( agent._pending_steer: Optional[str] = None agent._pending_steer_lock = threading.Lock() + # Active-turn redirect mechanism. A regular follow-up sent while the model + # is generating is different from a hard /stop: preserve the valid turn + # prefix, cancel only the in-flight model request, and rebuild its tail with + # the correction. The loop drains this slot at a role-safe boundary. + agent._pending_redirect: Optional[str] = None + agent._pending_redirect_lock = threading.Lock() + # Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent` # runs each tool on its own ThreadPoolExecutor worker — those worker # threads have tids distinct from `_execution_thread_id`, so @@ -897,6 +906,12 @@ def init_agent( agent._stream_writer_tls = threading.local() agent._stream_writer_dropped = 0 + # Displayed reasoning text streamed during the current model response, + # captured only when a surface consumed it via a reasoning callback. Used + # by active-turn redirect to checkpoint what the user actually saw without + # ever persisting hidden provider reasoning. + agent._current_streamed_reasoning_text = "" + # Optional current-turn user-message override used when the API-facing # user message intentionally differs from the persisted transcript # (e.g. CLI voice mode adds a temporary prefix for the live call only). diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 91c2af3e995f..59f9bac25a26 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -702,6 +702,16 @@ def run_codex_app_server_turn( except Exception: pass agent._codex_session = None + _user_interrupted = bool( + getattr(agent, "_interrupt_requested", False) + ) + _interrupt_message = ( + getattr(agent, "_interrupt_message", None) + if _user_interrupted + else None + ) + if _user_interrupted: + agent.clear_interrupt() return { "final_response": ( f"Codex app-server turn failed: {exc}. " @@ -711,9 +721,27 @@ def run_codex_app_server_turn( "api_calls": 0, "completed": False, "partial": True, + "interrupted": _user_interrupted, + **( + {"interrupt_message": _interrupt_message} + if _interrupt_message + else {} + ), "error": str(exc), } + # This runtime bypasses the normal conversation-loop finalizer. Mirror its + # interrupt handoff/cleanup so a hard stop cannot poison the next turn and a + # message-bearing compatibility interrupt can still be replayed by callers. + _user_interrupted = bool( + turn.interrupted and getattr(agent, "_interrupt_requested", False) + ) + _interrupt_message = ( + getattr(agent, "_interrupt_message", None) if _user_interrupted else None + ) + if _user_interrupted: + agent.clear_interrupt() + # If the turn signalled the underlying client is wedged (deadline # blown, post-tool watchdog tripped, OAuth refresh died, subprocess # exited), retire the session so the next turn respawns codex @@ -819,6 +847,12 @@ def run_codex_app_server_turn( "api_calls": api_calls, "completed": not turn.interrupted and turn.error is None, "partial": turn.interrupted or turn.error is not None, + "interrupted": _user_interrupted, + **( + {"interrupt_message": _interrupt_message} + if _interrupt_message + else {} + ), "error": turn.error, # The codex app-server runtime IS an early-return path that bypasses # conversation_loop, but we flush the projected assistant/tool messages diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index df81c20be211..fb9d11b66fce 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -103,6 +103,53 @@ _API_CALL_MODULES = frozenset({ }) +def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text: str) -> None: + """Append a provider-safe checkpoint and correction to the live turn. + + Incomplete provider reasoning blocks are not valid replay items (Anthropic + signs them; Responses reasoning items require their following output). + Preserve only what Hermes actually displayed, demoted to ordinary text, + then add the correction as a real user message. This keeps role alternation + valid and leaves every previously cached message byte-for-byte unchanged. + """ + reasoning = str( + getattr(agent, "_current_streamed_reasoning_text", "") or "" + ).strip() + visible = agent._strip_think_blocks( + getattr(agent, "_current_streamed_assistant_text", "") or "" + ).strip() + + checkpoint_parts = ["[This response was interrupted by a user correction.]"] + if reasoning: + checkpoint_parts.extend( + ["Reasoning shown before the interruption:", reasoning] + ) + if visible: + checkpoint_parts.extend( + ["Visible response before the interruption:", visible] + ) + checkpoint = "\n\n".join(checkpoint_parts) + + # The normal live tail is user or tool, so an assistant checkpoint followed + # by the correction preserves strict alternation. If a transport already + # committed an assistant item, attribute the checkpoint inside the user + # correction instead of creating assistant→assistant. + if messages and messages[-1].get("role") == "assistant": + correction = ( + "[Context from the interrupted assistant response]\n" + f"{checkpoint}\n\n" + f"{text}" + ) + messages.append({"role": "user", "content": correction}) + else: + messages.append({"role": "assistant", "content": checkpoint}) + messages.append({"role": "user", "content": text}) + + agent._current_streamed_assistant_text = "" + agent._current_streamed_reasoning_text = "" + agent._stream_needs_break = True + + def _image_error_max_dimension(error: Exception) -> Optional[int]: """Extract a provider-reported image dimension ceiling, if present.""" parts = [] @@ -733,6 +780,16 @@ def run_conversation( ) while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call: + _redirect_text = agent._drain_pending_redirect() + if _redirect_text: + _apply_active_turn_redirect(agent, messages, _redirect_text) + if isinstance(original_user_message, str): + original_user_message = ( + f"{original_user_message}\n\n" + f"User correction during the turn: {_redirect_text}" + ) + agent._persist_session(messages, conversation_history) + # Reset per-turn checkpoint dedup so each iteration can take one snapshot agent._checkpoint_mgr.new_turn() @@ -1532,22 +1589,59 @@ def run_conversation( from hermes_cli.middleware import run_llm_execution_middleware - response = run_llm_execution_middleware( - api_kwargs, - _perform_api_call, - original_request=_original_api_kwargs, - task_id=effective_task_id, - turn_id=turn_id, - api_request_id=api_request_id, - session_id=agent.session_id or "", - platform=agent.platform or "", - model=agent.model, - provider=agent.provider, - base_url=agent.base_url, - api_mode=agent.api_mode, - api_call_count=api_call_count, - middleware_trace=list(_llm_middleware_trace), - ) + _model_request_active = getattr(agent, "_model_request_active", None) + _redirect_lock = getattr(agent, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + if _model_request_active is not None: + _model_request_active.set() + elif _model_request_active is not None: + _model_request_active.set() + _redirect_crossed_response = False + try: + response = run_llm_execution_middleware( + api_kwargs, + _perform_api_call, + original_request=_original_api_kwargs, + task_id=effective_task_id, + turn_id=turn_id, + api_request_id=api_request_id, + session_id=agent.session_id or "", + platform=agent.platform or "", + model=agent.model, + provider=agent.provider, + base_url=agent.base_url, + api_mode=agent.api_mode, + api_call_count=api_call_count, + middleware_trace=list(_llm_middleware_trace), + ) + finally: + if _redirect_lock is not None: + with _redirect_lock: + if _model_request_active is not None: + _model_request_active.clear() + _redirect_crossed_response = bool( + agent._pending_redirect + ) + else: + if _model_request_active is not None: + _model_request_active.clear() + _redirect_crossed_response = agent._has_pending_redirect() + if _redirect_crossed_response: + # The response and redirect can cross on different threads: + # redirect() observed the request as active just before this + # call returned. Discard that now-stale response and rebuild + # from the correction rather than silently losing it. + if thinking_spinner: + thinking_spinner.stop("") + thinking_spinner = None + if agent.thinking_callback: + agent.thinking_callback("") + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + else: + interrupted = True + break api_duration = time.time() - api_start_time @@ -2509,6 +2603,15 @@ def run_conversation( thinking_spinner = None if agent.thinking_callback: agent.thinking_callback("") + if agent._has_pending_redirect(): + # redirect() deliberately used the interrupt machinery to + # cancel only this provider request. Keep its correction + # queued, clear the cancellation bit, and let the outer + # loop rebuild a clean request tail. Never materialize + # incomplete signed/encrypted reasoning items. + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break api_elapsed = time.time() - api_start_time agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True) interrupted = True @@ -4457,6 +4560,15 @@ def run_conversation( f"{int(sleep_end - time.time())}s remaining" ) + if _retry.restart_with_redirected_messages: + # The cancelled request produced no valid assistant item. Reuse the + # same logical iteration after the outer loop appends the displayed + # partial context and correction to ``messages``. + api_call_count -= 1 + agent.iteration_budget.refund() + _retry.restart_with_redirected_messages = False + continue + # If the API call was interrupted, skip response processing if interrupted: _turn_exit_reason = "interrupted_during_api_call" diff --git a/agent/onboarding.py b/agent/onboarding.py index c29ea1529fb0..148fbcc9fdae 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -52,6 +52,13 @@ def busy_input_hint_gateway(mode: str) -> str: "Send `/busy interrupt` or `/busy queue` to change this, or " "`/busy status` to check. This notice won't appear again." ) + if mode == "redirect": + return ( + "💡 First-time tip — I redirected the current run using your message. " + "Completed work stays in context, and `/stop` still cancels the task. " + "Send `/busy queue` to wait for a separate turn, or `/busy status` " + "to check. This notice won't appear again." + ) return ( "💡 First-time tip — I just interrupted my current task to answer you. " "Send `/busy queue` to queue follow-ups for after the current task instead, " @@ -74,6 +81,12 @@ def busy_input_hint_cli(mode: str) -> str: "after the next tool call. Use /busy interrupt or /busy queue to " "change this. This tip only shows once." ) + if mode == "redirect": + return ( + "(tip) Your correction redirected the current run without discarding " + "completed work. Use /stop to cancel or /busy queue to wait for a " + "separate turn. This tip only shows once." + ) return ( "(tip) Your message interrupted the current run. " "Use /busy queue to queue messages for the next turn instead, " diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 2e1cc64bdcbc..7954ecbf4d52 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -300,6 +300,8 @@ class CodexAppServerSession: self._client: Optional[CodexAppServerClient] = None self._thread_id: Optional[str] = None self._interrupt_event = threading.Event() + self._active_turn_id: Optional[str] = None + self._active_turn_lock = threading.Lock() # Pending file-change items, keyed by item id. Populated on # item/started for fileChange items; consumed by the approval # bridge when codex sends item/fileChange/requestApproval. The @@ -374,6 +376,8 @@ class CodexAppServerSession: if self._closed: return self._closed = True + with self._active_turn_lock: + self._active_turn_id = None if self._client is not None: try: self._client.close() @@ -395,6 +399,33 @@ class CodexAppServerSession: and unwind. Called by AIAgent's _interrupt_requested path.""" self._interrupt_event.set() + def request_steer(self, text: str) -> bool: + """Append user guidance to the active Codex turn via ``turn/steer``.""" + cleaned = str(text or "").strip() + if not cleaned: + return False + with self._active_turn_lock: + turn_id = self._active_turn_id + thread_id = self._thread_id + client = self._client + if not turn_id or not thread_id or client is None: + return False + try: + response = client.request( + "turn/steer", + { + "threadId": thread_id, + "input": [{"type": "text", "text": cleaned}], + "expectedTurnId": turn_id, + }, + timeout=10, + ) + except (CodexAppServerError, TimeoutError): + logger.debug("turn/steer rejected for active Codex turn", exc_info=True) + return False + accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None + return accepted_turn_id in {None, turn_id} + # ---------- diagnostics ---------- def _format_error_with_stderr( @@ -469,11 +500,18 @@ class CodexAppServerSession: # Subprocess almost certainly unhealthy — retire so the next # turn re-spawns cleanly. result.should_retire = True + self._interrupt_event.clear() return result assert self._client is not None and self._thread_id is not None result.thread_id = self._thread_id - self._interrupt_event.clear() + # Do not clear here: a hard stop can arrive while ensure_started() is + # spawning/initializing the subprocess. Honor it before launching a + # Codex turn instead of erasing the signal. + if self._interrupt_event.is_set(): + result.interrupted = True + self._interrupt_event.clear() + return result projector = CodexEventProjector() user_input_text = _coerce_turn_input_text(user_input) @@ -505,6 +543,7 @@ class CodexAppServerSession: result.error = self._format_error_with_stderr( "turn/start failed", exc ) + self._interrupt_event.clear() return result except TimeoutError as exc: # turn/start hanging is a strong signal the subprocess is wedged. @@ -514,9 +553,12 @@ class CodexAppServerSession: "turn/start timed out", exc ) result.should_retire = True + self._interrupt_event.clear() return result result.turn_id = (ts.get("turn") or {}).get("id") + with self._active_turn_lock: + self._active_turn_id = result.turn_id deadline = time.monotonic() + turn_timeout turn_complete = False # Post-tool watchdog state. last_tool_completion_at is set whenever @@ -741,6 +783,9 @@ class CodexAppServerSession: ) result.should_retire = True + with self._active_turn_lock: + self._active_turn_id = None + self._interrupt_event.clear() return result def compact_thread( diff --git a/agent/turn_retry_state.py b/agent/turn_retry_state.py index 3d231fef9ff4..59e343bfeda3 100644 --- a/agent/turn_retry_state.py +++ b/agent/turn_retry_state.py @@ -73,6 +73,10 @@ class TurnRetryState: # was rolled back off ``messages`` and the loop should re-issue the API # call against the newly-activated provider (#32421). restart_with_rebuilt_messages: bool = False + # A user correction cancelled the in-flight provider request. The outer + # loop must append a role-safe checkpoint + user message, rebuild the API + # payload, and retry the same logical iteration. + restart_with_redirected_messages: bool = False def __iter__(self): # Convenience for debugging / tests: iterate (name, value) pairs. diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7852ee81ef56..314bbd4c89cc 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,11 +1,9 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' -import { formatCombo } from '@/lib/keybinds/combo' +import { AudioLines, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' @@ -42,7 +40,6 @@ export function ComposerControls({ autoSpeak, busy, busyAction, - canSteer, canSubmit, compactModelPill = false, conversation, @@ -51,13 +48,11 @@ export function ComposerControls({ state, voiceStatus, onDictate, - onSteer, onToggleAutoSpeak }: { autoSpeak: boolean busy: boolean - busyAction: 'queue' | 'stop' - canSteer: boolean + busyAction: 'steer' | 'queue' | 'stop' canSubmit: boolean compactModelPill?: boolean conversation: ConversationProps @@ -66,49 +61,24 @@ export function ComposerControls({ state: ChatBarState voiceStatus: VoiceStatus onDictate: () => void - onSteer: () => void onToggleAutoSpeak: () => void }) { const { t } = useI18n() const c = t.composer - const steerCombo = formatCombo('mod+enter') - const steerLabel = `${c.steer} (${steerCombo})` - - const steerTip = ( - - {c.steer} - - - ) if (conversation.active) { return } const showVoicePrimary = !busy && !hasComposerPayload + // steer + stop both interrupt the live turn (the difference is whether a + // correction rides along), so they share the stop glyph; only queue differs. + const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop return (
- {/* While the agent runs and the user is typing, steer takes over the mic's - slot rather than crowding the row with an extra button. */} - {canSteer ? ( - - - - ) : ( - - )} + {showVoicePrimary ? ( @@ -127,9 +97,9 @@ export function ComposerControls({ ) : ( - +