Skip to content
Closed
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
33 changes: 31 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2355,6 +2355,8 @@ def _normalize_empty_agent_response(

api_calls = int(agent_result.get("api_calls", 0) or 0)
if api_calls > 0 and not agent_result.get("interrupted"):
if _is_gateway_hidden_reasoning_incomplete_turn(agent_result):
return ""
if agent_result.get("partial"):
err = agent_result.get("error", "processing incomplete")
return f"⚠️ Processing stopped: {str(err)[:200]}. Try again."
Expand All @@ -2366,6 +2368,20 @@ def _normalize_empty_agent_response(
return response


def _is_gateway_hidden_reasoning_incomplete_turn(agent_result: dict) -> bool:
"""Detect retry-exhausted turns with hidden reasoning but no visible answer."""
if not isinstance(agent_result, dict):
return False
if agent_result.get("failed") or agent_result.get("interrupted"):
return False
if agent_result.get("final_response"):
return False
if not agent_result.get("partial"):
return False
error_text = str(agent_result.get("error", "") or "").lower()
return "remained incomplete after" in error_text

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 predicate cannot match the current retry-exhaustion result: agent/conversation_loop.py:4436 sets this same diagnostic as a non-empty final_response, while the helper rejects non-empty final_response and the normalizer returns non-empty response at gateway/run.py:2604 before reaching this code. Please classify the structured incomplete/no-visible-output state before that early return and test the real result shape.



def _should_clear_resume_pending_after_turn(agent_result: dict) -> bool:
"""Return True only when a gateway turn really completed successfully.

Expand Down Expand Up @@ -9874,6 +9890,9 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
# forgets what was just asked. Persist the user turn so the
# conversation is preserved. (#7100)
agent_failed_early = bool(agent_result.get("failed"))
hidden_reasoning_incomplete = _is_gateway_hidden_reasoning_incomplete_turn(
agent_result
)
_err_str_for_classify = str(agent_result.get("error", "")).lower()
# Use specific multi-word phrases (not bare "exceed" or "token")
# to avoid false positives on transient errors like "rate limit
Expand Down Expand Up @@ -9902,6 +9921,13 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
"message so conversation context is preserved on retry.",
session_entry.session_id,
)
elif hidden_reasoning_incomplete:
logger.warning(
"Suppressing hidden-reasoning-only incomplete gateway turn "
"for session %s: %s",
session_entry.session_id,
agent_result.get("error", "processing incomplete"),
)

# When compression is exhausted, the session is permanently too
# large to process. Auto-reset it so the next message starts
Expand Down Expand Up @@ -9972,11 +9998,14 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
# entries that were stripped before the agent saw them.
if is_context_overflow_failure:
pass # handled above — skip all transcript writes
elif agent_failed_early:
elif agent_failed_early or hidden_reasoning_incomplete:
# Transient failure (429/timeout/5xx): persist only the user
# message so the next message can load a transcript that
# reflects what was said. Skip the assistant error text since
# it's a gateway-generated hint, not model output. (#7100)
# it's a gateway-generated hint, not model output. Hidden-
# reasoning-only incomplete turns follow the same persistence
# rule so peer-agent channels don't ingest them as completed
# assistant turns. (#7100, #51628)
_user_entry = {
"role": "user",
"content": (
Expand Down
159 changes: 159 additions & 0 deletions tests/gateway/test_incomplete_gateway_turns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Regression tests for hidden-reasoning-only incomplete gateway turns."""

import asyncio
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest

import gateway.run as gateway_run
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, ProcessingOutcome, SendResult
from gateway.session import SessionEntry, SessionSource, build_session_key


class CaptureSlackAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="fake-token"), Platform.SLACK)
self.sent = []
self.processing_hooks = []

async def connect(self) -> bool:
return True

async def disconnect(self) -> None:
return None

async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
self.sent.append(
{
"chat_id": chat_id,
"content": content,
"reply_to": reply_to,
"metadata": metadata,
}
)
return SendResult(success=True, message_id="slack-1")

async def send_typing(self, chat_id: str, metadata=None) -> None:
return None

async def get_chat_info(self, chat_id: str):
return {"id": chat_id}

async def on_processing_start(self, event: MessageEvent) -> None:
self.processing_hooks.append(("start", event.message_id))

async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
self.processing_hooks.append(("complete", event.message_id, outcome))


def _make_incomplete_result() -> dict:
return {
"final_response": None,
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": ""},
],
"tools": [],
"history_offset": 0,
"api_calls": 3,
"partial": True,
"completed": False,
"interrupted": False,
"error": "Codex response remained incomplete after 3 continuation attempts",
"last_prompt_tokens": 0,
}


def _make_runner(adapter: CaptureSlackAdapter) -> gateway_run.GatewayRunner:
runner = object.__new__(gateway_run.GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.SLACK: PlatformConfig(enabled=True, token="fake-token")}
)
runner.adapters = {Platform.SLACK: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = SessionEntry(
session_key="agent:main:slack:channel:C123:171717",
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.SLACK,
chat_type="channel",
)
runner.session_store.load_transcript.return_value = []
runner.session_store.has_any_sessions.return_value = True
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.append_to_transcript = MagicMock()
runner.session_store.update_session = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._run_agent = AsyncMock(return_value=_make_incomplete_result())
return runner


def _make_event() -> MessageEvent:
return MessageEvent(
text="hello",
source=SessionSource(
platform=Platform.SLACK,
chat_id="C123",
chat_type="channel",
thread_id="171717",
user_id="U123",
),
message_id="m-1",
)


def test_incomplete_codex_warning_is_not_surfaced_as_chat_text():
agent_result = _make_incomplete_result()

response = gateway_run._normalize_empty_agent_response(
agent_result,
agent_result.get("final_response") or "",
history_len=4,
)

assert response == ""


@pytest.mark.asyncio
async def test_incomplete_codex_turn_stays_out_of_slack_transcript(monkeypatch, tmp_path):
adapter = CaptureSlackAdapter()
runner = _make_runner(adapter)

monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"})
monkeypatch.setattr(
"agent.model_metadata.get_model_context_length",
lambda *_args, **_kwargs: 100,
)
monkeypatch.setenv("SLACK_HOME_CHANNEL", "C123")

adapter.set_message_handler(runner._handle_message)
adapter._keep_typing = lambda *_args, **_kwargs: asyncio.Event().wait()

event = _make_event()
await adapter._process_message_background(event, build_session_key(event.source))

assert adapter.sent == []
assert runner.session_store.update_session.called

transcript_roles = [
call.args[1]["role"]
for call in runner.session_store.append_to_transcript.call_args_list
]
assert transcript_roles == ["session_meta", "user"]
assert runner.session_store.append_to_transcript.call_args_list[1].args[1]["content"] == "hello"
assert adapter.processing_hooks == [
("start", "m-1"),
("complete", "m-1", ProcessingOutcome.SUCCESS),
]
6 changes: 6 additions & 0 deletions website/docs/user-guide/messaging/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ hermes gateway install # Install as a user service
sudo hermes gateway install --system # Linux only: boot-time system service
```

:::tip Codex reasoning-effort safety
For Codex-backed Slack peer-agent channels, prefer `agent.reasoning_effort: high` or lower. `xhigh`
can spend the entire turn in hidden reasoning and never produce visible assistant text; Hermes now
suppresses those incomplete-turn warnings from the thread and keeps the diagnostics in gateway logs.
:::

---

## Step 9: Invite the Bot to Channels
Expand Down
Loading