From 862bb0f12e9663bc754ae1a8cf54a572acdc4e3a Mon Sep 17 00:00:00 2001 From: aarongxa Date: Sun, 19 Apr 2026 21:10:31 -0400 Subject: [PATCH] fix(zai): set tool_stream=true to avoid api.z.ai 30s idle timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GLM models (4.6, 4.7, 5, 5.1) batch tool_call argument generation in a single chunk by default. When the tool args are large (long file writes, big shell commands), the model goes silent for 30+ seconds while generating them, and api.z.ai's 30s server-side idle timeout kills the connection with ECONNRESET — surfacing as a "timeout" in Hermes. Z.AI's documented fix is to send tool_stream=true alongside stream=true, which makes the model emit tool args incrementally and keeps chunks flowing within the 30s window. This patch injects extra_body.tool_stream=true in _build_api_kwargs whenever the provider is "zai" or the base URL points at api.z.ai or open.bigmodel.cn, and only when tools are present (no benefit otherwise). A user-supplied extra_body.tool_stream wins via setdefault. Refs: - https://docs.z.ai/guides/capabilities/stream-tool - vercel/ai#12949 (30s idle timeout root cause analysis) - opencode#15350 (ECONNRESET on zai-coding-plan) Made-with: Cursor --- run_agent.py | 16 +++++++++ tests/run_agent/test_run_agent.py | 60 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/run_agent.py b/run_agent.py index 85eaad1b3753..ded412e05fff 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7286,6 +7286,22 @@ def _build_api_kwargs(self, api_messages: list) -> dict: if self._is_qwen_portal(): extra_body["vl_high_resolution_images"] = True + # Z.AI / GLM: enable incremental tool-call argument streaming. + # GLM models (4.6, 4.7, 5, 5.1) batch tool_call args in one chunk by + # default, producing 30+ second silence gaps that trip api.z.ai's + # 30s server-side idle timeout (vercel/ai#12949, opencode#15350). + # Setting tool_stream=true makes the model stream tool args + # incrementally, eliminating the silence gap. + # Docs: https://docs.z.ai/guides/capabilities/stream-tool + _provider_lower = (self.provider or "").lower() + _is_zai_endpoint = ( + _provider_lower == "zai" + or "z.ai" in self._base_url_lower + or "bigmodel.cn" in self._base_url_lower + ) + if _is_zai_endpoint and self.tools: + extra_body.setdefault("tool_stream", True) + if extra_body: api_kwargs["extra_body"] = extra_body diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 9bc637135c7d..f4788e16a87f 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -247,6 +247,66 @@ def _mock_response( # =================================================================== +class TestZaiToolStream: + """Verify Z.AI / GLM endpoints get tool_stream=true to avoid api.z.ai's + 30s server-side idle timeout when GLM batches tool_call argument output. + Refs: vercel/ai#12949, opencode#15350, https://docs.z.ai/guides/capabilities/stream-tool + """ + + def _make_zai_agent(self, base_url, provider="zai", model="glm-5.1"): + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("web_search"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + a = AIAgent( + model=model, + provider=provider, + base_url=base_url, + api_key="test-key-1234567890", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + a.client = MagicMock() + return a + + def test_zai_provider_injects_tool_stream(self): + agent = self._make_zai_agent("https://api.z.ai/api/coding/paas/v4") + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert kwargs["extra_body"]["tool_stream"] is True + + def test_zai_global_endpoint_injects_tool_stream(self): + agent = self._make_zai_agent( + "https://api.z.ai/api/paas/v4", provider="custom" + ) + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert kwargs["extra_body"]["tool_stream"] is True + + def test_bigmodel_cn_endpoint_injects_tool_stream(self): + agent = self._make_zai_agent( + "https://open.bigmodel.cn/api/coding/paas/v4", provider="custom" + ) + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert kwargs["extra_body"]["tool_stream"] is True + + def test_no_tools_omits_tool_stream(self): + agent = self._make_zai_agent("https://api.z.ai/api/coding/paas/v4") + agent.tools = [] + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert "tool_stream" not in kwargs.get("extra_body", {}) + + def test_non_zai_provider_omits_tool_stream(self): + agent = self._make_zai_agent( + "https://openrouter.ai/api/v1", provider="openrouter", model="x" + ) + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert "tool_stream" not in kwargs.get("extra_body", {}) + + class TestHasContentAfterThinkBlock: def test_none_returns_false(self, agent): assert agent._has_content_after_think_block(None) is False