{"repo":"NousResearch/hermes-agent","public_id":"7729","display_label":"Agent token limits, truncation detection, and stream stability","summary":"These issues and fixes address incorrect truncation/finish-reason reporting, max_tokens config not being wired through to the agent and gateway, false thinking-budget-exhaustion signals on non-reasoning models, and stream stability problems—largely surfaced by bugs with the NVIDIA Build API.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7910},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-18T19:51:31+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2419047619047619},"fix_edges":[{"issue_number":7536,"pr_number":7910,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7680,"pr_number":7910,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7729,"pr_number":7910,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":9372,"pr_number":12231,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":7729,"title":"[Bug]: NVIDIA Build API modle z-ai/glm4.7 returns Thinking Budget Exhausted","url":null,"state":"closed"},{"kind":"issue","number":9372,"title":"[Bug]: NVIDIA Build API modle z-ai/glm4.7 returns model hit max output tokens","url":null,"state":"closed"},{"kind":"issue","number":10279,"title":"[Bug]: NVIDIA Build API modle z-ai/glm4.7 returns Response truncated (finish_reason='length') - model hit max output tokens","url":null,"state":"closed"},{"kind":"pr","number":7738,"title":"fix(agent): prevent false thinking-exhaustion for non-reasoning models","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7910","number":7910}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7910,"title":"fix: salvage 4 stream/loop stability fixes (#7901, #7693, #7738, #7747)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -53,6 +53,7 @@\n DEFAULT_PORT = 8642\n MAX_STORED_RESPONSES = 100\n MAX_REQUEST_BYTES = 1_000_000  # 1 MB default limit for POST bodies\n+CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0\n \n \n def check_api_server_requirements() -> bool:","@@ -762,7 +763,11 @@ async def _write_sse_chat_completion(\n         \"\"\"\n         import queue as _q\n \n-        sse_headers = {\"Content-Type\": \"text/event-stream\", \"Cache-Control\": \"no-cache\"}\n+        sse_headers = {\n+            \"Content-Type\": \"text/event-stream\",\n+            \"Cache-Control\": \"no-cache\",\n+            \"X-Accel-Buffering\": \"no\",\n+        }\n         # CORS middleware can't inject headers into StreamResponse after\n         # prepare() flushes them, so resolve CORS headers up front.\n         origin = request.headers.get(\"Origin\", \"\")","@@ -775,6 +780,8 @@ async def _write_sse_chat_completion(\n         await response.prepare(request)\n \n         try:\n+            last_activity = time.monotonic()\n+\n             # Role chunk\n             role_chunk = {\n                 \"id\": completion_id, \"object\": \"chat.completion.chunk\",","@@ -782,6 +789,7 @@ async def _write_sse_chat_completion(\n                 \"choices\": [{\"index\": 0, \"delta\": {\"role\": \"assistant\"}, \"finish_reason\": None}],\n             }\n             await response.write(f\"data: {json.dumps(role_chunk)}\\n\\n\".encode())\n+            last_activity = time.monotonic()\n \n             # Helper — route a queue item to the correct SSE event.\n             async def _emit(item):","@@ -805,6 +813,7 @@ async def _emit(item):\n                         \"choices\": [{\"index\": 0, \"delta\": {\"content\": item}, \"finish_reason\": None}],\n                     }\n                     await response.write(f\"data: {json.dumps(content_chunk)}\\n\\n\".encode())\n+                return time.monotonic()\n \n             # Stream content chunks as they arrive from the agent\n             loop = asyncio.get_event_loop()","@@ -819,16 +828,19 @@ async def _emit(item):\n                                 delta = stream_q.get_nowait()\n                                 if delta is None:\n                                     break\n-                                await _emit(delta)\n+                                last_activity = await _emit(delta)\n                             except _q.Empty:\n                                 break\n                         break\n+                    if time.monotonic() - last_activity >= CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS:\n+                        await response.write(b\": keepalive\\n\\n\")\n+                        last_activity = time.monotonic()\n                     continue\n \n                 if delta is None:  # End of stream sentinel\n                     break\n \n-                await _emit(delta)\n+                last_activity = await _emit(delta)\n \n             # Get usage from completed agent\n             usage = {\"input_tokens\": 0, \"output_tokens\": 0, \"total_tokens\": 0}"]},{"file":"gateway/run.py","hunks":["@@ -1465,7 +1465,18 @@ async def start(self) -> bool:\n                 logger.info(\"Recovered %s background process(es) from previous run\", recovered)\n         except Exception as e:\n             logger.warning(\"Process checkpoint recovery: %s\", e)\n-        \n+\n+        # Suspend sessions that were active when the gateway last exited.\n+        # This prevents stuck sessions from being blindly resumed on restart,\n+        # which can create an unrecoverable loop (#7536).  Suspended sessions\n+        # auto-reset on the next incoming message, giving the user a clean start.\n+        try:\n+            suspended = self.session_store.suspend_recently_active()\n+            if suspended:\n+                logger.info(\"Suspended %d in-flight session(s) from previous run\", suspended)\n+        except Exception as e:\n+            logger.warning(\"Session suspension on startup failed: %s\", e)\n+\n         connected_count = 0\n         enabled_platform_count = 0\n         startup_nonretryable_errors: list[str] = []","@@ -2370,8 +2381,11 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                 self._pending_messages.pop(_quick_key, None)\n                 if _quick_key in self._running_agents:\n                     del self._running_agents[_quick_key]\n-                logger.info(\"HARD STOP for session %s — session lock released\", _quick_key[:20])\n-                return \"⚡ Force-stopped. The session is unlocked — you can send a new message.\"\n+                # Mark session suspended so the next message starts fresh\n+                # instead of resuming the stuck context (#7536).\n+                self.session_store.suspend_session(_quick_key)\n+                logger.info(\"HARD STOP for session %s — suspended, session lock released\", _quick_key[:20])\n+                return \"⚡ Force-stopped. The session is suspended — your next message will start fresh.\"\n \n             # /reset and /new must bypass the running-agent guard so they\n             # 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):\n         # so the agent knows this is a fresh conversation (not an intentional /reset).\n         if getattr(session_entry, 'was_auto_reset', False):\n             reset_reason = getattr(session_entry, 'auto_reset_reason', None) or 'idle'\n-            if reset_reason == \"daily\":\n+            if reset_reason == \"suspended\":\n+                context_note = \"[System note: The user's previous session was stopped and suspended. This is a fresh conversation with no prior context.]\"\n+            elif reset_reason == \"daily\":\n                 context_note = \"[System note: The user's session was automatically reset by the daily schedule. This is a fresh conversation with no prior context.]\"\n             else:\n                 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):\n                 )\n                 platform_name = source.platform.value if source.platform else \"\"\n                 had_activity = getattr(session_entry, 'reset_had_activity', False)\n-                should_notify = (\n+                # Suspended sessions always notify (they were explicitly stopped\n+                # or crashed mid-operation) — skip the policy check.\n+                should_notify = reset_reason == \"suspended\" or (\n                     policy.notify\n                     and had_activity\n                     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):\n                 if should_notify:\n                     adapter = self.adapters.get(source.platform)\n                     if adapter:\n-                        if reset_reason == \"daily\":\n+                        if reset_reason == \"suspended\":\n+                            reason_text = \"previous session was stopped or interrupted\"\n+                        elif reset_reason == \"daily\":\n                             reason_text = f\"daily schedule at {policy.at_hour}:00\"\n                         else:\n                             hours = policy.idle_minutes // 60","@@ -3913,25 +3933,31 @@ async def _handle_stop_command(self, event: MessageEvent) -> str:\n         handles /stop before this method is reached.  This handler fires\n         only through normal command dispatch (no running agent) or as a\n         fallback.  Force-clean the session lock in all cases for safety.\n+\n+        When there IS a running/pending agent, the session is also marked\n+        as *suspended* so the next message starts a fresh session instead\n+        of resuming the stuck context (#7536).\n         \"\"\"\n         source = event.source\n         session_entry = self.session_store.get_or_create_session(source)\n         session_key = session_entry.session_key\n-        \n+\n         agent = self._running_agents.get(session_key)\n         if agent is _AGENT_PENDING_SENTINEL:\n             # Force-clean the sentinel so the session is unlocked.\n             if session_key in self._running_agents:\n                 del self._running_agents[session_key]\n-            logger.info(\"HARD STOP (pending) for session %s — sentinel cleared\", session_key[:20])\n-            return \"⚡ Force-stopped. The agent was still starting — session unlocked.\"\n+            self.session_store.suspend_session(session_key)\n+            logger.info(\"HARD STOP (pending) for session %s — suspended, sentinel cleared\", session_key[:20])\n+            return \"⚡ Force-stopped. The agent was still starting — your next message will start fresh.\"\n         if agent:\n             agent.interrupt(\"Stop requested\")\n             # Force-clean the session lock so a truly hung agent doesn't\n             # keep it locked forever.\n             if session_key in self._running_agents:\n                 del self._running_agents[session_key]\n-            return \"⚡ Force-stopped. The session is unlocked — you can send a new message.\"\n+            self.session_store.suspend_session(session_key)\n+            return \"⚡ Force-stopped. Your next message will start a fresh session.\"\n         else:\n             return \"No active task to stop.\"\n "]},{"file":"gateway/session.py","hunks":["@@ -368,6 +368,11 @@ class SessionEntry:\n     # survives gateway restarts (the old in-memory _pre_flushed_sessions\n     # set was lost on restart, causing redundant re-flushes).\n     memory_flushed: bool = False\n+\n+    # When True the next call to get_or_create_session() will auto-reset\n+    # this session (create a new session_id) so the user starts fresh.\n+    # Set by /stop to break stuck-resume loops (#7536).\n+    suspended: bool = False\n     \n     def to_dict(self) -> Dict[str, Any]:\n         result = {","@@ -387,6 +392,7 @@ def to_dict(self) -> Dict[str, Any]:\n             \"estimated_cost_usd\": self.estimated_cost_usd,\n             \"cost_status\": self.cost_status,\n             \"memory_flushed\": self.memory_flushed,\n+            \"suspended\": self.suspended,\n         }\n         if self.origin:\n             result[\"origin\"] = self.origin.to_dict()","@@ -423,6 +429,7 @@ def from_dict(cls, data: Dict[str, Any]) -> \"SessionEntry\":\n             estimated_cost_usd=data.get(\"estimated_cost_usd\", 0.0),\n             cost_status=data.get(\"cost_status\", \"unknown\"),\n             memory_flushed=data.get(\"memory_flushed\", False),\n+            suspended=data.get(\"suspended\", False),\n         )\n \n ","@@ -698,7 +705,12 @@ def get_or_create_session(\n             if session_key in self._entries and not force_new:\n                 entry = self._entries[session_key]\n \n-                reset_reason = self._should_reset(entry, source)\n+                # Auto-reset sessions marked as suspended (e.g. after /stop\n+                # broke a stuck loop — #7536).\n+                if entry.suspended:\n+                    reset_reason = \"suspended\"\n+                else:\n+                    reset_reason = self._should_reset(entry, source)\n                 if not reset_reason:\n                     entry.updated_at = now\n                     self._save()","@@ -771,6 +783,44 @@ def update_session(\n                     entry.last_prompt_tokens = [redacted:secret-assignment]\n                 self._save()\n \n+    def suspend_session(self, session_key: str) -> bool:\n+        \"\"\"Mark a session as suspended so it auto-resets on next access.\n+\n+        Used by ``/stop`` to prevent stuck sessions from being resumed\n+        after a gateway restart (#7536).  Returns True if the session\n+        existed and was marked.\n+        \"\"\"\n+        with self._lock:\n+            self._ensure_loaded_locked()\n+            if session_key in self._entries:\n+                self._entries[session_key].suspended = True\n+                self._save()\n+                return True\n+        return False\n+\n+    def suspend_recently_active(self, max_age_seconds: int = 120) -> int:\n+        \"\"\"Mark recently-active sessions as suspended.\n+\n+        Called on gateway startup to prevent sessions that were likely\n+        in-flight when the gateway last exited from being blindly resumed\n+        (#7536).  Only suspends sessions updated within *max_age_seconds*\n+        to avoid resetting long-idle sessions that are harmless to resume.\n+        Returns the number of sessions that were suspended.\n+        \"\"\"\n+        import time as _time\n+\n+        cutoff = _time.time() - max_age_seconds\n+        count = 0\n+        with self._lock:\n+            self._ensure_loaded_locked()\n+            for entry in self._entries.values():\n+                if not entry.suspended and entry.updated_at >= cutoff:\n+                    entry.suspended = True\n+                    count += 1\n+            if count:\n+                self._save()\n+        return count\n+\n     def reset_session(self, session_key: str) -> Optional[SessionEntry]:\n         \"\"\"Force reset a session, creating a new session ID.\"\"\"\n         db_end_session_id = None"]},{"file":"run_agent.py","hunks":["@@ -8167,8 +8167,24 @@ def _stop_spinner():\n                                     _text_parts.append(getattr(_blk, \"text\", \"\"))\n                             _trunc_content = \"\\n\".join(_text_parts) if _text_parts else None\n \n+                        # A response is \"thinking exhausted\" only when the model\n+                        # actually produced reasoning blocks but no visible text after\n+                        # them.  Models that do not use <think> tags (e.g. GLM-4.7 on\n+                        # NVIDIA Build, minimax) may return content=None or an empty\n+                        # string for unrelated reasons — treat those as normal\n+                        # truncations that deserve continuation retries, not as\n+                        # thinking-budget exhaustion.\n+                        _has_think_tags = bool(\n+                            _trunc_content and re.search(\n+                                r'<(?:think|thinking|reasoning|REASONING_SCRATCHPAD)[^>]*>',\n+                                _trunc_content,\n+                                re.IGNORECASE,\n+                            )\n+                        )\n                         _thinking_exhausted = (\n-                            not _trunc_has_tool_calls and (\n+                            not _trunc_has_tool_calls\n+                            and _has_think_tags\n+                            and (\n                                 (_trunc_content is not None and not self._has_content_after_think_block(_trunc_content))\n                                 or _trunc_content is None\n                             )","@@ -9396,12 +9412,41 @@ def _stop_spinner():\n                             invalid_json_args.append((tc.function.name, str(e)))\n                     \n                     if invalid_json_args:\n+                        # Check if the invalid JSON is due to truncation rather\n+                        # than a model formatting mistake.  Routers sometimes\n+                        # rewrite finish_reason from \"length\" to \"tool_calls\",\n+                        # hiding the truncation from the length handler above.\n+                        # Detect truncation: args that don't end with } or ]\n+                        # (after stripping whitespace) are cut off mid-stream.\n+                        _truncated = any(\n+                            not (tc.function.arguments or \"\").rstrip().endswith((\"}\", \"]\"))\n+                            for tc in assistant_message.tool_calls\n+                            if tc.function.name in {n for n, _ in invalid_json_args}\n+                        )\n+                        if _truncated:\n+                            self._vprint(\n+                                f\"{self.log_prefix}⚠️  Truncated tool call arguments detected \"\n+                                f\"(finish_reason={finish_reason!r}) — refusing to execute.\",\n+                                force=True,\n+                            )\n+                            self._invalid_json_retries = 0\n+                            self._cleanup_task_resources(effective_task_id)\n+                            self._persist_session(messages, conversation_history)\n+                            return {\n+                                \"final_response\": None,\n+                                \"messages\": messages,\n+                                \"api_calls\": api_call_count,\n+                                \"completed\": False,\n+                                \"partial\": True,\n+                                \"error\": \"Response truncated due to output length limit\",\n+                            }\n+\n                         # Track retries for invalid JSON arguments\n                         self._invalid_json_retries += 1\n-                        \n+\n                         tool_name, error_msg = invalid_json_args[0]\n                         self._vprint(f\"{self.log_prefix}⚠️  Invalid JSON in tool call arguments for '{tool_name}': {error_msg}\")\n-                        \n+\n                         if self._invalid_json_retries < 3:\n                             self._vprint(f\"{self.log_prefix}🔄 Retrying API call ({self._invalid_json_retries}/3)...\")\n                             # Don't add anything to messages, just retry the API call"]},{"file":"tests/gateway/test_api_server.py","hunks":["@@ -409,11 +409,50 @@ async def _mock_run_agent(**kwargs):\n                 )\n                 assert resp.status == 200\n                 assert \"text/event-stream\" in resp.headers.get(\"Content-Type\", \"\")\n+                assert resp.headers.get(\"X-Accel-Buffering\") == \"no\"\n                 body = await resp.text()\n                 assert \"data: \" in body\n                 assert \"[DONE]\" in body\n                 assert \"Hello!\" in body\n \n+    @pytest.mark.asyncio\n+    async def test_stream_sends_keepalive_during_quiet_tool_gap(self, adapter):\n+        \"\"\"Idle SSE streams should send keepalive comments while tools run silently.\"\"\"\n+        import asyncio\n+        import gateway.platforms.api_server as api_server_mod\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            async def _mock_run_agent(**kwargs):\n+                cb = kwargs.get(\"stream_delta_callback\")\n+                if cb:\n+                    cb(\"Working\")\n+                    await asyncio.sleep(0.65)\n+                    cb(\"...done\")\n+                return (\n+                    {\"final_response\": \"Working...done\", \"messages\": [], \"api_calls\": 1},\n+                    {\"input_tokens\": 10, \"output_tokens\": 5, \"total_tokens\": 15},\n+                )\n+\n+            with (\n+                patch.object(api_server_mod, \"CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS\", 0.01),\n+                patch.object(adapter, \"_run_agent\", side_effect=_mock_run_agent),\n+            ):\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    json={\n+                        \"model\": \"test\",\n+                        \"messages\": [{\"role\": \"user\", \"content\": \"do the thing\"}],\n+                        \"stream\": True,\n+                    },\n+                )\n+                assert resp.status == 200\n+                body = await resp.text()\n+                assert \": keepalive\" in body\n+                assert \"Working\" in body\n+                assert \"...done\" in body\n+                assert \"[DONE]\" in body\n+\n     @pytest.mark.asyncio\n     async def test_stream_survives_tool_call_none_sentinel(self, adapter):\n         \"\"\"stream_delta_callback(None) mid-stream (tool calls) must NOT kill the SSE stream."]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -2087,8 +2087,9 @@ def test_length_thinking_exhausted_skips_continuation(self, agent):\n         assert \"Thinking Budget Exhausted\" in result[\"final_response\"]\n         assert \"/thinkon\" in result[\"final_response\"]\n \n-    def test_length_empty_content_detected_as_thinking_exhausted(self, agent):\n-        \"\"\"When finish_reason='length' and content is None/empty, detect exhaustion.\"\"\"\n+    def test_length_empty_content_without_think_tags_retries_normally(self, agent):\n+        \"\"\"When finish_reason='length' and content is None but no think tags,\n+        fall through to normal continuation retry (not thinking-exhaustion).\"\"\"\n         self._setup_agent(agent)\n         resp = _mock_response(content=None, finish_reason=\"length\")\n         agent.client.chat.completions.create.return_value = resp","@@ -2100,12 +2101,10 @@ def test_length_empty_content_detected_as_thinking_exhausted(self, agent):\n         ):\n             result = agent.run_conversation(\"hello\")\n \n+        # Without think tags, the agent should attempt continuation retries\n+        # (up to 3), not immediately fire thinking-exhaustion.\n+        assert result[\"api_calls\"] == 3\n         assert result[\"completed\"] is False\n-        assert result[\"api_calls\"] == 1\n-        assert \"reasoning\" in result[\"error\"].lower()\n-        # User-friendly message is returned\n-        assert result[\"final_response\"] is not None\n-        assert \"Thinking Budget Exhausted\" in result[\"final_response\"]\n \n     def test_length_with_tool_calls_returns_partial_without_executing_tools(self, agent):\n         self._setup_agent(agent)","@@ -2169,6 +2168,35 @@ def test_truncated_tool_call_retries_once_before_refusing(self, agent):\n         mock_hfc.assert_called_once()\n         assert result[\"final_response\"] == \"Done!\"\n \n+    def test_truncated_tool_args_detected_when_finish_reason_not_length(self, agent):\n+        \"\"\"When a router rewrites finish_reason from 'length' to 'tool_calls',\n+        truncated JSON arguments should still be detected and refused rather\n+        than wasting 3 retry attempts.\"\"\"\n+        self._setup_agent(agent)\n+        agent.valid_tool_names.add(\"write_file\")\n+        bad_tc = _mock_tool_call(\n+            name=\"write_file\",\n+            arguments='{\"path\":\"report.md\",\"content\":\"partial',\n+            call_id=\"c1\",\n+        )\n+        resp = _mock_response(\n+            content=\"\", finish_reason=\"tool_calls\", tool_calls=[bad_tc],\n+        )\n+        agent.client.chat.completions.create.return_value = resp\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\") as mock_handle_function_call,\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"write the report\")\n+\n+        assert result[\"completed\"] is False\n+        assert result[\"partial\"] is True\n+        assert \"truncated due to output length limit\" in result[\"error\"]\n+        mock_handle_function_call.assert_not_called()\n+\n \n class TestRetryExhaustion:\n     \"\"\"Regression: retry_count > max_retries was dead code (off-by-one).\n"]}]},{"kind":"pr","number":9489,"title":"fix(agent): boost max_tokens on length-continuation retries","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f7af90e2d","number":0},{"kind":"pr","value":"#12231","number":12231}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12231,"title":"fix: wire ephemeral max_tokens into chat_completions + NVIDIA NIM default","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","scripts/release.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -7061,8 +7061,20 @@ def _build_api_kwargs(self, api_messages: list) -> dict:\n         if self.tools:\n             api_kwargs[\"tools\"] = self.tools\n \n-        if self.max_tokens is not None:\n+        # ── max_tokens for chat_completions ──────────────────────────────\n+        # Priority: ephemeral override (error recovery / length-continuation\n+        # boost) > user-configured max_tokens > provider-specific defaults.\n+        _ephemeral_out = getattr(self, \"_ephemeral_max_output_tokens\", None)\n+        if _ephemeral_out is not None:\n+            self._ephemeral_max_output_tokens = None  # consume immediately\n+            api_kwargs.update(self._max_tokens_param(_ephemeral_out))\n+        elif self.max_tokens is not None:\n             api_kwargs.update(self._max_tokens_param(self.max_tokens))\n+        elif \"integrate.api.nvidia.com\" in self._base_url_lower:\n+            # NVIDIA NIM defaults to a very low max_tokens when omitted,\n+            # causing models like GLM-4.7 to truncate immediately (thinking\n+            # tokens alone exhaust the budget).  16384 provides adequate room.\n+            api_kwargs.update(self._max_tokens_param(16384))\n         elif self._is_qwen_portal():\n             # Qwen Portal defaults to a very low max_tokens when omitted.\n             # Reasoning models (qwen3-coder-plus) exhaust that budget on","@@ -10804,6 +10816,12 @@ def _stop_spinner():\n                 continue\n \n             if restart_with_length_continuation:\n+                # Progressively boost the output token budget on each retry.\n+                # Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768.\n+                # Applies to all providers via _ephemeral_max_output_tokens.\n+                _boost_base = self.max_tokens if self.max_tokens else 4096\n+                _boost = _boost_base * (length_continue_retries + 1)\n+                self._ephemeral_max_output_tokens = [redacted:secret-assignment] 32768)\n                 continue\n \n             # Guard: if all retries exhausted without a successful response"]},{"file":"scripts/release.py","hunks":["@@ -267,6 +267,7 @@\n     \"[redacted:email]\": \"AviArora02-commits\",\n     \"[redacted:email]\": \"JimLiu\",\n     \"[redacted:email]\": \"maxchernin\",\n+    \"[redacted:email]\": \"LVT382009\",\n }\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}