Skip to content

Commit 7ce3964

Browse files
committed
bugifx: 修复tool文本和正常文本同时存在的时候文本丢失的边界问题
场景: 当模型不支持tool调用的时候,框架会做tool的适配,主要是通过文本解析的方式,框架会过滤这个文本避免被当做正常的模型推理数据,当tool和正常文本同时存在的时候,这个时候会把正常文本过滤掉 解决: 对正常文本和模型推理的文本做进一步的区分,避免漏了数据
1 parent 9f3f56c commit 7ce3964

2 files changed

Lines changed: 108 additions & 3 deletions

File tree

tests/models/test_openai_model.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,103 @@ async def mock_stream():
625625
# Should have multiple partial responses plus final response
626626
assert len(responses) > 1
627627

628+
async def test_generate_async_streaming_preserves_text_with_native_tool_call(self):
629+
"""Final streaming response keeps regular text alongside native tool calls."""
630+
model = OpenAIModel(model_name="gpt-4", api_key="test_key")
631+
content = Content(parts=[Part.from_text(text="What's the weather?")], role="user")
632+
request = LlmRequest(contents=[content], config=None, tools_dict={})
633+
text_chunk = Mock()
634+
text_chunk.model_dump.return_value = {
635+
"choices": [{
636+
"delta": {
637+
"content": "I'll check the weather first."
638+
},
639+
"finish_reason": None,
640+
"index": 0,
641+
}],
642+
"usage": None,
643+
}
644+
tool_chunk = Mock()
645+
tool_chunk.model_dump.return_value = {
646+
"choices": [{
647+
"delta": {
648+
"tool_calls": [{
649+
"index": 0,
650+
"id": "call_weather",
651+
"type": "function",
652+
"function": {
653+
"name": "get_weather",
654+
"arguments": '{"city": "Beijing"}',
655+
},
656+
}]
657+
},
658+
"finish_reason": "tool_calls",
659+
"index": 0,
660+
}],
661+
"usage": None,
662+
}
663+
async def mock_stream():
664+
yield text_chunk
665+
yield tool_chunk
666+
with patch.object(model, '_create_async_client') as mock_client_factory:
667+
mock_client = AsyncMock()
668+
mock_client.chat.completions.create = AsyncMock(return_value=mock_stream())
669+
mock_client.close = AsyncMock()
670+
mock_client_factory.return_value = mock_client
671+
responses = []
672+
async for response in model.generate_async(request, stream=True):
673+
responses.append(response)
674+
final_response = responses[-1]
675+
assert final_response.partial is False
676+
assert final_response.content is not None
677+
text_parts = [part.text for part in final_response.content.parts if part.text]
678+
function_parts = [part.function_call for part in final_response.content.parts if part.function_call]
679+
assert text_parts == ["I'll check the weather first."]
680+
assert len(function_parts) == 1
681+
assert function_parts[0].name == "get_weather"
682+
assert function_parts[0].args == {"city": "Beijing"}
683+
684+
@pytest.mark.asyncio
685+
async def test_generate_async_streaming_suppresses_tool_prompt_markup_but_keeps_visible_text(self):
686+
"""Provider tool-prompt markup is hidden from final text while visible text is preserved."""
687+
model = OpenAIModel(model_name="hy3-preview", api_key="test_key", add_tools_to_prompt=True)
688+
content = Content(parts=[Part.from_text(text="What's the weather?")], role="user")
689+
request = LlmRequest(contents=[content], config=None, tools_dict={})
690+
tool_prompt_chunk = Mock()
691+
tool_prompt_chunk.model_dump.return_value = {
692+
"choices": [{
693+
"delta": {
694+
"content": ("I'll check the weather first. "
695+
"<tool_call>get_weather<tool_sep>"
696+
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>"
697+
"</tool_call>")
698+
},
699+
"finish_reason": "stop",
700+
"index": 0,
701+
}],
702+
"usage": None,
703+
}
704+
async def mock_stream():
705+
yield tool_prompt_chunk
706+
with patch.object(model, '_create_async_client') as mock_client_factory:
707+
mock_client = AsyncMock()
708+
mock_client.chat.completions.create = AsyncMock(return_value=mock_stream())
709+
mock_client.close = AsyncMock()
710+
mock_client_factory.return_value = mock_client
711+
responses = []
712+
async for response in model.generate_async(request, stream=True):
713+
responses.append(response)
714+
final_response = responses[-1]
715+
assert final_response.partial is False
716+
assert final_response.content is not None
717+
text = "".join(part.text for part in final_response.content.parts if part.text)
718+
function_parts = [part.function_call for part in final_response.content.parts if part.function_call]
719+
assert text == "I'll check the weather first. "
720+
assert "<tool_call>" not in text
721+
assert len(function_parts) == 1
722+
assert function_parts[0].name == "get_weather"
723+
assert function_parts[0].args == {"city": "Beijing"}
724+
628725
@pytest.mark.asyncio
629726
async def test_generate_async_error_handling(self):
630727
"""Test generate_async handles API errors gracefully."""

trpc_agent_sdk/models/_openai_model.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1548,6 +1548,7 @@ async def _generate_stream(self,
15481548
http_options = {}
15491549
thought_content = ""
15501550
accumulated_content = ""
1551+
visible_content = ""
15511552
last_usage = None
15521553
accumulated_tool_calls: list[dict] = []
15531554
is_thinking = False # Track whether we're currently in thinking mode
@@ -1668,6 +1669,8 @@ async def _generate_stream(self,
16681669
partial_text = self._adapter.filter_streaming_text(content, content_filter_state)
16691670
if not partial_text:
16701671
continue
1672+
if not is_thinking:
1673+
visible_content += partial_text
16711674

16721675
# Set thought flag based on current thinking state
16731676
content_part = Part.from_text(text=partial_text)
@@ -1700,6 +1703,8 @@ async def _generate_stream(self,
17001703

17011704
flushed_content_text = self._adapter.flush_streaming_text(streaming_text_filter_state["content"])
17021705
if flushed_content_text:
1706+
if not is_thinking:
1707+
visible_content += flushed_content_text
17031708
content_part = Part.from_text(text=flushed_content_text)
17041709
content_part.thought = is_thinking
17051710
partial_content = Content(parts=[content_part], role=const.MODEL)
@@ -1739,9 +1744,12 @@ async def _generate_stream(self,
17391744
logger.warning("Failed to parse function calls from final accumulated content: %s", ex)
17401745

17411746
# Add text content if present
1742-
if accumulated_content and not complete_tool_calls:
1743-
logger.debug("Final accumulated regular content: %s...", accumulated_content[:200])
1744-
content_part = Part.from_text(text=accumulated_content)
1747+
final_text_content = accumulated_content
1748+
if complete_tool_calls and self._adapter.should_suppress_tool_prompt_text():
1749+
final_text_content = visible_content
1750+
if final_text_content:
1751+
logger.debug("Final accumulated regular content: %s...", final_text_content[:200])
1752+
content_part = Part.from_text(text=final_text_content)
17451753
content_part.thought = False # Final accumulated content represents the answer, not thinking
17461754
parts.append(content_part)
17471755

0 commit comments

Comments
 (0)