feat: expose structured per-turn content metadata from run_conversation() - #28453
feat: expose structured per-turn content metadata from run_conversation()#28453zccyman wants to merge 1 commit into
Conversation
…on() 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 NousResearch#28326, NousResearch#14894, NousResearch#7968, and NousResearch#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 NousResearch#28431
teknium1
left a comment
There was a problem hiding this comment.
Thanks for taking on the content-before-tool-calls class of bugs. The premise still matters: current main still captures content alongside tool calls in _last_content_with_tools at agent/conversation_loop.py:3839, and there is no content_segments result field on main.
Problems
- This is stale against current main.
run_conversation()now delegates result assembly toagent.turn_finalizer.finalize_turn()atagent/conversation_loop.py:4402, and the result dict is built inagent/turn_finalizer.py:326. The PR adds the new key to the old in-file result assembly. - The PR does not yet fix #14894. Current main persists at
agent/turn_finalizer.py:143, then invokespost_llm_callatagent/turn_finalizer.py:287; this PR does not change that ordering or pass segment metadata to the hook. - The PR does not yet fix #6067. Gateway delivery still reads
final_responsedirectly, e.g.gateway/run.py:8848/gateway/run.py:15721; no platform consumer is updated to send intermediate segments. - Segment content can diverge from returned text: final response is still changed later by truncation handling in
agent/conversation_loop.py:4311and by finalizer transforms/footers inagent/turn_finalizer.py:204-282.
Suggested changes
- Port the metadata through the current
finalize_turn(...)seam and add tests at that seam. - Either narrow the claims to metadata exposure or wire one real consumer, especially the gateway path for #6067.
- Document whether segments are raw model-turn content or post-processed user-visible content.
This is an automated hermes-sweeper review.
| @@ -3419,6 +3454,11 @@ def _stop_spinner(): | |||
| else: | |||
| # No tool calls - this is the final response | |||
There was a problem hiding this comment.
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.
| "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 |
There was a problem hiding this comment.
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.
| @@ -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. | |||
There was a problem hiding this comment.
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.
Problem
run_conversation()returns a flat dict wherefinal_responseis overwritten by the last non-tool-call turn. When the model emits substantive content alongside tool calls, that content is stored internally in_last_content_with_toolsbut never exposed to downstream consumers. This is the root cause of at least 4 open bugs:_last_content_with_toolsfallback bypasses empty-response retriesCloses #28431
Solution
Add a
ContentSegmentdataclass toagent/conversation_loop.py:The result dict from
run_conversation()now includes:How this fixes the 4 bugs
chat()can constructfinal_responsefromcontent_segmentsinstead of relying on a single overwritten string.post_llm_callhooks can inspectcontent_segmentsfor informed decisions.content_segments[-1]instead of_last_content_with_tools.content_segmentsto deliver each piece of content.Changes
agent/conversation_loop.pytests/run_agent/test_run_agent.pyTesting
Backward Compatibility
Fully backward compatible. All existing dict keys (
final_response,messages, etc.) are unchanged. Newcontent_segmentskey is additive — consumers that don't read it are unaffected.Follow-up Opportunities
With
content_segmentsavailable, these internal mechanisms can be simplified in future PRs:_last_content_with_toolshack