Repository navigation
models: preserve tool calls in mixed text stream chunks #363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
99396f8
2845ebb
2d8ec8b
ed2c396
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,266 @@ | ||
| # Tencent is pleased to support the open source community by making tRPC-Agent-Python available. | ||
| # | ||
| # Copyright (C) 2026 Tencent. All rights reserved. | ||
| # | ||
| # tRPC-Agent-Python is licensed under Apache-2.0. | ||
|
|
||
| import json | ||
| from types import SimpleNamespace | ||
| from unittest.mock import AsyncMock, Mock, patch | ||
|
|
||
| import pytest | ||
| from trpc_agent_sdk.models import LlmRequest, OpenAIModel | ||
| from trpc_agent_sdk.models._openai_model import const | ||
|
|
||
|
|
||
| async def collect(deltas, streaming_names=None, usage_only=True): | ||
| chunks = [{"id": "synthetic-response", "choices": [{"delta": delta, "finish_reason": None}]} for delta in deltas] | ||
| usage = {"prompt_tokens": 4, "completion_tokens": 6, "total_tokens": 10} | ||
| chunks.append({"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}) | ||
| if usage_only: | ||
| chunks.append({"choices": [], "usage": usage}) | ||
| else: | ||
| chunks[0]["usage"] = usage | ||
|
|
||
| async def stream(): | ||
| for chunk in chunks: | ||
| yield Mock(model_dump=lambda value=chunk: value) | ||
|
|
||
| client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=AsyncMock(return_value=stream()))), | ||
| close=AsyncMock()) | ||
| model = OpenAIModel(model_name="synthetic", api_key="synthetic") | ||
| request = LlmRequest() | ||
| request.streaming_tool_names = streaming_names | ||
| with patch.object(OpenAIModel, "_create_async_client", return_value=client): | ||
| return [response async for response in model._generate_stream({}, request)] | ||
|
|
||
|
|
||
| def tool_delta(index, arguments, first=True): | ||
| result = {"index": index, "function": {"arguments": arguments}} | ||
| if first: | ||
| result.update(id=f"call_{index}", type="function") | ||
| result["function"]["name"] = f"tool_{index}" | ||
| return result | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("text", [{}, { | ||
| "content": "text" | ||
| }, { | ||
| "reasoning_content": "synthetic" | ||
| }, { | ||
| "content": "text", | ||
| "reasoning_content": "synthetic" | ||
| }, { | ||
| "content": "", | ||
| "reasoning_content": None | ||
| }]) | ||
| @pytest.mark.parametrize("fragmented", [False, True]) | ||
| @pytest.mark.parametrize("tool_count", [1, 2]) | ||
| @pytest.mark.parametrize("streaming", [False, True]) | ||
| @pytest.mark.asyncio | ||
| async def test_mixed_stream_preserves_calls_and_text(text, fragmented, tool_count, streaming): | ||
| arguments = json.dumps({"value": "中文", "count": 12}, ensure_ascii=False) | ||
| fragments = [arguments[:12], arguments[12:]] if fragmented else [arguments] | ||
| deltas = [ | ||
| dict(text, tool_calls=[tool_delta(index, fragment, first=part == 0) for index in range(tool_count)]) | ||
| for part, fragment in enumerate(fragments) | ||
| ] | ||
| responses = await collect(deltas, {"tool_0"} if streaming else set(), usage_only=fragmented) | ||
| final = responses[-1] | ||
| assert not final.partial | ||
| assert final.error_code is None | ||
| assert final.response_id == "synthetic-response" | ||
| assert final.usage_metadata.total_token_count == 10 | ||
| calls = [part.function_call for part in final.content.parts if part.function_call] | ||
| assert [(call.id, call.name, call.args) for call in calls] == [(f"call_{index}", f"tool_{index}", { | ||
| "value": "中文", | ||
| "count": 12 | ||
| }) for index in range(tool_count)] | ||
| for thought, field in [(False, "content"), (True, "reasoning_content")]: | ||
| expected = (text.get(field) or "") * len(fragments) | ||
| assert "".join(part.text for part in final.content.parts | ||
| if part.text and bool(part.thought) == thought) == expected | ||
| assert "".join(part.text for response in responses[:-1] if response.content for part in response.content.parts | ||
| if part.text and bool(part.thought) == thought) == expected | ||
| partial_calls = [ | ||
| part.function_call for response in responses[:-1] if response.content for part in response.content.parts | ||
| if part.function_call | ||
| ] | ||
| assert [(call.id, call.name, call.args) for call in partial_calls] == ([("call_0", "tool_0", { | ||
| const.TOOL_STREAMING_ARGS: fragment | ||
| }) for fragment in fragments] if streaming else []) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_separate_text_and_tool_chunks(): | ||
| responses = await collect([{"content": "text"}, {"tool_calls": [tool_delta(0, '{"ok": true}')]}]) | ||
| parts = responses[-1].content.parts | ||
| assert parts[0].text == "text" | ||
| assert parts[1].function_call.args == {"ok": True} | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("mixed", [False, True]) | ||
| @pytest.mark.asyncio | ||
| async def test_invalid_json_preserves_error_envelope(mixed): | ||
| delta = {"tool_calls": [tool_delta(0, 'not-json'), tool_delta(1, '{"ok": true}')]} | ||
| if mixed: | ||
| delta["content"] = "text" | ||
| responses = await collect([delta]) | ||
| final = responses[-1] | ||
| calls = [part.function_call for part in final.content.parts if part.function_call] | ||
| assert [call.name for call in calls] == ["tool_0", "tool_1"] | ||
| assert const.TOOL_CALL_ARGUMENT_ERRORS in calls[0].args | ||
| assert calls[1].args == {"ok": True} | ||
| assert final.error_code is None | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("mixed_index", [0, 1, 2]) | ||
| @pytest.mark.asyncio | ||
| async def test_one_mixed_fragment_cannot_silently_change_arguments(mixed_index): | ||
| fragments = ['{"count":1', '2', '}'] | ||
| deltas = [{"tool_calls": [tool_delta(0, fragment, first=index == 0)]} for index, fragment in enumerate(fragments)] | ||
| deltas[mixed_index]["content"] = "text" | ||
| responses = await collect(deltas, {"tool_0"}) | ||
| calls = [part.function_call for part in responses[-1].content.parts if part.function_call] | ||
| assert len(calls) == 1 | ||
| assert calls[0].id == "call_0" | ||
| assert calls[0].args == {"count": 12} | ||
| streamed = [ | ||
| part.function_call.args[const.TOOL_STREAMING_ARGS] for response in responses[:-1] if response.content | ||
| for part in response.content.parts if part.function_call | ||
| ] | ||
| assert streamed == fragments | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_mixed_chunk_text_precedes_streaming_tool_event(): | ||
| responses = await collect([{ | ||
| "content": "Recording now.", | ||
| "tool_calls": [tool_delta(0, '{"count":12}')] | ||
| }], {"tool_0"}) | ||
| assert responses[0].partial | ||
| assert responses[0].content.parts[0].text == "Recording now." | ||
| assert responses[1].partial | ||
| assert responses[1].content.parts[0].function_call.name == "tool_0" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_mixed_chunks_keep_text_through_anthropic_proxy(): | ||
| pytest.importorskip("claude_agent_sdk") | ||
| from trpc_agent_sdk.server.agents.claude._proxy import AnthropicMessagesRequest, AnthropicProxyApp | ||
|
|
||
| responses = await collect([ | ||
| { | ||
| "content": "Recording ", | ||
| "tool_calls": [tool_delta(0, '{"count":')] | ||
| }, | ||
| { | ||
| "content": "now.", | ||
| "tool_calls": [tool_delta(0, '12}', first=False)] | ||
| }, | ||
| ], {"tool_0"}) | ||
|
|
||
| async def generate(request, stream=True): | ||
| for response in responses: | ||
| yield response | ||
|
|
||
| model = SimpleNamespace(generate_async=generate) | ||
| request = AnthropicMessagesRequest(model="synthetic", messages=[{"role": "user", "content": "record"}], stream=True) | ||
| events = [event async for event in AnthropicProxyApp()._handle_streaming(LlmRequest(), request, model)] | ||
| payloads = [ | ||
| json.loads(line[6:]) for event in events for line in event.splitlines() | ||
| if line.startswith("data: ") and line != "data: [DONE]" | ||
| ] | ||
| deltas = [payload["delta"] for payload in payloads if payload["type"] == "content_block_delta"] | ||
| assert "".join(delta["text"] for delta in deltas if delta["type"] == "text_delta") == "Recording now." | ||
| assert "".join(delta["partial_json"] for delta in deltas if delta["type"] == "input_json_delta") == '{"count":12}' | ||
|
|
||
| starts = [payload["index"] for payload in payloads if payload["type"] == "content_block_start"] | ||
| stops = [payload["index"] for payload in payloads if payload["type"] == "content_block_stop"] | ||
| assert len(starts) == len(set(starts)) | ||
| assert sorted(starts) == sorted(stops) | ||
|
Comment on lines
+156
to
+181
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 问题: 新测试存在两处盲区,无法覆盖本次变更引入的若干新代码行与回归面:1) 测试模型均未设置 触发条件: 后续修改若破坏了过滤抑制路径下的工具事件产出、stop 顺序或转换链路上的 streaming_tool_names 设置,整套测试仍保持绿色。 实际影响: 本次变更新增的最复杂状态机(文本块重开、事件经多个 continue 分支产出)缺少有效回归防护,回归只能靠生产事故暴露。 修正方向: 为工具提示过滤路径补充用例(构造 |
||
|
|
||
|
|
||
| @pytest.mark.parametrize("mixed", [False, True]) | ||
| @pytest.mark.asyncio | ||
| async def test_streaming_tool_events_keep_response_identity_and_chunk_metadata(mixed): | ||
| deltas = [ | ||
| { | ||
| "tool_calls": [tool_delta(0, '{"count":')] | ||
| }, | ||
| { | ||
| "tool_calls": [tool_delta(0, '12}', first=False)] | ||
| }, | ||
| ] | ||
| if mixed: | ||
| for delta in deltas: | ||
| delta["content"] = "text" | ||
| responses = await collect(deltas, {"tool_0"}) | ||
| tool_events = [ | ||
| response for response in responses[:-1] if any(part.function_call for part in response.content.parts) | ||
| ] | ||
| assert len(tool_events) == 2 | ||
| for event, delta in zip(tool_events, deltas): | ||
| assert event.response_id == responses[-1].response_id == "synthetic-response" | ||
| assert event.custom_metadata[const.TOOL_STREAMING] is True | ||
| assert event.custom_metadata[const.CHUNK]["choices"][0]["delta"] == delta | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_fragmented_invalid_json_in_mixed_chunks_keeps_error_envelope(): | ||
| responses = await collect([ | ||
| { | ||
| "content": "Recording ", | ||
| "tool_calls": [tool_delta(0, 'not-')] | ||
| }, | ||
| { | ||
| "content": "now.", | ||
| "tool_calls": [tool_delta(0, 'json', first=False)] | ||
| }, | ||
| ], {"tool_0"}) | ||
| assert [bool(response.content.parts[0].text) for response in responses[:-1]] == [True, False, True, False] | ||
| call = next(part.function_call for part in responses[-1].content.parts if part.function_call) | ||
| assert call.name == "tool_0" | ||
| assert const.TOOL_CALL_ARGUMENT_ERRORS in call.args | ||
| assert responses[-1].error_code is None | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_proxy_closes_open_blocks_when_interleaved_stream_fails(): | ||
| pytest.importorskip("claude_agent_sdk") | ||
| from trpc_agent_sdk.server.agents.claude._proxy import AnthropicMessagesRequest, AnthropicProxyApp | ||
|
|
||
| responses = await collect([ | ||
| { | ||
| "content": "Recording ", | ||
| "tool_calls": [tool_delta(0, '{"count":')] | ||
| }, | ||
| { | ||
| "content": "now." | ||
| }, | ||
| ], {"tool_0"}) | ||
|
|
||
| async def generate(request, stream=True): | ||
| for response in responses[:-1]: | ||
| yield response | ||
| raise RuntimeError("test stream interrupted") | ||
|
|
||
| request = AnthropicMessagesRequest(model="synthetic", messages=[{"role": "user", "content": "record"}], stream=True) | ||
| model = SimpleNamespace(generate_async=generate) | ||
| events = [event async for event in AnthropicProxyApp()._handle_streaming(LlmRequest(), request, model)] | ||
| payloads = [ | ||
| json.loads(line[6:]) for event in events for line in event.splitlines() | ||
| if line.startswith("data: ") and line != "data: [DONE]" | ||
| ] | ||
| open_blocks = set() | ||
| for payload in payloads: | ||
| if payload["type"] == "content_block_start": | ||
| assert payload["index"] not in open_blocks | ||
| open_blocks.add(payload["index"]) | ||
| elif payload["type"] == "content_block_stop": | ||
| assert payload["index"] in open_blocks | ||
| open_blocks.remove(payload["index"]) | ||
| elif payload["type"] in {"message_delta", "message_stop"}: | ||
| assert not open_blocks | ||
| assert not open_blocks | ||
| assert next(p for p in payloads if p["type"] == "message_delta")["delta"]["stop_reason"] == "error" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1118,7 +1118,7 @@ def _process_responses_usage(self, response_dict: dict) -> Optional[GenerateCont | |
| def _process_chunk_without_content( | ||
| self, chunk_dict: dict, accumulated_tool_calls: list[dict] | ||
| ) -> tuple[Optional[FinishReason], Optional[GenerateContentResponseUsageMetadata], dict[int, str]]: | ||
| """Process a chunk that doesn't contain content. | ||
| """Process tool calls and usage, including chunks that also contain text. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 问题: 本提交将 触发条件: 代码维护期;高 token 速率流式场景下每个文本 chunk 的多余 dict 遍历。 实际影响: 命名与调用语义背离易诱导后续维护者把调用移回旧的 "if not verify" 分支内(重新引入本提交修复的 bug);重复解析是热点循环中的冗余开销(数值上很小)。 修正方向: 将方法更名为 |
||
|
|
||
| Args: | ||
| chunk_dict (`dict`): The chunk dictionary to process | ||
|
|
@@ -1149,7 +1149,7 @@ def _process_chunk_without_content( | |
| # Handle usage | ||
| usage = self._process_usage(chunk_dict) | ||
|
|
||
| # Handle tool calls in chunks without content (this is where streaming tool calls happen) | ||
| # Handle tool calls independently of text content. | ||
| tool_calls_data = delta.get(const.TOOL_CALLS) | ||
|
|
||
| # Track delta arguments from this chunk | ||
|
|
@@ -2519,30 +2519,36 @@ async def _generate_stream( | |
| # We can log them but don't need to yield them as content | ||
| continue | ||
|
|
||
| # Verify if the chunk contains valid text content | ||
| if not self._verify_text_content_in_delta_response(chunk_dict): | ||
| # Process chunk without content (this is where tool calls are streamed) | ||
| _, usage, delta_arguments = self._process_chunk_without_content(chunk_dict, accumulated_tool_calls) | ||
|
|
||
| if usage: | ||
| last_usage = usage | ||
|
|
||
| # If streaming tool call arguments is enabled, yield partial tool call events | ||
| # delta_arguments being non-empty means tool calls were processed | ||
| if streaming_tool_names and delta_arguments and accumulated_tool_calls: | ||
| # Yield streaming tool call event with delta arguments | ||
| # Only for tools in streaming_tool_names | ||
| streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls, | ||
| delta_arguments, | ||
| streaming_tool_names) | ||
| if streaming_event: | ||
| yield streaming_event | ||
| # Tool deltas and text can coexist in the same chunk. | ||
| _, usage, delta_arguments = self._process_chunk_without_content(chunk_dict, accumulated_tool_calls) | ||
|
|
||
| if usage: | ||
| last_usage = usage | ||
|
|
||
| # Emit text before tool events from the same chunk for streaming consumers. | ||
| streaming_event = None | ||
| # If streaming tool call arguments is enabled, prepare partial tool call events | ||
| # delta_arguments being non-empty means tool calls were processed | ||
| if streaming_tool_names and delta_arguments and accumulated_tool_calls: | ||
| # Yield streaming tool call event with delta arguments | ||
| # Only for tools in streaming_tool_names | ||
| streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls, | ||
| delta_arguments, | ||
| streaming_tool_names) | ||
|
Comment on lines
+2535
to
+2537
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 问题: 此处调用的 触发条件: 任何启用流式工具且模型产出工具增量的回合; 实际影响: 同一回合的流式工具事件与文本/终态事件无法按 response_id 关联,会话持久化与 a2a 消息聚合把同回合事件当作不同消息处理,影响消息分组、压缩与检索的完整性。 修正方向: 在 |
||
| if streaming_event: | ||
| streaming_event.response_id = response_id | ||
| streaming_event.custom_metadata = {const.CHUNK: chunk_dict, const.TOOL_STREAMING: True} | ||
|
Comment on lines
+2538
to
+2540
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 问题: 流式工具调用事件新增 触发条件: 启用 实际影响: 客户端每收到一个工具参数片段就多收到一条携带原始 provider JSON 的 修正方向: 若该元数据仅用于响应关联/调试,可仅在首片段或末片段附加,或复用与文本事件一致的轻量元数据(如仅 |
||
|
|
||
| if not self._verify_text_content_in_delta_response(chunk_dict): | ||
| if streaming_event: | ||
| yield streaming_event | ||
| continue | ||
|
Comment on lines
+2522
to
2545
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 问题: 本变更将流式工具调用事件( 触发条件: AnthropicProxyApp 配置 OpenAI 兼容模型, 实际影响: 通过该代理接入的客户端(如 Claude Code 风格的 Anthropic 流式消费方)拿到的 SSE 流中助手文本整段缺失,表现为"只见工具调用、不见任何前言文本";而变更前该场景文本可正常送达。工具调用增量 JSON 仍可通过 修正方向: 代理层(_proxy.py)在收到流式工具事件时不要立即关闭文本块,改为在最终响应到达(或流结束)时统一关闭并补发文本增量;或在 |
||
|
|
||
| # Process chunk with valid content | ||
| choices = chunk_dict.get(const.CHOICES, []) | ||
| if not choices: | ||
| if streaming_event: | ||
| yield streaming_event | ||
| continue # Skip if no choices available | ||
| choice: dict[str, dict] = choices[0] | ||
| delta = choice[const.DELTA] | ||
|
|
@@ -2558,6 +2564,8 @@ async def _generate_stream( | |
| partial_text = self._adapter.filter_streaming_text(reasoning_content, | ||
| reasoning_filter_state) | ||
| if not partial_text: | ||
| if streaming_event: | ||
| yield streaming_event | ||
| continue | ||
|
|
||
| # Reasoning content is always thinking content | ||
|
|
@@ -2589,6 +2597,8 @@ async def _generate_stream( | |
| content_filter_state = streaming_text_filter_state["content"] | ||
| partial_text = self._adapter.filter_streaming_text(content, content_filter_state) | ||
| if not partial_text: | ||
| if streaming_event: | ||
| yield streaming_event | ||
| continue | ||
| if not is_thinking: | ||
| visible_content += partial_text | ||
|
|
@@ -2604,11 +2614,8 @@ async def _generate_stream( | |
| response_id=response_id, | ||
| custom_metadata={const.CHUNK: chunk_dict}, | ||
| ) | ||
|
|
||
| # Handle usage | ||
| usage = self._process_usage(chunk_dict) | ||
| if usage: | ||
| last_usage = usage | ||
| if streaming_event: | ||
| yield streaming_event | ||
|
Comment on lines
+2617
to
+2618
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 问题: 本次变更使 OpenAIModel 对"文本+tool_calls 共存"的同一 chunk 拆分为两个事件产出(先文本 partial,再于末尾 yield 流式工具事件),这是下游 AG-UI 翻译器回合割裂的根因所在: 触发条件: 启用流式工具( 实际影响: 同一回合被切分为多个 AG-UI 消息(如"Recording"与"now."分属两个 message_id),按 message_id 聚合、去重或渲染为独立气泡的前端会看到回合被割裂,消息时序与原文顺序错乱。 修正方向: 在事件翻译层对同一回合内工具事件之后的文本延续复用原消息(如记录"工具间文本"状态,不重新 START),或将本行末尾的独立事件 yield 与文本 partial 合并为同一 |
||
|
|
||
| if tool_prompt and streaming_text_filter_state is not None: | ||
| if self._adapter.should_filter_reasoning_text(): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
问题: 新增测试未覆盖本次变更最关键的对外契约——流式工具事件与文本 partial 的相对顺序(及二者在同一 chunk 内的交错)。
test_mixed_stream_preserves_calls_and_text(本文件 79-91 行)分别断言文本聚合与工具 delta 序列,test_one_mixed_fragment...(123-132 行)仅断言工具 delta 序列;text={}参数化用例(46 行第一个参数)使deltas退化为纯工具 chunk,断言为空;混合 chunk + 分片 + 非法 JSON 修复路径组合也未覆盖。触发条件: 任何未来改动如翻转事件产出顺序(先文本后工具、或合并事件)都会通过这些测试但改变消费端可见契约。
实际影响: 本次已确认的代理文本丢失与 AG-UI 消息拆分回归(见前面评论)在测试中不可见,回归防护缺失,测试对消费端协议顺序给出虚假的安全感。
修正方向: 增加对完整事件序列(含顺序)的断言,例如按序收集所有
responses的事件类型列表并校验混合 chunk 的事件顺序为 [streaming-tool, text],并补充混合 + 分片 + 非法 JSON 的修复路径用例。