mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73128 from NousResearch/bb/steer-mid-stream
fix(agent): mid-stream steering survives retry/backoff; interrupt sentinel stays out of the transcript
This commit is contained in:
commit
3c388db06b
2 changed files with 59 additions and 0 deletions
|
|
@ -2487,6 +2487,17 @@ def run_conversation(
|
|||
_backoff_touch_counter = 0
|
||||
while time.time() < sleep_end:
|
||||
if agent._interrupt_requested:
|
||||
# A redirect uses the interrupt machinery to cancel
|
||||
# only the live request. Aborting the retry here
|
||||
# with clear_interrupt() would DESTROY the pending
|
||||
# correction and kill the turn with "Operation
|
||||
# interrupted" — the exact mid-stream steer loss
|
||||
# users hit when a redirect lands during provider
|
||||
# backoff. Rebuild from the correction instead,
|
||||
# mirroring the InterruptedError handler.
|
||||
if agent.clear_interrupt(preserve_redirect=True):
|
||||
_retry.restart_with_redirected_messages = True
|
||||
break
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
|
||||
_interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
|
|
@ -2508,6 +2519,8 @@ def run_conversation(
|
|||
f"retry backoff ({retry_count}/{max_retries}), "
|
||||
f"{int(sleep_end - time.time())}s remaining"
|
||||
)
|
||||
if _retry.restart_with_redirected_messages:
|
||||
break # rebuild this iteration from the correction
|
||||
continue # Retry the API call
|
||||
|
||||
agent._turn_received_provider_response = True
|
||||
|
|
@ -4000,6 +4013,12 @@ def run_conversation(
|
|||
|
||||
# Check for interrupt before deciding to retry
|
||||
if agent._interrupt_requested:
|
||||
# Preserve a pending redirect (mid-stream correction): the
|
||||
# user is steering, not stopping. Rebuild the turn from the
|
||||
# correction instead of aborting with a dead-end interrupt.
|
||||
if agent.clear_interrupt(preserve_redirect=True):
|
||||
_retry.restart_with_redirected_messages = True
|
||||
break
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
|
||||
_interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
|
|
@ -5236,6 +5255,12 @@ def run_conversation(
|
|||
_backoff_touch_counter = 0
|
||||
while time.time() < sleep_end:
|
||||
if agent._interrupt_requested:
|
||||
# Same preserve-redirect rule as the retry-wait above:
|
||||
# a steering correction must survive backoff, not die
|
||||
# as "Operation interrupted".
|
||||
if agent.clear_interrupt(preserve_redirect=True):
|
||||
_retry.restart_with_redirected_messages = True
|
||||
break
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
|
||||
_interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
|
|
@ -5257,6 +5282,11 @@ def run_conversation(
|
|||
f"error retry backoff ({retry_count}/{max_retries}), "
|
||||
f"{int(sleep_end - time.time())}s remaining"
|
||||
)
|
||||
if _retry.restart_with_redirected_messages:
|
||||
# Leave the retry loop — the check right below rebuilds this
|
||||
# iteration from the correction instead of re-firing the
|
||||
# stale request.
|
||||
break
|
||||
|
||||
if _retry.restart_with_redirected_messages:
|
||||
# The cancelled request produced no valid assistant item. Reuse the
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from hermes_cli.env_loader import load_hermes_dotenv
|
|||
from utils import is_truthy_value
|
||||
from tools.environments.local import hermes_subprocess_env
|
||||
from agent.replay_cleanup import sanitize_replay_history
|
||||
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX
|
||||
from tui_gateway import git_probe
|
||||
from tui_gateway.turn_marker import (
|
||||
clear_turn_marker,
|
||||
|
|
@ -10748,6 +10749,14 @@ def _(rid, params: dict) -> dict:
|
|||
accepted = agent.steer(text)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5000, f"steer failed: {exc}")
|
||||
if accepted:
|
||||
# Record the correction on the live turn exactly like session.redirect
|
||||
# does. Without this, a resume/reconnect while the turn is running
|
||||
# rebuilds the transcript from the inflight snapshot and the steered
|
||||
# text has no user bubble — the "my message vanished on reload" loss.
|
||||
with session["history_lock"]:
|
||||
_record_inflight_correction(session, text)
|
||||
session["last_active"] = time.time()
|
||||
return _ok(rid, {"status": "queued" if accepted else "rejected", "text": text})
|
||||
|
||||
|
||||
|
|
@ -11682,6 +11691,7 @@ def _run_prompt_submit(
|
|||
home_token = None # per-turn HERMES_HOME override for a resumed remote profile
|
||||
secret_token = None
|
||||
goal_followup = None # set by the post-turn goal hook below
|
||||
result = None # turn outcome; read after the finally for leftover /steer
|
||||
tts_queue = None # streaming-TTS feed for this turn (voice mode)
|
||||
one_turn_restore = session.pop("one_turn_model_restore", None)
|
||||
# True once a failed turn's snapshot was retained for resume replay —
|
||||
|
|
@ -12001,6 +12011,15 @@ def _run_prompt_submit(
|
|||
result.get("failed") or result.get("partial")
|
||||
):
|
||||
raw = f"Error: {result.get('error')}"
|
||||
# "Operation interrupted: waiting for model response (…)" is
|
||||
# cancellation metadata, not assistant prose. gateway/run.py
|
||||
# and the ACP adapter already suppress this sentinel; without
|
||||
# this the desktop paints it as the agent's reply whenever a
|
||||
# stop/steer lands mid-request (#7921).
|
||||
if status == "interrupted" and isinstance(raw, str) and raw.strip().startswith(
|
||||
INTERRUPT_WAITING_FOR_MODEL_PREFIX
|
||||
):
|
||||
raw = ""
|
||||
lr = result.get("last_reasoning")
|
||||
if isinstance(lr, str) and lr.strip():
|
||||
last_reasoning = lr.strip()
|
||||
|
|
@ -12257,6 +12276,16 @@ def _run_prompt_submit(
|
|||
# A user prompt that arrived mid-turn (interrupt + queue) wins over
|
||||
# every auto follow-up below — drain it first and skip them this cycle;
|
||||
# the goal judge / notifications re-evaluate at the end of that turn.
|
||||
# Leftover /steer: the steer arrived after the last tool batch (e.g.
|
||||
# during the final API call), so the agent couldn't inject it and
|
||||
# returned it in result["pending_steer"]. Requeue it as the next turn
|
||||
# so it isn't silently dropped — same rule as cli.py and gateway/run.py.
|
||||
# A real queued prompt still wins: the merge in _enqueue_prompt keeps
|
||||
# both texts.
|
||||
_leftover_steer = result.get("pending_steer") if isinstance(result, dict) else None
|
||||
if isinstance(_leftover_steer, str) and _leftover_steer.strip():
|
||||
with session["history_lock"]:
|
||||
_enqueue_prompt(session, _leftover_steer, session.get("transport"))
|
||||
if _drain_queued_prompt(rid, sid, session):
|
||||
return
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue