diff --git a/src/agent_loop.py b/src/agent_loop.py index 3b45c77d5..cb2d33b8c 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -27833,16 +27833,25 @@ async def stream_agent_loop( if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text): _streamed_tool_markup += _delta_text if not _streamed_tool_markup_complete(_streamed_tool_markup): - continue - _visible_markup_tail = strip_tool_blocks( - _streamed_tool_markup, - skip_fenced=True, - ).strip() - _streamed_tool_markup = "" - if not _visible_markup_tail: - continue - _delta_text = _visible_markup_tail - data["delta"] = _delta_text + if _streamed_tool_markup_starts(_streamed_tool_markup): + continue + # False alarm: a bare "<" (e.g. ``x < 2``) looked like + # a tag prefix, but the buffer can no longer become a + # tool tag. Release it as prose instead of holding the + # rest of the answer forever. + _delta_text = _streamed_tool_markup + _streamed_tool_markup = "" + data["delta"] = _delta_text + else: + _visible_markup_tail = strip_tool_blocks( + _streamed_tool_markup, + skip_fenced=True, + ).strip() + _streamed_tool_markup = "" + if not _visible_markup_tail: + continue + _delta_text = _visible_markup_tail + data["delta"] = _delta_text full_response += _delta_text if not _suppress_unavailable_web_delta: _qwen_buffered_route = bool( diff --git a/tests/test_streamed_tool_markup.py b/tests/test_streamed_tool_markup.py index db1449ec2..e031dad42 100644 --- a/tests/test_streamed_tool_markup.py +++ b/tests/test_streamed_tool_markup.py @@ -27,3 +27,102 @@ def test_dsml_tool_markup_strips_to_empty_visible_text(): def test_chunk_split_tool_call_prefix_is_buffered_and_not_persisted(): assert _streamed_tool_markup_starts('I will search" + assert _streamed_tool_markup_starts(' <') + assert _streamed_tool_markup_starts('x <') + assert _streamed_tool_markup_starts('{"name":"host_shell","arguments":{"command":"pwd"}}', + "", + " done", + ] + for c in chunks: + yield "data: " + json.dumps({"delta": c}) + "\n\n" + yield "data: [DONE]\n\n" + + monkeypatch.setattr(al, "get_setting", lambda k, d=None: d, raising=False) + monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False) + monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False) + monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream, raising=False) + + deltas = [] + async for chunk in al.stream_agent_loop( + "http://local.test/v1", + "qwen3.6-35b-a3b-heretic", + [{"role": "user", "content": "run command"}], + max_rounds=1, + owner="test_user", + ): + for line in chunk.split("\n"): + line = line.strip() + if line.startswith("data: ") and line[6:] != "[DONE]": + try: + payload = json.loads(line[6:]) + if "delta" in payload: + deltas.append(payload["delta"]) + except Exception: + pass + + assert "".join(deltas) == "Sure. done"