mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
fix(streaming): request the answer after tools run
The streaming path collected tool calls, executed them, appended the results to chat history -- and then ended. It never asked the model for the final answer, so start(stream=True) yielded nothing while the identical non-streaming call answered normally. There was no error. The caller received an empty generator and could not distinguish 'the model had nothing to say' from 'the answer was never requested'. The follow-up completion carries no tools, so the model answers from the results it already has rather than calling another tool, which also bounds the turn to a single round. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
689978100b
commit
06dfc8e59d
2 files changed
+96
No files matched your search
@@ -5097,6 +5097,39 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Flush deferred media follow-ups after all tool replies.
|
||||
for _m in _deferred_media_followups:
|
||||
self._append_to_chat_history(_m)
|
||||
|
||||
# Ask the model for the answer now that the tools have
|
||||
# run. Without this the generator ends here: the tool
|
||||
# results sit in chat history, nothing is yielded, and
|
||||
# the caller receives an empty stream with no error --
|
||||
# indistinguishable from a model that had nothing to
|
||||
# say. The non-streaming path already does this round
|
||||
# trip, so the two disagreed about whether an answer
|
||||
# existed at all.
|
||||
followup_args = dict(completion_args)
|
||||
followup_args['messages'] = self.chat_history
|
||||
followup_args['stream'] = True
|
||||
# No tools on the follow-up: the model has its results
|
||||
# and should now answer, not call another tool. That
|
||||
# also bounds the turn to a single round of tools.
|
||||
followup_args.pop('tools', None)
|
||||
followup_args.pop('tool_choice', None)
|
||||
followup_args.pop('parallel_tool_calls', None)
|
||||
|
||||
followup_text = ""
|
||||
for followup_chunk in self._openai_client.sync_client.chat.completions.create(
|
||||
**followup_args
|
||||
):
|
||||
if not followup_chunk.choices:
|
||||
continue
|
||||
piece = followup_chunk.choices[0].delta.content
|
||||
if piece:
|
||||
followup_text += piece
|
||||
yield piece
|
||||
if followup_text:
|
||||
self._append_to_chat_history(
|
||||
{"role": "assistant", "content": followup_text}
|
||||
)
|
||||
else:
|
||||
# Add complete response to chat history (text-only response)
|
||||
if response_text:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Streaming must still produce an answer when a tool is invoked.
|
||||
|
||||
The streaming path collects tool calls, executes them, appends the results to
|
||||
chat history -- and then ends. It never asks the model for the final answer, so
|
||||
`start(stream=True)` yields nothing while the identical non-streaming call
|
||||
answers normally.
|
||||
|
||||
There is no error. The caller sees an empty generator and cannot tell "the model
|
||||
had nothing to say" from "the answer was never requested".
|
||||
|
||||
Exercised against the real path rather than a mocked client: the defect lives in
|
||||
the interaction between tool execution and the follow-up completion, which is
|
||||
precisely what a hand-built mock of that client would define away.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="exercises the real streaming path; needs a provider credential",
|
||||
)
|
||||
|
||||
SENTINEL = "PRAISONAI-TOOL-SENTINEL"
|
||||
|
||||
|
||||
def sentinel_tool() -> str:
|
||||
"""Return a fixed marker so a passing assertion cannot be a coincidence."""
|
||||
return SENTINEL
|
||||
|
||||
|
||||
def _agent():
|
||||
return Agent(name="T", role="Assistant", goal="Answer using your tools.",
|
||||
llm="gpt-4o-mini", tools=[sentinel_tool])
|
||||
|
||||
|
||||
def test_a_plain_turn_streams():
|
||||
"""Positive control: without tools the path must be unaffected."""
|
||||
chunks = [c for c in Agent(name="P", role="Assistant", goal="Answer.",
|
||||
llm="gpt-4o-mini").start("Say hi", stream=True) if c]
|
||||
assert chunks, "a plain streamed turn produced nothing; the harness is broken"
|
||||
|
||||
|
||||
def test_a_tool_using_turn_still_streams_an_answer():
|
||||
"""The regression: a tool round-trip must not swallow the reply."""
|
||||
chunks = [c for c in _agent().start(
|
||||
"Call sentinel_tool and tell me exactly what it returned.", stream=True) if c]
|
||||
assert chunks, (
|
||||
"streaming yielded nothing after a tool call; the follow-up completion "
|
||||
"that produces the final answer was never requested"
|
||||
)
|
||||
|
||||
|
||||
def test_streaming_and_non_streaming_agree_when_a_tool_runs():
|
||||
"""The two paths must not disagree about whether an answer exists."""
|
||||
blocking = _agent().start("Call sentinel_tool and report its output.")
|
||||
streamed = "".join(c for c in _agent().start(
|
||||
"Call sentinel_tool and report its output.", stream=True) if c)
|
||||
assert blocking, "non-streaming produced nothing; the harness is broken"
|
||||
assert streamed, "streaming produced nothing while non-streaming answered"
|
||||
Reference in new issue
Block a user