From 136568fe81e65bcedbdc5f5897fbb2920452c532 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Thu, 11 Jun 2026 17:58:15 +0800 Subject: [PATCH] fix(streaming): tighten tool limit terminal guards --- api/run_journal.py | 2 +- api/streaming.py | 4 +-- tests/test_run_journal.py | 2 ++ tests/test_tool_limit_terminal_state.py | 41 +++++++++++++++++++++++-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/api/run_journal.py b/api/run_journal.py index 3ac639eb..1853de05 100644 --- a/api/run_journal.py +++ b/api/run_journal.py @@ -86,7 +86,7 @@ def _terminal_state_for_event(event_name: str, payload) -> str | None: if name == "done" or name == "stream_end": if isinstance(payload, dict): explicit_state = str(payload.get("terminal_state") or "").strip().lower() - if explicit_state: + if explicit_state in {"tool_limit_reached"}: return explicit_state return "completed" if name == "cancel": diff --git a/api/streaming.py b/api/streaming.py index e37a76e2..b5177d0d 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -994,7 +994,7 @@ def _agent_result_tool_limit_reached(result) -> bool: if ( 'max_iterations_reached' in haystack or 'maximum number of tool-calling iterations' in haystack - or 'tool-calling iterations' in haystack and 'maximum' in haystack + or ('tool-calling iterations' in haystack and 'maximum' in haystack) ): return True return False @@ -6796,7 +6796,7 @@ def _run_agent_streaming( # workspace-aware helper from this branch while still # preserving the pre-turn length for downstream self-heal # checks introduced on master. - _all_result_messages = _result_messages or [] + _all_result_messages = result.get('messages') or [] _prev_len = len(_previous_context_messages) _assistant_added = _assistant_reply_added_after_current_turn( _all_result_messages, diff --git a/tests/test_run_journal.py b/tests/test_run_journal.py index b1d32c08..6681e89f 100644 --- a/tests/test_run_journal.py +++ b/tests/test_run_journal.py @@ -110,6 +110,7 @@ def test_terminal_state_classification_distinguishes_crash_from_user_cancel(tmp_ append_run_event("session_1", "run_failed", "apperror", {"type": "auth_mismatch"}, session_dir=tmp_path) append_run_event("session_1", "run_tool_limit", "apperror", {"type": "tool_limit_reached"}, session_dir=tmp_path) append_run_event("session_1", "run_tool_limit_done", "done", {"terminal_state": "tool_limit_reached"}, session_dir=tmp_path) + append_run_event("session_1", "run_unknown_done", "done", {"terminal_state": "future_unknown_state"}, session_dir=tmp_path) append_run_event("session_1", "run_done", "done", {"session": {}}, session_dir=tmp_path) assert latest_run_summary("session_1", "run_cancelled", session_dir=tmp_path)["terminal_state"] == "interrupted-by-user" @@ -117,6 +118,7 @@ def test_terminal_state_classification_distinguishes_crash_from_user_cancel(tmp_ assert latest_run_summary("session_1", "run_failed", session_dir=tmp_path)["terminal_state"] == "errored" assert latest_run_summary("session_1", "run_tool_limit", session_dir=tmp_path)["terminal_state"] == "tool_limit_reached" assert latest_run_summary("session_1", "run_tool_limit_done", session_dir=tmp_path)["terminal_state"] == "tool_limit_reached" + assert latest_run_summary("session_1", "run_unknown_done", session_dir=tmp_path)["terminal_state"] == "completed" assert latest_run_summary("session_1", "run_done", session_dir=tmp_path)["terminal_state"] == "completed" diff --git a/tests/test_tool_limit_terminal_state.py b/tests/test_tool_limit_terminal_state.py index 9bc0b1a7..e675ba40 100644 --- a/tests/test_tool_limit_terminal_state.py +++ b/tests/test_tool_limit_terminal_state.py @@ -12,7 +12,14 @@ from api.models import Session ROOT = Path(__file__).resolve().parents[1] -def _run_streaming_with_fake_agent(tmp_path, monkeypatch, agent_result): +def _run_streaming_with_fake_agent( + tmp_path, + monkeypatch, + agent_result, + *, + prior_messages=None, + prior_context_messages=None, +): session_dir = tmp_path / "sessions" session_dir.mkdir() monkeypatch.setattr(models, "SESSION_DIR", session_dir) @@ -38,8 +45,8 @@ def _run_streaming_with_fake_agent(tmp_path, monkeypatch, agent_result): title="Tool limit test", workspace=str(tmp_path), model="gpt-4o", - messages=[], - context_messages=[], + messages=list(prior_messages or []), + context_messages=list(prior_context_messages or []), ) session.active_stream_id = stream_id session.pending_user_message = "Do the long task." @@ -124,6 +131,12 @@ def test_synthetic_max_iteration_summary_request_is_dropped_from_agent_result(): assert "here is the summary" in cleaned[-1]["content"] +def test_tool_limit_detection_uses_explicit_boolean_grouping(): + streaming_py = (ROOT / "api" / "streaming.py").read_text(encoding="utf-8") + + assert "or ('tool-calling iterations' in haystack and 'maximum' in haystack)" in streaming_py + + def test_historical_synthetic_summary_prompt_does_not_mark_normal_result_as_tool_limit(): result = { "messages": [ @@ -285,3 +298,25 @@ def test_streaming_historical_synthetic_prompt_normal_result_does_not_emit_tool_ assert "terminal_state" not in done_payloads[-1] assert payload["messages"][-1]["role"] == "assistant" assert payload["messages"][-1]["content"] == "Current task completed normally." + + +def test_streaming_empty_result_messages_do_not_treat_prior_assistant_as_current_answer(tmp_path, monkeypatch): + prior = [ + {"role": "user", "content": "Earlier task."}, + {"role": "assistant", "content": "Earlier answer."}, + ] + result = {"messages": []} + + events, payload = _run_streaming_with_fake_agent( + tmp_path, + monkeypatch, + result, + prior_messages=prior, + prior_context_messages=prior, + ) + + apperror_payloads = [payload for event, payload in events if event == "apperror"] + assert apperror_payloads, "expected silent-failure apperror" + assert apperror_payloads[-1]["type"] == "no_response" + assert not [payload for event, payload in events if event == "done"] + assert payload["messages"][-1]["_error"] is True