hermes - ✅(Solved) Fix feat: expose structured multi-turn response metadata from run_conversation() [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#28431Fetched 2026-05-20 04:03:44
View on GitHub
Comments
0
Participants
1
Timeline
5
Reactions
0
Author
Participants
Timeline (top)
labeled ×3cross-referenced ×2

Root Cause

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 a tool call, 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:

Fix Action

Fixed

PR fix notes

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

Description (problem / solution / changelog)

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:

  • #28326: oneshot/chat() loses content when model appends short follow-up after tool calls
  • #14894: post_llm_call response overrides cause persistence/history mismatch
  • #7968: _last_content_with_tools fallback bypasses empty-response retries
  • #6067: Telegram does not show text content before tool calls

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. #28326: chat() can construct final_response from content_segments instead of relying on a single overwritten string.
  2. #14894: post_llm_call hooks can inspect content_segments for informed decisions.
  3. #7968: Empty-response retry logic can check content_segments[-1] instead of _last_content_with_tools.
  4. #6067: Platform adapters can iterate content_segments to deliver each piece of content.

Changes

FileChange
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

Changed files

  • agent/conversation_loop.py (modified, +46/-1)
  • tests/run_agent/test_run_agent.py (modified, +74/-0)

Code Example

@dataclass
class ContentSegment:
    """One piece of assistant content from the conversation loop."""
    content: str
    had_tool_calls: bool
    tool_call_count: int = 0

@dataclass
class ConversationResult:
    """Structured result from run_conversation()."""
    final_response: str          # Backward-compatible: same as today
    content_segments: list[ContentSegment]  # All content-bearing turns
    tool_calls: list[dict]       # Tool call records
    total_turns: int
    model_used: str

---

# Before (current):
return {"final_response": "...", "messages": [...], ...}

# After (proposed):
result = {
    "final_response": "...",           # unchanged — backward compatible
    "messages": [...],                  # unchanged
    "content_segments": [               # NEW
        ContentSegment(content="Report...", had_tool_calls=True, tool_call_count=1),
        ContentSegment(content="Any more?", had_tool_calls=False),
    ],
    "tool_calls": [...],                # NEW
    "total_turns": 2,                   # NEW
    "model_used": "gpt-4o",            # NEW
}
RAW_BUFFERClick to expand / collapse

Problem or Use Case

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 a tool call, 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:

IssueSymptomSame Root Cause
#28326oneshot/chat() loses content when model appends short follow-up after tool callsfinal_response overwritten by last turn
#14894post_llm_call response overrides cause persistence/history mismatchNo structured multi-turn content tracking
#7968_last_content_with_tools fallback bypasses empty-response retriesIncomplete fallback mechanism for intermediate content
#6067Telegram does not show text content before tool callsPlatform layer has no access to intermediate content

The current fix for #28326 uses a 30% length heuristic to merge content — this is fragile and only addresses one manifestation. A structural solution would eliminate the entire bug class.

Proposed Solution

Enrich the return value of run_conversation() with structured response metadata:

@dataclass
class ContentSegment:
    """One piece of assistant content from the conversation loop."""
    content: str
    had_tool_calls: bool
    tool_call_count: int = 0

@dataclass
class ConversationResult:
    """Structured result from run_conversation()."""
    final_response: str          # Backward-compatible: same as today
    content_segments: list[ContentSegment]  # All content-bearing turns
    tool_calls: list[dict]       # Tool call records
    total_turns: int
    model_used: str

Return value change

# Before (current):
return {"final_response": "...", "messages": [...], ...}

# After (proposed):
result = {
    "final_response": "...",           # unchanged — backward compatible
    "messages": [...],                  # unchanged
    "content_segments": [               # NEW
        ContentSegment(content="Report...", had_tool_calls=True, tool_call_count=1),
        ContentSegment(content="Any more?", had_tool_calls=False),
    ],
    "tool_calls": [...],                # NEW
    "total_turns": 2,                   # NEW
    "model_used": "gpt-4o",            # NEW
}

How this fixes the 4 bugs

  1. #28326: chat() can construct final_response from content_segments instead of relying on a single overwritten string. All intermediate content is preserved.

  2. #14894: post_llm_call hooks can inspect content_segments to make informed decisions instead of operating on an already-overwritten final_response.

  3. #7968: Empty-response retry logic can check content_segments[-1] instead of relying on the _last_content_with_tools internal hack.

  4. #6067: Gateway platform adapters (Telegram, Discord, etc.) can iterate content_segments and deliver each piece of content, including text before tool calls.

Implementation notes

  • Backward compatible: final_response key still present with same semantics. New keys are additive.
  • Small scope: Changes confined to agent/conversation_loop.py (populate the new fields) and consumers that opt in.
  • No breaking changes: Existing callers that only read final_response and messages continue working unchanged.
  • _last_content_with_tools can eventually be removed once all consumers migrate.

Environment

  • Affects: all versions that have run_conversation() extraction to agent/conversation_loop.py
  • Components: comp/agent, comp/gateway, comp/cli

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING