diff --git a/gateway/run.py b/gateway/run.py index ef626504dc4bc..752c6269a896a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7939,6 +7939,36 @@ def _queue_depth(self, session_key: str, *, adapter: Any = None) -> int: depth += 1 return depth + @staticmethod + def _queue_notice(depth: int) -> str: + """User-facing queue notice for an interrupted turn with pending follow-ups.""" + return ( + "Queued for the next turn." + if depth <= 1 + else f"Queued for the next turn. ({depth} queued)" + ) + + @staticmethod + def _apply_queue_notice_if_followup_pending( + fallback_result: dict | None, + *, + response: Any, + history: list[dict], + queued_depth: int, + ) -> dict: + """Attach a queue notice only when a follow-up really survived. + + The interrupt-depth cap can receive a pending string from an adapter that + has no durable pending slot and no ``queue_message`` implementation. In + that case ``queued_depth`` stays zero, so claiming "Queued for the next + turn" would be false; return the fallback result silently instead. + """ + result = fallback_result or {"final_response": response, "messages": history} + if result.get("final_response") or queued_depth <= 0: + return result + result["final_response"] = GatewayRunner._queue_notice(queued_depth) + return result + @staticmethod def _is_goal_continuation_event(event_or_text: Any) -> bool: """Return True for synthetic /goal continuation turns. @@ -7948,7 +7978,11 @@ def _is_goal_continuation_event(event_or_text: Any) -> bool: suppressing them. """ text = getattr(event_or_text, "text", event_or_text) or "" - return str(text).startswith("[Continuing toward your standing goal]\nGoal:") + try: + from hermes_cli.goals import CONTINUATION_MARKER + return str(text).startswith(f"{CONTINUATION_MARKER}\nGoal:") + except Exception: + return False def _clear_goal_pending_continuations(self, session_key: str, adapter: Any) -> int: """Remove queued synthetic /goal continuations for one session. @@ -18109,6 +18143,18 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # empty-response handling (and the suppression below) applies. if _is_gateway_hidden_reasoning_incomplete_turn(agent_result): response = "" + if not response and agent_result.get("interrupted"): + _queue_adapter = self.adapters.get(source.platform) + _queued_depth = self._queue_depth(_quick_key, adapter=_queue_adapter) + if _queued_depth > 0: + response = self._queue_notice(_queued_depth) + logger.info( + "Session %s interrupted with queued follow-up(s) pending (chat=%s depth=%d); " + "returning queue notice instead of silence.", + _quick_key or "?", + getattr(source, "chat_id", "?"), + _queued_depth, + ) try: from gateway.response_filters import is_intentional_silence_agent_result _intentional_silence = is_intentional_silence_agent_result( @@ -26365,7 +26411,24 @@ def _run_sync_with_timeout_lifecycle(): merge_pending_message_event(adapter._pending_messages, session_key, pending_event) elif adapter and hasattr(adapter, 'queue_message'): adapter.queue_message(session_key, pending) - return result_holder[0] or {"final_response": response, "messages": history} + + queued_depth = self._queue_depth(session_key, adapter=adapter) + fallback_result = self._apply_queue_notice_if_followup_pending( + result_holder[0], + response=response, + history=history, + queued_depth=queued_depth, + ) + if fallback_result.get("final_response"): + return fallback_result + logger.info( + "Interrupt depth cap had no durable queue slot for session %s " + "(chat=%s depth=%d); returning fallback result without a queue notice.", + session_key or "?", + getattr(source, "chat_id", "?"), + queued_depth, + ) + return fallback_result was_interrupted = result.get("interrupted") if not was_interrupted: diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index b906979e8d319..bdebdd73fe5a9 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -87,8 +87,12 @@ _GATE_OUTPUT_TAIL_CHARS = 3000 +CONTINUATION_MARKER = ( + "[Automated goal continuation — Hermes is pursuing your standing goal, not a message you sent]" +) + CONTINUATION_PROMPT_TEMPLATE = ( - "[Continuing toward your standing goal]\n" + f"{CONTINUATION_MARKER}\n" "Goal: {goal}\n\n" "Continue working toward this goal. Take the next concrete step. " "If you believe the goal is complete, state so explicitly and stop. " @@ -100,7 +104,7 @@ # to break, what's in scope, and when to stop and ask — so it targets the # verification surface instead of declaring victory loosely. CONTINUATION_PROMPT_WITH_CONTRACT_TEMPLATE = ( - "[Continuing toward your standing goal]\n" + f"{CONTINUATION_MARKER}\n" "Goal: {goal}\n\n" "Completion contract:\n" "{contract_block}\n\n" @@ -116,7 +120,7 @@ # to the agent verbatim so it sees what to target on the next turn, # and surfaced to the judge so the verdict considers them too. CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE = ( - "[Continuing toward your standing goal]\n" + f"{CONTINUATION_MARKER}\n" "Goal: {goal}\n\n" "Additional criteria the user added mid-loop:\n" "{subgoals_block}\n\n" diff --git a/tests/cli/test_cli_goal_interrupt.py b/tests/cli/test_cli_goal_interrupt.py index 37ee84ccc6883..8be864f44d231 100644 --- a/tests/cli/test_cli_goal_interrupt.py +++ b/tests/cli/test_cli_goal_interrupt.py @@ -109,7 +109,7 @@ def test_clean_response_enqueues_continuation_when_judge_says_continue( # Continuation prompt must be queued. assert not cli._pending_input.empty() queued = cli._pending_input.get_nowait() - assert "Continuing toward your standing goal" in queued + assert "Automated goal continuation" in queued assert mgr.state.status == "active" def test_clean_response_marks_done_when_judge_says_done(self, hermes_home): diff --git a/tests/gateway/test_gateway_silence_tokens.py b/tests/gateway/test_gateway_silence_tokens.py index 0af7eec226c40..3aa6f67757c5c 100644 --- a/tests/gateway/test_gateway_silence_tokens.py +++ b/tests/gateway/test_gateway_silence_tokens.py @@ -169,20 +169,22 @@ async def test_prose_mentioning_silence_token_is_delivered(monkeypatch, tmp_path async def test_agent_end_hook_includes_model_and_provider(monkeypatch, tmp_path): """Gateway hooks receive the actual model/provider for post-turn routing.""" runner = _runner(monkeypatch, tmp_path) - runner._run_agent = AsyncMock(return_value={ - "final_response": "done", - "messages": [ - {"role": "user", "content": "question"}, - {"role": "assistant", "content": "done"}, - ], - "tools": [], - "history_offset": 0, - "last_prompt_tokens": 0, - "api_calls": 1, - "failed": False, - "model": "gpt-5.6-terra", - "provider": "openai-codex", - }) + runner._run_agent = AsyncMock( + return_value={ + "final_response": "done", + "messages": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "done"}, + ], + "tools": [], + "history_offset": 0, + "last_prompt_tokens": 0, + "api_calls": 1, + "failed": False, + "model": "gpt-5.6-terra", + "provider": "openai-codex", + } + ) await runner._handle_message_with_agent( _event(), _source(), "agent:main:telegram:group:-1001:12345", 1 @@ -195,3 +197,45 @@ async def test_agent_end_hook_includes_model_and_provider(monkeypatch, tmp_path) ) assert end_context["model"] == "gpt-5.6-terra" assert end_context["provider"] == "openai-codex" + + +@pytest.mark.asyncio +async def test_interrupted_empty_turn_with_queued_followup_returns_queue_notice(monkeypatch, tmp_path): + runner = _runner(monkeypatch, tmp_path) + session_key = "agent:main:telegram:group:-1001:12345" + adapter = MagicMock() + pending_event = MessageEvent( + text="follow-up", + source=_source(), + message_id="msg-follow-up", + ) + adapter._pending_messages = {session_key: pending_event} + adapter._send_with_retry = AsyncMock() + runner.adapters[_source().platform] = adapter + runner._queued_events = {} + runner._post_turn_goal_continuation = AsyncMock() + runner._deliver_platform_notice = AsyncMock() + + runner._run_agent = AsyncMock( + return_value={ + "final_response": "", + "messages": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": ""}, + ], + "tools": [], + "history_offset": 0, + "last_prompt_tokens": 0, + "api_calls": 1, + "failed": False, + "interrupted": True, + } + ) + + response = await runner._handle_message_with_agent( + _event(), _source(), session_key, runner._MAX_INTERRUPT_DEPTH + ) + + assert response == "Queued for the next turn." + assert session_key in adapter._pending_messages + assert adapter._pending_messages[session_key].text == "follow-up" diff --git a/tests/gateway/test_goal_continuation_drain.py b/tests/gateway/test_goal_continuation_drain.py index 662f541fc6266..1160b1a5f68cd 100644 --- a/tests/gateway/test_goal_continuation_drain.py +++ b/tests/gateway/test_goal_continuation_drain.py @@ -24,6 +24,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType from gateway.session import SessionSource, build_session_key +from hermes_cli.goals import CONTINUATION_MARKER class _DrainProbeAdapter(BasePlatformAdapter): @@ -72,7 +73,7 @@ def _slack_thread_source() -> SessionSource: ) -CONTINUATION_TEXT = "[Continuing toward your standing goal]\nGoal: ship it" +CONTINUATION_TEXT = f"{CONTINUATION_MARKER}\nGoal: ship it" @pytest.fixture() @@ -196,6 +197,4 @@ async def test_runner_goal_hook_enqueues_into_the_key_the_adapter_drains(hermes_ f"drains: pending keys={list(adapter._pending_messages)} " f"expected={adapter_key}" ) - assert adapter._pending_messages[adapter_key].text.startswith( - "[Continuing toward your standing goal]" - ) + assert adapter._pending_messages[adapter_key].text.startswith(CONTINUATION_MARKER) diff --git a/tests/gateway/test_goal_status_notice.py b/tests/gateway/test_goal_status_notice.py index ada6d2dc9afb1..00fe99bacc1a7 100644 --- a/tests/gateway/test_goal_status_notice.py +++ b/tests/gateway/test_goal_status_notice.py @@ -8,7 +8,12 @@ from gateway.platforms.base import MessageEvent, MessageType from gateway.run import GatewayRunner from gateway.session import SessionSource -from hermes_cli.goals import CONTINUATION_PROMPT_TEMPLATE +from hermes_cli.goals import ( + CONTINUATION_MARKER, + CONTINUATION_PROMPT_TEMPLATE, + CONTINUATION_PROMPT_WITH_CONTRACT_TEMPLATE, + CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE, +) class FakeAdapter: @@ -80,3 +85,86 @@ async def test_goal_status_notice_defers_until_post_delivery_callback(): ] +def test_clear_goal_pending_continuations_removes_slot_and_overflow_only(): + """Regression: /goal pause/clear must cancel queued self-continuations. + + A user-issued /goal pause can arrive after the judge queued the next + continuation but before that queued turn runs. The queued synthetic goal + continuation should be removed without dropping normal user /queue items. + """ + runner = GatewayRunner.__new__(GatewayRunner) + adapter = FakeAdapter() + adapter._pending_messages = {} + runner._queued_events = {} + + source = SessionSource( + platform=Platform.DISCORD, + chat_id="parent-channel", + thread_id="thread-123", + ) + session_key = "discord:parent-channel:thread-123" + normal_event = MessageEvent( + text="normal queued user message", + message_type=MessageType.TEXT, + source=source, + ) + + adapter._pending_messages[session_key] = _goal_continuation_event(source) + runner._queued_events[session_key] = [ + normal_event, + _goal_continuation_event(source, goal="second continuation"), + ] + + removed = runner._clear_goal_pending_continuations(session_key, adapter) + + assert removed == 2 + assert GatewayRunner._is_goal_continuation_event( + CONTINUATION_PROMPT_TEMPLATE.format(goal="x") + ) + assert GatewayRunner._is_goal_continuation_event( + CONTINUATION_PROMPT_WITH_CONTRACT_TEMPLATE.format( + goal="x", contract_block="- one" + ) + ) + assert GatewayRunner._is_goal_continuation_event( + CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE.format( + goal="x", subgoals_block="- one" + ) + ) + assert CONTINUATION_MARKER in CONTINUATION_PROMPT_TEMPLATE + assert adapter._pending_messages.get(session_key) is None + assert runner._queued_events[session_key] == [normal_event] + + +def test_queue_notice_formats_depth_consistently(): + runner = GatewayRunner.__new__(GatewayRunner) + + assert runner._queue_notice(0) == "Queued for the next turn." + assert runner._queue_notice(1) == "Queued for the next turn." + assert runner._queue_notice(2) == "Queued for the next turn. (2 queued)" + + +def test_depth_cap_notice_requires_durable_queued_followup(): + """Do not claim a dropped pending string was queued at the depth cap.""" + result = GatewayRunner._apply_queue_notice_if_followup_pending( + None, + response=None, + history=[], + queued_depth=0, + ) + + assert result == {"final_response": None, "messages": []} + + +def test_depth_cap_notice_reports_real_queued_followups(): + result = GatewayRunner._apply_queue_notice_if_followup_pending( + None, + response=None, + history=[], + queued_depth=2, + ) + + assert result == { + "final_response": "Queued for the next turn. (2 queued)", + "messages": [], + } diff --git a/website/docs/user-guide/features/goals.md b/website/docs/user-guide/features/goals.md index b4a9f31585840..01abd7c2eca51 100644 --- a/website/docs/user-guide/features/goals.md +++ b/website/docs/user-guide/features/goals.md @@ -251,19 +251,19 @@ Hermes: Creating /tmp/note_1.txt now. ↻ Continuing toward goal (1/20): Only 1 of 4 files has been created; 3 files remain. -Hermes: [Continuing toward your standing goal] +Hermes: [Automated goal continuation — Hermes is pursuing your standing goal, not a message you sent] 💻 echo "2" > /tmp/note_2.txt (0.1s) Created /tmp/note_2.txt. Two more to go. ↻ Continuing toward goal (2/20): 2 of 4 files created; 2 remain. -Hermes: [Continuing toward your standing goal] +Hermes: [Automated goal continuation — Hermes is pursuing your standing goal, not a message you sent] 💻 echo "3" > /tmp/note_3.txt (0.1s) Created /tmp/note_3.txt. ↻ Continuing toward goal (3/20): 3 of 4 files created; 1 remains. -Hermes: [Continuing toward your standing goal] +Hermes: [Automated goal continuation — Hermes is pursuing your standing goal, not a message you sent] 💻 echo "4" > /tmp/note_4.txt (0.1s) All four files have been created: /tmp/note_1.txt through /tmp/note_4.txt, each containing its number. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/goals.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/goals.md index 5a36234cebb73..f8bde7daefff9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/goals.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/goals.md @@ -142,19 +142,19 @@ Hermes: Creating /tmp/note_1.txt now. ↻ Continuing toward goal (1/20): Only 1 of 4 files has been created; 3 files remain. -Hermes: [Continuing toward your standing goal] +Hermes: [Automated goal continuation — Hermes is pursuing your standing goal, not a message you sent] 💻 echo "2" > /tmp/note_2.txt (0.1s) Created /tmp/note_2.txt. Two more to go. ↻ Continuing toward goal (2/20): 2 of 4 files created; 2 remain. -Hermes: [Continuing toward your standing goal] +Hermes: [Automated goal continuation — Hermes is pursuing your standing goal, not a message you sent] 💻 echo "3" > /tmp/note_3.txt (0.1s) Created /tmp/note_3.txt. ↻ Continuing toward goal (3/20): 3 of 4 files created; 1 remains. -Hermes: [Continuing toward your standing goal] +Hermes: [Automated goal continuation — Hermes is pursuing your standing goal, not a message you sent] 💻 echo "4" > /tmp/note_4.txt (0.1s) All four files have been created: /tmp/note_1.txt through /tmp/note_4.txt, each containing its number.