fix(acp): suppress cancel interrupt sentinel
This commit is contained in:
parent
2789bf4e25
commit
9b631e4ae1
2 changed files with 103 additions and 3 deletions
|
|
@ -88,6 +88,20 @@ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent")
|
|||
# does not expose a client-side limit, so this is a fixed cap that clients
|
||||
# paginate against using `cursor` / `next_cursor`.
|
||||
_LIST_SESSIONS_PAGE_SIZE = 50
|
||||
_INTERRUPT_WAITING_FOR_MODEL_PREFIX = (
|
||||
"Operation interrupted: waiting for model response ("
|
||||
)
|
||||
_INTERRUPT_WAITING_FOR_MODEL_SUFFIX = " elapsed)."
|
||||
|
||||
|
||||
def _is_interrupt_waiting_for_model_response(text: Any) -> bool:
|
||||
"""Return True for Hermes' local API-wait interruption status string."""
|
||||
response = str(text or "").strip()
|
||||
return (
|
||||
response.startswith(_INTERRUPT_WAITING_FOR_MODEL_PREFIX)
|
||||
and response.endswith(_INTERRUPT_WAITING_FOR_MODEL_SUFFIX)
|
||||
)
|
||||
|
||||
_MAX_ACP_RESOURCE_BYTES = 512 * 1024
|
||||
_TEXT_RESOURCE_MIME_PREFIXES = ("text/",)
|
||||
_TEXT_RESOURCE_MIME_TYPES = {
|
||||
|
|
@ -1513,7 +1527,12 @@ class HermesACPAgent(acp.Agent):
|
|||
self.session_manager.save_session(session_id)
|
||||
|
||||
final_response = result.get("final_response", "")
|
||||
if final_response:
|
||||
cancelled = bool(state.cancel_event and state.cancel_event.is_set())
|
||||
interrupted = bool(result.get("interrupted")) or cancelled
|
||||
suppress_interrupt_response = (
|
||||
interrupted and _is_interrupt_waiting_for_model_response(final_response)
|
||||
)
|
||||
if final_response and not suppress_interrupt_response:
|
||||
try:
|
||||
from agent.title_generator import maybe_auto_title
|
||||
|
||||
|
|
@ -1534,7 +1553,12 @@ class HermesACPAgent(acp.Agent):
|
|||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True)
|
||||
if final_response and conn and (not streamed_message or result.get("response_transformed")):
|
||||
if (
|
||||
final_response
|
||||
and conn
|
||||
and not suppress_interrupt_response
|
||||
and (not streamed_message or result.get("response_transformed"))
|
||||
):
|
||||
# Deliver the final response when streaming did not already send it,
|
||||
# or when a plugin hook transformed the response after streaming
|
||||
# finished (e.g. transform_llm_output) — otherwise the appended /
|
||||
|
|
@ -1576,7 +1600,7 @@ class HermesACPAgent(acp.Agent):
|
|||
|
||||
await self._send_usage_update(state)
|
||||
|
||||
stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn"
|
||||
stop_reason = "cancelled" if cancelled else "end_turn"
|
||||
return PromptResponse(stop_reason=stop_reason, usage=usage)
|
||||
|
||||
# ---- Slash commands (headless) -------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1100,6 +1100,82 @@ class TestPrompt:
|
|||
]
|
||||
assert any(update.session_update == "agent_message_chunk" for update in updates)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_suppresses_cancel_interrupt_sentinel(self, agent):
|
||||
"""ACP cancel status text should not be emitted as assistant output."""
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
sentinel = "Operation interrupted: waiting for model response (3.3s elapsed)."
|
||||
|
||||
def mock_run(*args, **kwargs):
|
||||
state.cancel_event.set()
|
||||
return {
|
||||
"final_response": sentinel,
|
||||
"messages": list(state.history),
|
||||
"interrupted": True,
|
||||
"completed": False,
|
||||
}
|
||||
|
||||
state.agent.run_conversation = mock_run
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
with patch("agent.title_generator.maybe_auto_title") as mock_title:
|
||||
prompt = [TextContentBlock(type="text", text="please do a long task")]
|
||||
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
|
||||
updates = [
|
||||
call.kwargs.get("update") or call.args[1]
|
||||
for call in mock_conn.session_update.call_args_list
|
||||
]
|
||||
agent_texts = [
|
||||
update.content.text
|
||||
for update in updates
|
||||
if update.session_update == "agent_message_chunk"
|
||||
]
|
||||
assert resp.stop_reason == "cancelled"
|
||||
assert sentinel not in agent_texts
|
||||
assert not any(text.startswith("Operation interrupted:") for text in agent_texts)
|
||||
mock_title.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_keeps_real_final_response_on_cancelled_turn(self, agent):
|
||||
"""A cancel flag must not suppress actual assistant/model text."""
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
final_text = "The actual model answer arrived before cancellation settled."
|
||||
|
||||
def mock_run(*args, **kwargs):
|
||||
state.cancel_event.set()
|
||||
return {
|
||||
"final_response": final_text,
|
||||
"messages": [],
|
||||
"interrupted": True,
|
||||
}
|
||||
|
||||
state.agent.run_conversation = mock_run
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
prompt = [TextContentBlock(type="text", text="finish if you can")]
|
||||
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
|
||||
updates = [
|
||||
call.kwargs.get("update") or call.args[1]
|
||||
for call in mock_conn.session_update.call_args_list
|
||||
]
|
||||
agent_texts = [
|
||||
update.content.text
|
||||
for update in updates
|
||||
if update.session_update == "agent_message_chunk"
|
||||
]
|
||||
assert resp.stop_reason == "cancelled"
|
||||
assert final_text in agent_texts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_propagates_hermes_session_id_env(self, agent, monkeypatch):
|
||||
"""ACP must propagate the originating session id to the agent loop
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue