Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This notice can be false for the plain pending fallback: the unchanged branch above calls adapter.queue_message(session_key, pending), but normal adapters only expose _pending_messages, so no event is stored and _queue_depth() can be zero. Convert plain pending text to a MessageEvent and enqueue it via _enqueue_fifo before returning this notice.

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:
Expand Down
10 changes: 7 additions & 3 deletions hermes_cli/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. "
Expand All @@ -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"
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/test_cli_goal_interrupt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
72 changes: 58 additions & 14 deletions tests/gateway/test_gateway_silence_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
7 changes: 3 additions & 4 deletions tests/gateway/test_goal_continuation_drain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
90 changes: 89 additions & 1 deletion tests/gateway/test_goal_status_notice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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": [],
}
6 changes: 3 additions & 3 deletions website/docs/user-guide/features/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading