Truncated streaming tool call argument detection and repair

NousResearch/hermes-agent · cluster 6638 · agent

Data as of

Nothing open on GitHub. Fix merged in #6847. Check it covers your case.

The short answer

0open issues
0open PRs
20items grouped
Known?
Yes. 20 related items grouped here, 7 issues already closed.
Fix event
A fix was merged to main in PR #6847. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #6847. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes center on detecting, repairing, or rejecting truncated or malformed tool call arguments that arise during streaming assembly, particularly for file-writing tools and models like DeepSeek and GLM. Concerns include silent failures, incorrect finish_reason handling, and attempts to salvage or surface broken tool calls before execution.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #6847 fix(agent): detect truncated streaming tool calls before execution

    merged to main

    files: run_agent.py, tests/run_agent/test_run_agent.py

    Show the diff
    • run_agent.py
      @@ -4565,20 +4565,31 @@ def _call_chat_completions():
                   # Build mock response matching non-streaming shape
                   full_content = "".join(content_parts) or None
                   mock_tool_calls = None
      +            has_truncated_tool_args = False
                   if tool_calls_acc:
                       mock_tool_calls = []
                       for idx in sorted(tool_calls_acc):
                           tc = tool_calls_acc[idx]
      +                    arguments = tc["function"]["arguments"]
      +                    if arguments and arguments.strip():
      +                        try:
      +                            json.loads(arguments)
      +                        except json.JSONDecodeError:
      +                            has_truncated_tool_args = True
                           mock_tool_calls.append(SimpleNamespace(
                               id=tc["id"],
                               type=tc["type"],
                               extra_content=tc.get("extra_content"),
                               function=SimpleNamespace(
                                   name=tc["function"]["name"],
      -                            arguments=tc["function"]["arguments"],
      +                            arguments=arguments,
                               ),
                           ))
       
      +            effective_finish_reason = finish_reason or "stop"
      +            if has_truncated_tool_args:
      +                effective_finish_reason = "length"
      +
                   full_reasoning = "".join(reasoning_parts) or None
                   mock_message = SimpleNamespace(
                       role=role,
      @@ -4589,7 +4600,7 @@ def _call_chat_completions():
                   mock_choice = SimpleNamespace(
                       index=0,
                       message=mock_message,
      -                finish_reason=finish_reason or "stop",
      +                finish_reason=effective_finish_reason,
                   )
                   return SimpleNamespace(
                       id="stream-" + str(uuid.uuid4()),
      @@ -7300,6 +7311,7 @@ def run_conversation(
               interrupted = False
               codex_ack_continuations = 0
               length_continue_retries = 0
      +        truncated_tool_call_retries = 0
               truncated_response_prefix = ""
               compression_attempts = 0
               _turn_exit_reason = "unknown"  # Diagnostic: why the loop ended
      @@ -7768,9 +7780,11 @@ def _stop_spinner():
                               # retries are pointless.  Detect this early and give a
                               # targeted error instead of wasting 3 API calls.
                               _trunc_content = None
      +                        _trunc_has_tool_calls = False
                               if self.api_mode == "chat_completions":
                                   _trunc_msg = response.choices[0].message if (hasattr(response, "choices") and response.choices) else None
                                   _trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None
      +                            _trunc_has_tool_calls = bool(getattr(_trunc_msg, "tool_calls", None)) if _trunc_msg else False
                               elif self.api_mode == "anthropic_messages":
                                   # Anthropic response.content is a list of blocks
                                   _text_parts = []
      @@ -7780,9 +7794,11 @@ def _stop_spinner():
                                   _trunc_content = "\n".join(_text_parts) if _text_parts else None
       
                               _thinking_exhausted = (
      -                            _trunc_content is not None
      -                            and not self._has_content_after_think_block(_trunc_content)
      -                        ) or _trunc_content is None
      +                            not _trunc_has_tool_calls and (
      +                                (_trunc_content is not None and not self._has_content_after_think_block(_trunc_content))
      +                                or _trunc_content is None
      +                            )
      +                        )
       
                               if _thinking_exhausted:
                                   _exhaust_error = (
      @@ -7858,6 +7874,34 @@ def _stop_spinner():
                                           "error": "Response remained truncated after 3 continuation attempts",
                                       }
       
      +                        if self.api_mode == "chat_completions":
      +                            assistant_message = response.choices[0].message
      +                            if assistant_message.tool_calls:
      +                                if truncated_tool_call_retries < 1:
      +                                    truncated_tool_call_retries += 1
      +                                    self._vprint(
      +                                        f"{self.log_prefix}⚠️  Truncated tool call detected — retrying API call...",
      +                                        force=True,
      +                                    )
      +                                    # Don't append the broken response to messages;
      +                                    # just re-run the same API call from the current
      +                                    # message state, giving the model another chance.
      +                                    continue
      +                                self._vprint(
      +                                    f"{self.log_prefix}⚠️  Truncated tool call response detected again — refusing to execute incomplete tool arguments.",
      +                                    force=True,
      +                                )
      +                                self._cleanup_task_resources(effective_task_id)
      +                                self._persist_session(messages, conversation_history)
      +                                return {
      +                                    "final_response": None,
      +                                    "messages": messages,
      +                                    "api_calls": api_call_count,
      +                                    "completed": False,
      +                                    "partial": True,
      +                                    "error": "Response truncated due to output length limit",
      +                                }
      +
                               # If we have prior messages, roll back to last complete state
                               if len(messages) > 1:
                                   self._vprint(f"{self.log_prefix}   ⏪ Rolling back to last complete assistant turn")
    • tests/run_agent/test_run_agent.py
      @@ -1877,6 +1877,68 @@ def test_length_empty_content_detected_as_thinking_exhausted(self, agent):
               assert result["final_response"] is not None
               assert "Thinking Budget Exhausted" in result["final_response"]
       
      +    def test_length_with_tool_calls_returns_partial_without_executing_tools(self, agent):
      +        self._setup_agent(agent)
      +        bad_tc = _mock_tool_call(
      +            name="write_file",
      +            arguments='{"path":"report.md","content":"partial',
      +            call_id="c1",
      +        )
      +        resp = _mock_response(content="", finish_reason="length", tool_calls=[bad_tc])
      +        agent.client.chat.completions.create.return_value = resp
      +
      +        with (
      +            patch("run_agent.handle_function_call") as mock_handle_function_call,
      +            patch.object(agent, "_persist_session"),
      +            patch.object(agent, "_save_trajectory"),
      +            patch.object(agent, "_cleanup_task_resources"),
      +        ):
      +            result = agent.run_conversation("write the report")
      +
      +        assert result["completed"] is False
      +        assert result["partial"] is True
      +        assert "truncated due to output length limit" in result["error"]
      +        mock_handle_function_call.assert_not_called()
      +
      +    def test_truncated_tool_call_retries_once_before_refusing(self, agent):
      +        """When tool call args are truncated, the agent retries the API call
      +        once. If the retry succeeds (valid JSON args), tool execution proceeds."""
      +        self._setup_agent(agent)
      +        agent.valid_tool_names.add("write_file")
      +        bad_tc = _mock_tool_call(
      +            name="write_file",
      +            arguments='{"path":"report.md","content":"partial',
      +            call_id="c1",
      +        )
      +        truncated_resp = _mock_response(
      +            content="", finish_reason="length", tool_calls=[bad_tc],
      +        )
      +        good_tc = _mock_tool_call(
      +            name="write_file",
      +            arguments='{"path":"report.md","content":"full content"}',
      +            call_id="c2",
      +        )
      +        good_resp = _mock_response(
      +            content="", finish_reason="stop", tool_calls=[good_tc],
      +        )
      +        with (
      +            patch("run_agent.handle_function_call", return_value='{"success":true}') as mock_hfc,
      +            patch.object(agent, "_persist_session"),
      +            patch.object(agent, "_save_trajectory"),
      +            patch.object(agent, "_cleanup_task_resources"),
      +        ):
      +            # First call: truncated → retry. Second: valid → execute tool.
      +            # Third: final text response.
      +            final_resp = _mock_response(content="Done!", finish_reason="stop")
      +            agent.client.chat.completions.create.side_effect = [
      +                truncated_resp, good_resp, final_resp,
      +            ]
      +            result = agent.run_conversation("write the report")
      +
      +        # Tool was executed on the retry (good_resp)
      +        mock_hfc.assert_called_once()
      +        assert result["final_response"] == "Done!"
      +
       
       class TestRetryExhaustion:
           """Regression: retry_count > max_retries was dead code (off-by-one).
      @@ -3010,6 +3072,20 @@ def test_multiple_tool_calls(self, agent):
               assert tc[0].function.name == "search"
               assert tc[1].function.name == "read"
       
      +    def test_truncated_tool_call_args_upgrade_finish_reason_to_length(self, agent):
      +        chunks = [
      +            _make_chunk(tool_calls=[_make_tc_delta(0, "call_1", "write_file", '{"path":"x.txt","content":"hel')]),
      +        ]
      +        agent.client.chat.completions.create.return_value = iter(chunks)
      +
      +        resp = agent._interruptible_streaming_api_call({"messages": []})
      +
      +        tc = resp.choices[0].message.tool_calls
      +        assert len(tc) == 1
      +        assert tc[0].function.name == "write_file"
      +        assert tc[0].function.arguments == '{"path":"x.txt","content":"hel'
      +        assert resp.choices[0].finish_reason == "length"
      +
           def test_ollama_reused_index_separate_tool_calls(self, agent):
               """Ollama sends every tool call at index 0 with different ids.
       
  • #7910 fix: salvage 4 stream/loop stability fixes (#7901, #7693, #7738, #7747)

    merged to main

    files: gateway/platforms/api_server.py, gateway/run.py, gateway/session.py, run_agent.py, tests/gateway/test_api_server.py, tests/run_agent/test_run_agent.py

    Show the diff
    • gateway/platforms/api_server.py
      @@ -53,6 +53,7 @@
       DEFAULT_PORT = 8642
       MAX_STORED_RESPONSES = 100
       MAX_REQUEST_BYTES = 1_000_000  # 1 MB default limit for POST bodies
      +CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0
       
       
       def check_api_server_requirements() -> bool:
      @@ -762,7 +763,11 @@ async def _write_sse_chat_completion(
               """
               import queue as _q
       
      -        sse_headers = {"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}
      +        sse_headers = {
      +            "Content-Type": "text/event-stream",
      +            "Cache-Control": "no-cache",
      +            "X-Accel-Buffering": "no",
      +        }
               # CORS middleware can't inject headers into StreamResponse after
               # prepare() flushes them, so resolve CORS headers up front.
               origin = request.headers.get("Origin", "")
      @@ -775,6 +780,8 @@ async def _write_sse_chat_completion(
               await response.prepare(request)
       
               try:
      +            last_activity = time.monotonic()
      +
                   # Role chunk
                   role_chunk = {
                       "id": completion_id, "object": "chat.completion.chunk",
      @@ -782,6 +789,7 @@ async def _write_sse_chat_completion(
                       "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
                   }
                   await response.write(f"data: {json.dumps(role_chunk)}\n\n".encode())
      +            last_activity = time.monotonic()
       
                   # Helper — route a queue item to the correct SSE event.
                   async def _emit(item):
      @@ -805,6 +813,7 @@ async def _emit(item):
                               "choices": [{"index": 0, "delta": {"content": item}, "finish_reason": None}],
                           }
                           await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
      +                return time.monotonic()
       
                   # Stream content chunks as they arrive from the agent
                   loop = asyncio.get_event_loop()
      @@ -819,16 +828,19 @@ async def _emit(item):
                                       delta = stream_q.get_nowait()
                                       if delta is None:
                                           break
      -                                await _emit(delta)
      +                                last_activity = await _emit(delta)
                                   except _q.Empty:
                                       break
                               break
      +                    if time.monotonic() - last_activity >= CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS:
      +                        await response.write(b": keepalive\n\n")
      +                        last_activity = time.monotonic()
                           continue
       
                       if delta is None:  # End of stream sentinel
                           break
       
      -                await _emit(delta)
      +                last_activity = await _emit(delta)
       
                   # Get usage from completed agent
                   usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
    • gateway/run.py
      @@ -1465,7 +1465,18 @@ async def start(self) -> bool:
                       logger.info("Recovered %s background process(es) from previous run", recovered)
               except Exception as e:
                   logger.warning("Process checkpoint recovery: %s", e)
      -        
      +
      +        # Suspend sessions that were active when the gateway last exited.
      +        # This prevents stuck sessions from being blindly resumed on restart,
      +        # which can create an unrecoverable loop (#7536).  Suspended sessions
      +        # auto-reset on the next incoming message, giving the user a clean start.
      +        try:
      +            suspended = self.session_store.suspend_recently_active()
      +            if suspended:
      +                logger.info("Suspended %d in-flight session(s) from previous run", suspended)
      +        except Exception as e:
      +            logger.warning("Session suspension on startup failed: %s", e)
      +
               connected_count = 0
               enabled_platform_count = 0
               startup_nonretryable_errors: list[str] = []
      @@ -2370,8 +2381,11 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
                       self._pending_messages.pop(_quick_key, None)
                       if _quick_key in self._running_agents:
                           del self._running_agents[_quick_key]
      -                logger.info("HARD STOP for session %s — session lock released", _quick_key[:20])
      -                return "⚡ Force-stopped. The session is unlocked — you can send a new message."
      +                # Mark session suspended so the next message starts fresh
      +                # instead of resuming the stuck context (#7536).
      +                self.session_store.suspend_session(_quick_key)
      +                logger.info("HARD STOP for session %s — suspended, session lock released", _quick_key[:20])
      +                return "⚡ Force-stopped. The session is suspended — your next message will start fresh."
       
                   # /reset and /new must bypass the running-agent guard so they
                   # actually dispatch as commands instead of being queued as user
      @@ -2805,7 +2819,9 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
               # so the agent knows this is a fresh conversation (not an intentional /reset).
               if getattr(session_entry, 'was_auto_reset', False):
                   reset_reason = getattr(session_entry, 'auto_reset_reason', None) or 'idle'
      -            if reset_reason == "daily":
      +            if reset_reason == "suspended":
      +                context_note = "[System note: The user's previous session was stopped and suspended. This is a fresh conversation with no prior context.]"
      +            elif reset_reason == "daily":
                       context_note = "[System note: The user's session was automatically reset by the daily schedule. This is a fresh conversation with no prior context.]"
                   else:
                       context_note = "[System note: The user's previous session expired due to inactivity. This is a fresh conversation with no prior context.]"
      @@ -2822,7 +2838,9 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
                       )
                       platform_name = source.platform.value if source.platform else ""
                       had_activity = getattr(session_entry, 'reset_had_activity', False)
      -                should_notify = (
      +                # Suspended sessions always notify (they were explicitly stopped
      +                # or crashed mid-operation) — skip the policy check.
      +                should_notify = reset_reason == "suspended" or (
                           policy.notify
                           and had_activity
                           and platform_name not in policy.notify_exclude_platforms
      @@ -2830,7 +2848,9 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
                       if should_notify:
                           adapter = self.adapters.get(source.platform)
                           if adapter:
      -                        if reset_reason == "daily":
      +                        if reset_reason == "suspended":
      +                            reason_text = "previous session was stopped or interrupted"
      +                        elif reset_reason == "daily":
                                   reason_text = f"daily schedule at {policy.at_hour}:00"
                               else:
                                   hours = policy.idle_minutes // 60
      @@ -3913,25 +3933,31 @@ async def _handle_stop_command(self, event: MessageEvent) -> str:
               handles /stop before this method is reached.  This handler fires
               only through normal command dispatch (no running agent) or as a
               fallback.  Force-clean the session lock in all cases for safety.
      +
      +        When there IS a running/pending agent, the session is also marked
      +        as *suspended* so the next message starts a fresh session instead
      +        of resuming the stuck context (#7536).
               """
               source = event.source
               session_entry = self.session_store.get_or_create_session(source)
               session_key = session_entry.session_key
      -        
      +
               agent = self._running_agents.get(session_key)
               if agent is _AGENT_PENDING_SENTINEL:
                   # Force-clean the sentinel so the session is unlocked.
                   if session_key in self._running_agents:
                       del self._running_agents[session_key]
      -            logger.info("HARD STOP (pending) for session %s — sentinel cleared", session_key[:20])
      -            return "⚡ Force-stopped. The agent was still starting — session unlocked."
      +            self.session_store.suspend_session(session_key)
      +            logger.info("HARD STOP (pending) for session %s — suspended, sentinel cleared", session_key[:20])
      +            return "⚡ Force-stopped. The agent was still starting — your next message will start fresh."
               if agent:
                   agent.interrupt("Stop requested")
                   # Force-clean the session lock so a truly hung agent doesn't
                   # keep it locked forever.
                   if session_key in self._running_agents:
                       del self._running_agents[session_key]
      -            return "⚡ Force-stopped. The session is unlocked — you can send a new message."
      +            self.session_store.suspend_session(session_key)
      +            return "⚡ Force-stopped. Your next message will start a fresh session."
               else:
                   return "No active task to stop."
       
    • gateway/session.py
      @@ -368,6 +368,11 @@ class SessionEntry:
           # survives gateway restarts (the old in-memory _pre_flushed_sessions
           # set was lost on restart, causing redundant re-flushes).
           memory_flushed: bool = False
      +
      +    # When True the next call to get_or_create_session() will auto-reset
      +    # this session (create a new session_id) so the user starts fresh.
      +    # Set by /stop to break stuck-resume loops (#7536).
      +    suspended: bool = False
           
           def to_dict(self) -> Dict[str, Any]:
               result = {
      @@ -387,6 +392,7 @@ def to_dict(self) -> Dict[str, Any]:
                   "estimated_cost_usd": self.estimated_cost_usd,
                   "cost_status": self.cost_status,
                   "memory_flushed": self.memory_flushed,
      +            "suspended": self.suspended,
               }
               if self.origin:
                   result["origin"] = self.origin.to_dict()
      @@ -423,6 +429,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry":
                   estimated_cost_usd=data.get("estimated_cost_usd", 0.0),
                   cost_status=data.get("cost_status", "unknown"),
                   memory_flushed=data.get("memory_flushed", False),
      +            suspended=data.get("suspended", False),
               )
       
       
      @@ -698,7 +705,12 @@ def get_or_create_session(
                   if session_key in self._entries and not force_new:
                       entry = self._entries[session_key]
       
      -                reset_reason = self._should_reset(entry, source)
      +                # Auto-reset sessions marked as suspended (e.g. after /stop
      +                # broke a stuck loop — #7536).
      +                if entry.suspended:
      +                    reset_reason = "suspended"
      +                else:
      +                    reset_reason = self._should_reset(entry, source)
                       if not reset_reason:
                           entry.updated_at = now
                           self._save()
      @@ -771,6 +783,44 @@ def update_session(
                           entry.last_prompt_tokens = [redacted:secret-assignment]
                       self._save()
       
      +    def suspend_session(self, session_key: str) -> bool:
      +        """Mark a session as suspended so it auto-resets on next access.
      +
      +        Used by ``/stop`` to prevent stuck sessions from being resumed
      +        after a gateway restart (#7536).  Returns True if the session
      +        existed and was marked.
      +        """
      +        with self._lock:
      +            self._ensure_loaded_locked()
      +            if session_key in self._entries:
      +                self._entries[session_key].suspended = True
      +                self._save()
      +                return True
      +        return False
      +
      +    def suspend_recently_active(self, max_age_seconds: int = 120) -> int:
      +        """Mark recently-active sessions as suspended.
      +
      +        Called on gateway startup to prevent sessions that were likely
      +        in-flight when the gateway last exited from being blindly resumed
      +        (#7536).  Only suspends sessions updated within *max_age_seconds*
      +        to avoid resetting long-idle sessions that are harmless to resume.
      +        Returns the number of sessions that were suspended.
      +        """
      +        import time as _time
      +
      +        cutoff = _time.time() - max_age_seconds
      +        count = 0
      +        with self._lock:
      +            self._ensure_loaded_locked()
      +            for entry in self._entries.values():
      +                if not entry.suspended and entry.updated_at >= cutoff:
      +                    entry.suspended = True
      +                    count += 1
      +            if count:
      +                self._save()
      +        return count
      +
           def reset_session(self, session_key: str) -> Optional[SessionEntry]:
               """Force reset a session, creating a new session ID."""
               db_end_session_id = None
    • run_agent.py
      @@ -8167,8 +8167,24 @@ def _stop_spinner():
                                           _text_parts.append(getattr(_blk, "text", ""))
                                   _trunc_content = "\n".join(_text_parts) if _text_parts else None
       
      +                        # A response is "thinking exhausted" only when the model
      +                        # actually produced reasoning blocks but no visible text after
      +                        # them.  Models that do not use <think> tags (e.g. GLM-4.7 on
      +                        # NVIDIA Build, minimax) may return content=None or an empty
      +                        # string for unrelated reasons — treat those as normal
      +                        # truncations that deserve continuation retries, not as
      +                        # thinking-budget exhaustion.
      +                        _has_think_tags = bool(
      +                            _trunc_content and re.search(
      +                                r'<(?:think|thinking|reasoning|REASONING_SCRATCHPAD)[^>]*>',
      +                                _trunc_content,
      +                                re.IGNORECASE,
      +                            )
      +                        )
                               _thinking_exhausted = (
      -                            not _trunc_has_tool_calls and (
      +                            not _trunc_has_tool_calls
      +                            and _has_think_tags
      +                            and (
                                       (_trunc_content is not None and not self._has_content_after_think_block(_trunc_content))
                                       or _trunc_content is None
                                   )
      @@ -9396,12 +9412,41 @@ def _stop_spinner():
                                   invalid_json_args.append((tc.function.name, str(e)))
                           
                           if invalid_json_args:
      +                        # Check if the invalid JSON is due to truncation rather
      +                        # than a model formatting mistake.  Routers sometimes
      +                        # rewrite finish_reason from "length" to "tool_calls",
      +                        # hiding the truncation from the length handler above.
      +                        # Detect truncation: args that don't end with } or ]
      +                        # (after stripping whitespace) are cut off mid-stream.
      +                        _truncated = any(
      +                            not (tc.function.arguments or "").rstrip().endswith(("}", "]"))
      +                            for tc in assistant_message.tool_calls
      +                            if tc.function.name in {n for n, _ in invalid_json_args}
      +                        )
      +                        if _truncated:
      +                            self._vprint(
      +                                f"{self.log_prefix}⚠️  Truncated tool call arguments detected "
      +                                f"(finish_reason={finish_reason!r}) — refusing to execute.",
      +                                force=True,
      +                            )
      +                            self._invalid_json_retries = 0
      +                            self._cleanup_task_resources(effective_task_id)
      +                            self._persist_session(messages, conversation_history)
      +                            return {
      +                                "final_response": None,
      +                                "messages": messages,
      +                                "api_calls": api_call_count,
      +                                "completed": False,
      +                                "partial": True,
      +                                "error": "Response truncated due to output length limit",
      +                            }
      +
                               # Track retries for invalid JSON arguments
                               self._invalid_json_retries += 1
      -                        
      +
                               tool_name, error_msg = invalid_json_args[0]
                               self._vprint(f"{self.log_prefix}⚠️  Invalid JSON in tool call arguments for '{tool_name}': {error_msg}")
      -                        
      +
                               if self._invalid_json_retries < 3:
                                   self._vprint(f"{self.log_prefix}🔄 Retrying API call ({self._invalid_json_retries}/3)...")
                                   # Don't add anything to messages, just retry the API call
    • tests/gateway/test_api_server.py
      @@ -409,11 +409,50 @@ async def _mock_run_agent(**kwargs):
                       )
                       assert resp.status == 200
                       assert "text/event-stream" in resp.headers.get("Content-Type", "")
      +                assert resp.headers.get("X-Accel-Buffering") == "no"
                       body = await resp.text()
                       assert "data: " in body
                       assert "[DONE]" in body
                       assert "Hello!" in body
       
      +    @pytest.mark.asyncio
      +    async def test_stream_sends_keepalive_during_quiet_tool_gap(self, adapter):
      +        """Idle SSE streams should send keepalive comments while tools run silently."""
      +        import asyncio
      +        import gateway.platforms.api_server as api_server_mod
      +
      +        app = _create_app(adapter)
      +        async with TestClient(TestServer(app)) as cli:
      +            async def _mock_run_agent(**kwargs):
      +                cb = kwargs.get("stream_delta_callback")
      +                if cb:
      +                    cb("Working")
      +                    await asyncio.sleep(0.65)
      +                    cb("...done")
      +                return (
      +                    {"final_response": "Working...done", "messages": [], "api_calls": 1},
      +                    {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
      +                )
      +
      +            with (
      +                patch.object(api_server_mod, "CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS", 0.01),
      +                patch.object(adapter, "_run_agent", side_effect=_mock_run_agent),
      +            ):
      +                resp = await cli.post(
      +                    "/v1/chat/completions",
      +                    json={
      +                        "model": "test",
      +                        "messages": [{"role": "user", "content": "do the thing"}],
      +                        "stream": True,
      +                    },
      +                )
      +                assert resp.status == 200
      +                body = await resp.text()
      +                assert ": keepalive" in body
      +                assert "Working" in body
      +                assert "...done" in body
      +                assert "[DONE]" in body
      +
           @pytest.mark.asyncio
           async def test_stream_survives_tool_call_none_sentinel(self, adapter):
               """stream_delta_callback(None) mid-stream (tool calls) must NOT kill the SSE stream.
    • tests/run_agent/test_run_agent.py
      @@ -2087,8 +2087,9 @@ def test_length_thinking_exhausted_skips_continuation(self, agent):
               assert "Thinking Budget Exhausted" in result["final_response"]
               assert "/thinkon" in result["final_response"]
       
      -    def test_length_empty_content_detected_as_thinking_exhausted(self, agent):
      -        """When finish_reason='length' and content is None/empty, detect exhaustion."""
      +    def test_length_empty_content_without_think_tags_retries_normally(self, agent):
      +        """When finish_reason='length' and content is None but no think tags,
      +        fall through to normal continuation retry (not thinking-exhaustion)."""
               self._setup_agent(agent)
               resp = _mock_response(content=None, finish_reason="length")
               agent.client.chat.completions.create.return_value = resp
      @@ -2100,12 +2101,10 @@ def test_length_empty_content_detected_as_thinking_exhausted(self, agent):
               ):
                   result = agent.run_conversation("hello")
       
      +        # Without think tags, the agent should attempt continuation retries
      +        # (up to 3), not immediately fire thinking-exhaustion.
      +        assert result["api_calls"] == 3
               assert result["completed"] is False
      -        assert result["api_calls"] == 1
      -        assert "reasoning" in result["error"].lower()
      -        # User-friendly message is returned
      -        assert result["final_response"] is not None
      -        assert "Thinking Budget Exhausted" in result["final_response"]
       
           def test_length_with_tool_calls_returns_partial_without_executing_tools(self, agent):
               self._setup_agent(agent)
      @@ -2169,6 +2168,35 @@ def test_truncated_tool_call_retries_once_before_refusing(self, agent):
               mock_hfc.assert_called_once()
               assert result["final_response"] == "Done!"
       
      +    def test_truncated_tool_args_detected_when_finish_reason_not_length(self, agent):
      +        """When a router rewrites finish_reason from 'length' to 'tool_calls',
      +        truncated JSON arguments should still be detected and refused rather
      +        than wasting 3 retry attempts."""
      +        self._setup_agent(agent)
      +        agent.valid_tool_names.add("write_file")
      +        bad_tc = _mock_tool_call(
      +            name="write_file",
      +            arguments='{"path":"report.md","content":"partial',
      +            call_id="c1",
      +        )
      +        resp = _mock_response(
      +            content="", finish_reason="tool_calls", tool_calls=[bad_tc],
      +        )
      +        agent.client.chat.completions.create.return_value = resp
      +
      +        with (
      +            patch("run_agent.handle_function_call") as mock_handle_function_call,
      +            patch.object(agent, "_persist_session"),
      +            patch.object(agent, "_save_trajectory"),
      +            patch.object(agent, "_cleanup_task_resources"),
      +        ):
      +            result = agent.run_conversation("write the report")
      +
      +        assert result["completed"] is False
      +        assert result["partial"] is True
      +        assert "truncated due to output length limit" in result["error"]
      +        mock_handle_function_call.assert_not_called()
      +
       
       class TestRetryExhaustion:
           """Regression: retry_count > max_retries was dead code (off-by-one).
  • #61784 fix(agent): reject malformed tool call arguments

    merged to main

    files: agent/tool_executor.py, tests/run_agent/test_malformed_tool_arguments.py, tests/run_agent/test_run_agent.py

10 PRs closed without merging

From the close comments: merged via another change 3 · partial fix 3 · done elsewhere 1 · won't fix / by design 1 · other 2

  • #6640 fix: detect truncated tool call args in streaming mock builder, override finish_reason to 'length' · merged via another change → 2d0d05a, 17fc84c, #6638, #6776
  • #6776 fix(agent): detect truncated streaming tool calls before execution · merged via another change → #6847
  • #7693 fix: detect truncated tool_calls when finish_reason is not length · merged via another change → #7910
  • #25399 fix: validate tool_calls JSON when finish_reason='length' (#7680) · partial fix → #7680
  • #29599 fix(agent): recover write_file path from malformed content JSON · won't fix / by design → #36271
Show 5 more
  • #35171 fix: return tool error for invalid repaired args · other
  • #52747 fix(agent): repair DeepSeek double-serialized & trailing-content tool-call JSON (#52740) · partial fix
  • #60669 fix(sanitization): return structured error for truncated write_file/create_file args · partial fix → #60655
  • #60675 fix: recover truncated file tool arguments · other → #36271, #29599
  • #62978 fix(agent): surface unrepairable tool arguments · done elsewhere → #56399, #61784
Closed issues · 7
  • Closed#6638[Bug] Truncated streaming tool calls silently treated as valid — tool executes with broken/empty args
  • Closed#7680Critical: Hermes doesn't validate finish_reason for tool_calls, allowing truncated JSON to be processed
  • Closed#29597[Bug]: write_file silently fails + agent loops when model emits unescaped triple-quotes in JSON content
  • Closed#35151【Bug】Truncated tool_call arguments silently succeed instead of returning error
  • Closed#52740DeepSeek tool-call JSON serialization: 4 failure patterns (276 occurrences)
  • Closed#60655delegate_task: GLM-5.2 emits truncated tool_call arguments for write_file, causing subagent 600s timeout
  • Closed#62948write_file silently fails when content exceeds ~8 KB — tool call replaced with empty object
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.09: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#6847, #7910, #61784
Closed issues
7
Attempts
10 PRs closed without merging
Fix link checked
10 Jul 2026

For your agent

The same facts, typed.