Skip to content

feat: expose structured per-turn content metadata from run_conversation() - #28453

Open
zccyman wants to merge 1 commit into
NousResearch:mainfrom
atyou2happy:feat/conversation-result-metadata-28431
Open

feat: expose structured per-turn content metadata from run_conversation()#28453
zccyman wants to merge 1 commit into
NousResearch:mainfrom
atyou2happy:feat/conversation-result-metadata-28431

Conversation

@zccyman

@zccyman zccyman commented May 19, 2026

Copy link
Copy Markdown
Contributor

Problem

run_conversation() returns a flat dict where final_response is 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_tools but never exposed to downstream consumers. This is the root cause of at least 4 open bugs:

Closes #28431

Solution

Add a ContentSegment dataclass to agent/conversation_loop.py:

@dataclass
class ContentSegment:
    content: str           # Text content (stripped of think blocks)
    had_tool_calls: bool   # Whether this turn also had tool calls
    tool_call_count: int   # Number of tool calls
    tool_names: list[str]  # Names of tools called

The result dict from run_conversation() now includes:

"content_segments": [  # NEW — list of ContentSegment
    ContentSegment(content="Report...", had_tool_calls=True, tool_call_count=1, tool_names=["web_search"]),
    ContentSegment(content="Done!", had_tool_calls=False),
]

How this fixes the 4 bugs

  1. -z/oneshot 模式下 final_response 丢弃带工具调用的消息正文 #28326: chat() can construct final_response from content_segments instead of relying on a single overwritten string.
  2. [Bug]: post_llm_call response overrides are applied after persistence, causing final_response/history mismatch #14894: post_llm_call hooks can inspect content_segments for informed decisions.
  3. [Bug]: _last_content_with_tools fallback bypasses empty-response retries, causing silent agent loop termination mid-task #7968: Empty-response retry logic can check content_segments[-1] instead of _last_content_with_tools.
  4. [Bug]: Text content before tool calls is not delivered on Telegram (only visible in CLI) #6067: Platform adapters can iterate content_segments to deliver each piece of content.

Changes

File Change
agent/conversation_loop.py +47: ContentSegment dataclass, collection in 2 loop branches, result dict field
tests/run_agent/test_run_agent.py +74: 5 new tests in TestContentSegments

Testing

343 passed, 0 failed (including 5 new tests)

Backward Compatibility

Fully backward compatible. All existing dict keys (final_response, messages, etc.) are unchanged. New content_segments key is additive — consumers that don't read it are unaffected.

Follow-up Opportunities

With content_segments available, these internal mechanisms can be simplified in future PRs:

  • Remove _last_content_with_tools hack
  • Simplify empty-response retry logic
  • Add platform-level content delivery from segments

…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
@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels May 19, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 to agent.turn_finalizer.finalize_turn() at agent/conversation_loop.py:4402, and the result dict is built in agent/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 invokes post_llm_call at agent/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_response directly, 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:4311 and by finalizer transforms/footers in agent/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

Copy link
Copy Markdown
Contributor

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.

"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
Contributor

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.

@@ -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
Contributor

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.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: expose structured multi-turn response metadata from run_conversation()

3 participants