Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`` /
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these segments are intended to fix gateway delivery, collecting the metadata is only half the change; no gateway/platform consumer in this PR reads content_segments, so text before tool calls will still be dropped by callers that only send final_response.

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
Expand Down Expand Up @@ -3419,6 +3454,11 @@ def _stop_spinner():
else:
# No tool calls - this is the final response

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This records the no-tool segment before later final-response mutations such as truncation-prefix concatenation, think-block stripping, finalizer footers, and transform_llm_output, so content_segments[-1].content can disagree with the returned 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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On current main the result dict is no longer assembled in conversation_loop.py; run_conversation() calls agent.turn_finalizer.finalize_turn(), so this new key needs to be added through that seam or the cherry-pick will miss the actual return path.

# 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()
Expand Down Expand Up @@ -4096,4 +4141,4 @@ def _stop_spinner():



__all__ = ["run_conversation"]
__all__ = ["run_conversation", "ContentSegment"]
74 changes: 74 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading