From 4cf20f560b470d06cbe896c26be9736416451ebd Mon Sep 17 00:00:00 2001 From: zccyman <16263913+zccyman@users.noreply.github.com> Date: Tue, 19 May 2026 12:21:09 +0800 Subject: [PATCH] feat: expose structured per-turn content metadata from run_conversation() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ContentSegment dataclass to agent/conversation_loop.py that records per-turn assistant content alongside tool-call metadata. The result dict from run_conversation() now includes a "content_segments" field containing a list of ContentSegment instances, one per assistant turn. This enables downstream consumers (gateway platforms, chat(), session persistence) to reconstruct multi-turn content without relying on the single overwritten final_response string — the root cause of #28326, #14894, #7968, and #6067. Changes: - agent/conversation_loop.py: ContentSegment dataclass (4 fields: content, had_tool_calls, tool_call_count, tool_names). Collected in both tool_calls and no-tool-calls branches of the agent loop. - tests/run_agent/test_run_agent.py: 5 new tests in TestContentSegments covering single turn, content+tool→final, multiple tool turns, empty content with tools, and importability. Backward compatible: final_response and all existing dict keys unchanged. Consumers that don't read content_segments are unaffected. Closes #28431 --- agent/conversation_loop.py | 47 +++++++++++++++++++- tests/run_agent/test_run_agent.py | 74 +++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 41eb2d730f123..59bacccc8f3d6 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -25,6 +25,7 @@ import threading import time import uuid +from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from agent.anthropic_adapter import _is_oauth_token @@ -73,6 +74,29 @@ logger = logging.getLogger(__name__) +# ── Structured response metadata ────────────────────────────────────── +# These dataclasses enrich the return value of run_conversation() so +# downstream consumers (gateway platforms, chat(), session persistence) +# can access per-turn content without relying on the overwritten +# ``final_response`` string. See FR #28431. + +@dataclass +class ContentSegment: + """One piece of assistant content produced during the conversation loop. + + Attributes: + content: The text content from this assistant turn (after stripping + think/reasoning blocks). + had_tool_calls: True if this turn also contained tool calls. + tool_call_count: Number of tool calls in this turn (0 if none). + tool_names: Names of the tools called in this turn. + """ + content: str + had_tool_calls: bool = False + tool_call_count: int = 0 + tool_names: List[str] = field(default_factory=list) + + def _ra(): """Lazy reference to ``run_agent`` so callers can patch ``run_agent.handle_function_call`` / ``run_agent._set_interrupt`` / @@ -283,6 +307,9 @@ def run_conversation( agent._last_content_with_tools = None agent._last_content_tools_all_housekeeping = False agent._mute_post_response = False + # Collect structured per-turn content metadata for downstream consumers. + # See FR #28431. + _content_segments: List[ContentSegment] = [] agent._unicode_sanitization_passes = 0 agent._tool_guardrails.reset_for_turn() agent._tool_guardrail_halt_decision = None @@ -3271,6 +3298,14 @@ def _stop_spinner(): # answer and calls memory/skill tools as a side-effect in the same # turn. If the follow-up turn after tools is empty, we use this. turn_content = assistant_message.content or "" + # Collect structured content segment for this turn. + _tc_names = [tc.function.name for tc in assistant_message.tool_calls] + _content_segments.append(ContentSegment( + content=agent._strip_think_blocks(turn_content).strip() if turn_content else "", + had_tool_calls=True, + tool_call_count=len(assistant_message.tool_calls), + tool_names=_tc_names, + )) if turn_content and agent._has_content_after_think_block(turn_content): agent._last_content_with_tools = turn_content # Only mute subsequent output when EVERY tool call in @@ -3419,6 +3454,11 @@ def _stop_spinner(): else: # No tool calls - this is the final response final_response = assistant_message.content or "" + # Collect structured content segment for this final turn. + _content_segments.append(ContentSegment( + content=agent._strip_think_blocks(final_response).strip() if final_response else "", + had_tool_calls=False, + )) # Fix: unmute output when entering the no-tool-call branch # so the user can see empty-response warnings and recovery @@ -4021,6 +4061,11 @@ def _stop_spinner(): "estimated_cost_usd": agent.session_estimated_cost_usd, "cost_status": agent.session_cost_status, "cost_source": agent.session_cost_source, + # Structured per-turn content metadata. See FR #28431. + # Each ContentSegment records the text and tool-call info for one + # assistant turn, allowing consumers to reconstruct multi-turn + # content without relying on the single overwritten ``final_response``. + "content_segments": _content_segments, } if agent._tool_guardrail_halt_decision is not None: result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata() @@ -4096,4 +4141,4 @@ def _stop_spinner(): -__all__ = ["run_conversation"] +__all__ = ["run_conversation", "ContentSegment"] diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 20019a05f85f6..95fcc96e3744a 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -5502,3 +5502,77 @@ def test_on_turn_start_uses_user_turn_count(self): # The extracted body uses ``agent.X`` rather than ``self.X``; # assert the extracted-form spelling directly. assert "on_turn_start(agent._user_turn_count" in src + + +class TestContentSegments: + """FR #28431: run_conversation() returns structured per-turn content metadata.""" + + def test_single_turn_no_tools(self, agent): + """Simple response with no tool calls → one segment.""" + resp = _mock_response(content="Hello world") + agent.client.chat.completions.create.return_value = resp + result = agent.run_conversation("hi") + segs = result["content_segments"] + assert len(segs) == 1 + assert segs[0].content == "Hello world" + assert segs[0].had_tool_calls is False + assert segs[0].tool_call_count == 0 + assert segs[0].tool_names == [] + + def test_content_then_tool_call_then_final(self, agent): + """Content+tool → short follow-up: both turns recorded as segments.""" + tc = _mock_tool_call(name="web_search", arguments='{"query":"test"}') + resp1 = _mock_response(content="Here is the report " + "X" * 50, tool_calls=[tc]) + resp2 = _mock_response(content="Done!") + agent.client.chat.completions.create.side_effect = [resp1, resp2] + agent.handle_function_call = MagicMock(return_value="ok") + result = agent.run_conversation("search") + segs = result["content_segments"] + assert len(segs) == 2 + # First turn: content + tool + assert segs[0].had_tool_calls is True + assert segs[0].tool_call_count == 1 + assert segs[0].tool_names == ["web_search"] + assert "report" in segs[0].content + # Second turn: final response only + assert segs[1].had_tool_calls is False + assert segs[1].content == "Done!" + + def test_multiple_tool_turns(self, agent): + """Multiple tool-calling turns each produce a segment.""" + tc1 = _mock_tool_call(name="web_search", arguments='{"query":"a"}') + tc2 = _mock_tool_call(name="web_search", arguments='{"query":"b"}') + resp1 = _mock_response(content="Searching A...", tool_calls=[tc1]) + resp2 = _mock_response(content="Searching B...", tool_calls=[tc2]) + resp3 = _mock_response(content="All done") + agent.client.chat.completions.create.side_effect = [resp1, resp2, resp3] + agent.handle_function_call = MagicMock(return_value="ok") + result = agent.run_conversation("search twice") + segs = result["content_segments"] + assert len(segs) == 3 + assert segs[0].tool_names == ["web_search"] + assert segs[0].had_tool_calls is True + assert segs[1].tool_names == ["web_search"] + assert segs[1].had_tool_calls is True + assert segs[2].had_tool_calls is False + + def test_empty_content_with_tools(self, agent): + """Tool call with no content: segment has empty content but records tools.""" + tc = _mock_tool_call(name="web_search", arguments='{"query":"test"}') + resp1 = _mock_response(content=None, tool_calls=[tc]) + resp2 = _mock_response(content="Done") + agent.client.chat.completions.create.side_effect = [resp1, resp2] + agent.handle_function_call = MagicMock(return_value="ok") + result = agent.run_conversation("search") + segs = result["content_segments"] + assert len(segs) == 2 + assert segs[0].content == "" + assert segs[0].had_tool_calls is True + assert segs[0].tool_names == ["web_search"] + + def test_content_segments_importable(self): + """ContentSegment can be imported from agent.conversation_loop.""" + from agent.conversation_loop import ContentSegment + seg = ContentSegment(content="test", had_tool_calls=True, tool_call_count=1, tool_names=["foo"]) + assert seg.content == "test" + assert seg.had_tool_calls is True