mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 16:31:56 +08:00
Compare commits
1 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0a3c6083 |
120
run_agent.py
120
run_agent.py
@@ -375,6 +375,58 @@ def _sanitize_messages_surrogates(messages: list) -> bool:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_structured_content(content) -> tuple:
|
||||||
|
"""Normalize Mistral-style structured content blocks to (text, reasoning).
|
||||||
|
|
||||||
|
Mistral's Magistral models (and mistral-large-2512+) return ``content`` as
|
||||||
|
a list of typed blocks instead of a plain string::
|
||||||
|
|
||||||
|
[{"type": "thinking", "thinking": [{"type": "text", "text": "..."}]},
|
||||||
|
{"type": "text", "text": "final answer"},
|
||||||
|
{"type": "reference", ...}]
|
||||||
|
|
||||||
|
This also appears in streaming deltas (``delta.content`` is a list).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(text_content, thinking_content) — text is always a string (possibly
|
||||||
|
empty), thinking is a string or None.
|
||||||
|
"""
|
||||||
|
if content is None:
|
||||||
|
return ("", None)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return (content, None)
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return (str(content), None)
|
||||||
|
|
||||||
|
text_parts: list = []
|
||||||
|
thinking_parts: list = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
text_parts.append(block)
|
||||||
|
continue
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
block_type = block.get("type", "")
|
||||||
|
if block_type == "text":
|
||||||
|
text_parts.append(block.get("text", ""))
|
||||||
|
elif block_type == "thinking":
|
||||||
|
# "thinking" is itself a list of text blocks
|
||||||
|
thinking = block.get("thinking", [])
|
||||||
|
if isinstance(thinking, list):
|
||||||
|
for t in thinking:
|
||||||
|
if isinstance(t, dict) and t.get("type") == "text":
|
||||||
|
thinking_parts.append(t.get("text", ""))
|
||||||
|
elif isinstance(t, str):
|
||||||
|
thinking_parts.append(t)
|
||||||
|
elif isinstance(thinking, str):
|
||||||
|
thinking_parts.append(thinking)
|
||||||
|
# Other types (reference, image, document, audio, file) are skipped.
|
||||||
|
|
||||||
|
text = "\n".join(p for p in text_parts if p)
|
||||||
|
thinking = "\n\n".join(p for p in thinking_parts if p) or None
|
||||||
|
return (text, thinking)
|
||||||
|
|
||||||
|
|
||||||
def _strip_budget_warnings_from_history(messages: list) -> None:
|
def _strip_budget_warnings_from_history(messages: list) -> None:
|
||||||
"""Remove budget pressure warnings from tool-result messages in-place.
|
"""Remove budget pressure warnings from tool-result messages in-place.
|
||||||
|
|
||||||
@@ -4107,12 +4159,25 @@ class AIAgent:
|
|||||||
_fire_first_delta()
|
_fire_first_delta()
|
||||||
self._fire_reasoning_delta(reasoning_text)
|
self._fire_reasoning_delta(reasoning_text)
|
||||||
|
|
||||||
# Accumulate text content — fire callback only when no tool calls
|
# Accumulate text content — fire callback only when no tool calls.
|
||||||
|
# Mistral Magistral models return delta.content as a list of
|
||||||
|
# structured blocks instead of a plain string; normalize first.
|
||||||
if delta and delta.content:
|
if delta and delta.content:
|
||||||
content_parts.append(delta.content)
|
_raw_delta_content = delta.content
|
||||||
|
if isinstance(_raw_delta_content, list):
|
||||||
|
_delta_text, _delta_thinking = _normalize_structured_content(_raw_delta_content)
|
||||||
|
if _delta_thinking:
|
||||||
|
reasoning_parts.append(_delta_thinking)
|
||||||
|
_fire_first_delta()
|
||||||
|
self._fire_reasoning_delta(_delta_thinking)
|
||||||
|
else:
|
||||||
|
_delta_text = _raw_delta_content
|
||||||
|
|
||||||
|
if _delta_text:
|
||||||
|
content_parts.append(_delta_text)
|
||||||
if not tool_calls_acc:
|
if not tool_calls_acc:
|
||||||
_fire_first_delta()
|
_fire_first_delta()
|
||||||
self._fire_stream_delta(delta.content)
|
self._fire_stream_delta(_delta_text)
|
||||||
deltas_were_sent["yes"] = True
|
deltas_were_sent["yes"] = True
|
||||||
else:
|
else:
|
||||||
# Tool calls suppress regular content streaming (avoids
|
# Tool calls suppress regular content streaming (avoids
|
||||||
@@ -4128,7 +4193,7 @@ class AIAgent:
|
|||||||
# box is already closed (tool boundary flush).
|
# box is already closed (tool boundary flush).
|
||||||
if self.stream_delta_callback:
|
if self.stream_delta_callback:
|
||||||
try:
|
try:
|
||||||
self.stream_delta_callback(delta.content)
|
self.stream_delta_callback(_delta_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -5170,14 +5235,28 @@ class AIAgent:
|
|||||||
Handles reasoning extraction, reasoning_details, and optional tool_calls
|
Handles reasoning extraction, reasoning_details, and optional tool_calls
|
||||||
so both the tool-call path and the final-response path share one builder.
|
so both the tool-call path and the final-response path share one builder.
|
||||||
"""
|
"""
|
||||||
|
# Normalize content early — Mistral Magistral models return content
|
||||||
|
# as a list of structured blocks instead of a string.
|
||||||
|
_raw_content = assistant_message.content
|
||||||
|
_structured_thinking = None
|
||||||
|
if isinstance(_raw_content, list):
|
||||||
|
_raw_content, _structured_thinking = _normalize_structured_content(_raw_content)
|
||||||
|
|
||||||
reasoning_text = self._extract_reasoning(assistant_message)
|
reasoning_text = self._extract_reasoning(assistant_message)
|
||||||
_from_structured = bool(reasoning_text)
|
_from_structured = bool(reasoning_text)
|
||||||
|
|
||||||
|
# If the structured content included thinking blocks and
|
||||||
|
# _extract_reasoning didn't find anything, use the structured thinking.
|
||||||
|
if not reasoning_text and _structured_thinking:
|
||||||
|
reasoning_text = _structured_thinking
|
||||||
|
_from_structured = True
|
||||||
|
|
||||||
# Fallback: extract inline <think> blocks from content when no structured
|
# Fallback: extract inline <think> blocks from content when no structured
|
||||||
# reasoning fields are present (some models/providers embed thinking
|
# reasoning fields are present (some models/providers embed thinking
|
||||||
# directly in the content rather than returning separate API fields).
|
# directly in the content rather than returning separate API fields).
|
||||||
if not reasoning_text:
|
if not reasoning_text:
|
||||||
content = assistant_message.content or ""
|
content = _raw_content or ""
|
||||||
|
if isinstance(content, str):
|
||||||
think_blocks = re.findall(r'<think>(.*?)</think>', content, flags=re.DOTALL)
|
think_blocks = re.findall(r'<think>(.*?)</think>', content, flags=re.DOTALL)
|
||||||
if think_blocks:
|
if think_blocks:
|
||||||
combined = "\n\n".join(b.strip() for b in think_blocks if b.strip())
|
combined = "\n\n".join(b.strip() for b in think_blocks if b.strip())
|
||||||
@@ -5203,7 +5282,7 @@ class AIAgent:
|
|||||||
|
|
||||||
msg = {
|
msg = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": assistant_message.content or "",
|
"content": _raw_content or "",
|
||||||
"reasoning": reasoning_text,
|
"reasoning": reasoning_text,
|
||||||
"finish_reason": finish_reason,
|
"finish_reason": finish_reason,
|
||||||
}
|
}
|
||||||
@@ -7022,6 +7101,9 @@ class AIAgent:
|
|||||||
if self.api_mode == "chat_completions":
|
if self.api_mode == "chat_completions":
|
||||||
_trunc_msg = response.choices[0].message if (hasattr(response, "choices") and response.choices) else None
|
_trunc_msg = response.choices[0].message if (hasattr(response, "choices") and response.choices) else None
|
||||||
_trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None
|
_trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None
|
||||||
|
# Mistral Magistral: content may be a list of blocks
|
||||||
|
if isinstance(_trunc_content, list):
|
||||||
|
_trunc_content, _ = _normalize_structured_content(_trunc_content)
|
||||||
elif self.api_mode == "anthropic_messages":
|
elif self.api_mode == "anthropic_messages":
|
||||||
# Anthropic response.content is a list of blocks
|
# Anthropic response.content is a list of blocks
|
||||||
_text_parts = []
|
_text_parts = []
|
||||||
@@ -7076,7 +7158,10 @@ class AIAgent:
|
|||||||
interim_msg = self._build_assistant_message(assistant_message, finish_reason)
|
interim_msg = self._build_assistant_message(assistant_message, finish_reason)
|
||||||
messages.append(interim_msg)
|
messages.append(interim_msg)
|
||||||
if assistant_message.content:
|
if assistant_message.content:
|
||||||
truncated_response_prefix += assistant_message.content
|
_cont = assistant_message.content
|
||||||
|
if isinstance(_cont, list):
|
||||||
|
_cont, _ = _normalize_structured_content(_cont)
|
||||||
|
truncated_response_prefix += _cont
|
||||||
|
|
||||||
if length_continue_retries < 3:
|
if length_continue_retries < 3:
|
||||||
self._vprint(
|
self._vprint(
|
||||||
@@ -7791,21 +7876,22 @@ class AIAgent:
|
|||||||
# Normalize content to string — some OpenAI-compatible servers
|
# Normalize content to string — some OpenAI-compatible servers
|
||||||
# (llama-server, etc.) return content as a dict or list instead
|
# (llama-server, etc.) return content as a dict or list instead
|
||||||
# of a plain string, which crashes downstream .strip() calls.
|
# of a plain string, which crashes downstream .strip() calls.
|
||||||
|
# Mistral Magistral models return a list of structured blocks
|
||||||
|
# including {type: "thinking"} and {type: "text"}.
|
||||||
if assistant_message.content is not None and not isinstance(assistant_message.content, str):
|
if assistant_message.content is not None and not isinstance(assistant_message.content, str):
|
||||||
raw = assistant_message.content
|
raw = assistant_message.content
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
assistant_message.content = raw.get("text", "") or raw.get("content", "") or json.dumps(raw)
|
assistant_message.content = raw.get("text", "") or raw.get("content", "") or json.dumps(raw)
|
||||||
elif isinstance(raw, list):
|
elif isinstance(raw, list):
|
||||||
# Multimodal content list — extract text parts
|
_norm_text, _norm_thinking = _normalize_structured_content(raw)
|
||||||
parts = []
|
assistant_message.content = _norm_text
|
||||||
for part in raw:
|
# Preserve extracted thinking as reasoning_content so
|
||||||
if isinstance(part, str):
|
# _extract_reasoning / _build_assistant_message picks it up.
|
||||||
parts.append(part)
|
if _norm_thinking and not getattr(assistant_message, "reasoning_content", None):
|
||||||
elif isinstance(part, dict) and part.get("type") == "text":
|
try:
|
||||||
parts.append(part.get("text", ""))
|
assistant_message.reasoning_content = _norm_thinking
|
||||||
elif isinstance(part, dict) and "text" in part:
|
except (AttributeError, TypeError):
|
||||||
parts.append(str(part["text"]))
|
pass # frozen/read-only SDK object
|
||||||
assistant_message.content = "\n".join(parts)
|
|
||||||
else:
|
else:
|
||||||
assistant_message.content = str(raw)
|
assistant_message.content = str(raw)
|
||||||
|
|
||||||
|
|||||||
384
tests/test_mistral_structured_content.py
Normal file
384
tests/test_mistral_structured_content.py
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"""Tests for Mistral Magistral structured content handling.
|
||||||
|
|
||||||
|
Mistral's Magistral reasoning models return ``content`` as a list of typed
|
||||||
|
blocks instead of a plain string (both in streaming deltas and non-streaming
|
||||||
|
responses). This test suite verifies that:
|
||||||
|
|
||||||
|
1. _normalize_structured_content() correctly extracts text and thinking parts.
|
||||||
|
2. The streaming path handles list-valued delta.content without crashing.
|
||||||
|
3. The non-streaming path normalizes list content and extracts thinking.
|
||||||
|
4. _build_assistant_message handles list content correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ── Ensure HERMES_HOME is set before importing run_agent ──────────────
|
||||||
|
if not os.environ.get("HERMES_HOME"):
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_tmp = tempfile.mkdtemp(prefix="hermes_test_")
|
||||||
|
os.environ["HERMES_HOME"] = _tmp
|
||||||
|
|
||||||
|
from run_agent import AIAgent, _normalize_structured_content
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_tool_defs(*names):
|
||||||
|
"""Build minimal tool definitions matching get_tool_definitions output."""
|
||||||
|
return [
|
||||||
|
{"type": "function", "function": {"name": n, "description": n, "parameters": {}}}
|
||||||
|
for n in names
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent():
|
||||||
|
"""Minimal AIAgent for testing _build_assistant_message."""
|
||||||
|
with (
|
||||||
|
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||||
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||||
|
patch("run_agent.OpenAI"),
|
||||||
|
):
|
||||||
|
ag = AIAgent(
|
||||||
|
api_key="test-key-1234567890",
|
||||||
|
model="mistral/magistral-medium-latest",
|
||||||
|
quiet_mode=True,
|
||||||
|
skip_context_files=True,
|
||||||
|
skip_memory=True,
|
||||||
|
)
|
||||||
|
ag.client = MagicMock()
|
||||||
|
ag.verbose_logging = False
|
||||||
|
ag.reasoning_callback = None
|
||||||
|
ag.stream_delta_callback = None
|
||||||
|
return ag
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sample data matching Mistral's API format ─────────────────────────
|
||||||
|
|
||||||
|
MAGISTRAL_CONTENT_BLOCKS = [
|
||||||
|
{
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": [
|
||||||
|
{"type": "text", "text": "Let me think about this step by step."},
|
||||||
|
{"type": "text", "text": "The capital of France is Paris."},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"type": "text", "text": "The capital of France is Paris."},
|
||||||
|
]
|
||||||
|
|
||||||
|
MAGISTRAL_TEXT_ONLY_BLOCKS = [
|
||||||
|
{"type": "text", "text": "Hello, how can I help?"},
|
||||||
|
]
|
||||||
|
|
||||||
|
MAGISTRAL_WITH_REFERENCE = [
|
||||||
|
{"type": "thinking", "thinking": [{"type": "text", "text": "Checking references."}]},
|
||||||
|
{"type": "text", "text": "Here is the answer."},
|
||||||
|
{"type": "reference", "url": "https://example.com"},
|
||||||
|
]
|
||||||
|
|
||||||
|
STREAMING_THINKING_DELTA = [
|
||||||
|
{"type": "thinking", "thinking": [{"type": "text", "text": "Okay"}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
STREAMING_TEXT_DELTA = [
|
||||||
|
{"type": "text", "text": "Hello"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests: _normalize_structured_content ──────────────────────────────
|
||||||
|
|
||||||
|
class TestNormalizeStructuredContent:
|
||||||
|
"""Tests for the _normalize_structured_content helper."""
|
||||||
|
|
||||||
|
def test_string_passthrough(self):
|
||||||
|
text, thinking = _normalize_structured_content("Hello world")
|
||||||
|
assert text == "Hello world"
|
||||||
|
assert thinking is None
|
||||||
|
|
||||||
|
def test_none_returns_empty_string(self):
|
||||||
|
text, thinking = _normalize_structured_content(None)
|
||||||
|
assert text == ""
|
||||||
|
assert thinking is None
|
||||||
|
|
||||||
|
def test_non_list_non_string_coerced(self):
|
||||||
|
text, thinking = _normalize_structured_content(42)
|
||||||
|
assert text == "42"
|
||||||
|
assert thinking is None
|
||||||
|
|
||||||
|
def test_magistral_full_response(self):
|
||||||
|
text, thinking = _normalize_structured_content(MAGISTRAL_CONTENT_BLOCKS)
|
||||||
|
assert text == "The capital of France is Paris."
|
||||||
|
assert "step by step" in thinking
|
||||||
|
assert "capital of France is Paris" in thinking
|
||||||
|
|
||||||
|
def test_text_only_blocks(self):
|
||||||
|
text, thinking = _normalize_structured_content(MAGISTRAL_TEXT_ONLY_BLOCKS)
|
||||||
|
assert text == "Hello, how can I help?"
|
||||||
|
assert thinking is None
|
||||||
|
|
||||||
|
def test_with_reference_blocks(self):
|
||||||
|
"""Reference blocks should be skipped, not cause errors."""
|
||||||
|
text, thinking = _normalize_structured_content(MAGISTRAL_WITH_REFERENCE)
|
||||||
|
assert text == "Here is the answer."
|
||||||
|
assert thinking == "Checking references."
|
||||||
|
|
||||||
|
def test_streaming_thinking_delta(self):
|
||||||
|
text, thinking = _normalize_structured_content(STREAMING_THINKING_DELTA)
|
||||||
|
assert text == ""
|
||||||
|
assert thinking == "Okay"
|
||||||
|
|
||||||
|
def test_streaming_text_delta(self):
|
||||||
|
text, thinking = _normalize_structured_content(STREAMING_TEXT_DELTA)
|
||||||
|
assert text == "Hello"
|
||||||
|
assert thinking is None
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
text, thinking = _normalize_structured_content([])
|
||||||
|
assert text == ""
|
||||||
|
assert thinking is None
|
||||||
|
|
||||||
|
def test_mixed_string_and_dict_blocks(self):
|
||||||
|
"""Some providers might mix raw strings with typed blocks."""
|
||||||
|
content = ["raw text", {"type": "text", "text": "typed text"}]
|
||||||
|
text, thinking = _normalize_structured_content(content)
|
||||||
|
assert "raw text" in text
|
||||||
|
assert "typed text" in text
|
||||||
|
|
||||||
|
def test_thinking_as_plain_string(self):
|
||||||
|
"""Handle edge case where thinking value is a string not a list."""
|
||||||
|
content = [{"type": "thinking", "thinking": "I'm thinking..."}]
|
||||||
|
text, thinking = _normalize_structured_content(content)
|
||||||
|
assert text == ""
|
||||||
|
assert thinking == "I'm thinking..."
|
||||||
|
|
||||||
|
def test_multiple_text_blocks_joined(self):
|
||||||
|
content = [
|
||||||
|
{"type": "text", "text": "First paragraph."},
|
||||||
|
{"type": "text", "text": "Second paragraph."},
|
||||||
|
]
|
||||||
|
text, thinking = _normalize_structured_content(content)
|
||||||
|
assert "First paragraph." in text
|
||||||
|
assert "Second paragraph." in text
|
||||||
|
assert "\n" in text # joined with newline
|
||||||
|
|
||||||
|
def test_empty_thinking_block(self):
|
||||||
|
"""Thinking block with no text should result in thinking=None."""
|
||||||
|
content = [
|
||||||
|
{"type": "thinking", "thinking": []},
|
||||||
|
{"type": "text", "text": "Answer"},
|
||||||
|
]
|
||||||
|
text, thinking = _normalize_structured_content(content)
|
||||||
|
assert text == "Answer"
|
||||||
|
assert thinking is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests: _build_assistant_message with structured content ────────────
|
||||||
|
|
||||||
|
class TestBuildAssistantMessageStructuredContent:
|
||||||
|
"""Tests that _build_assistant_message correctly handles Mistral list content."""
|
||||||
|
|
||||||
|
def test_list_content_normalized_to_string(self, agent):
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content=MAGISTRAL_CONTENT_BLOCKS,
|
||||||
|
tool_calls=None,
|
||||||
|
)
|
||||||
|
result = agent._build_assistant_message(msg, "stop")
|
||||||
|
assert isinstance(result["content"], str)
|
||||||
|
assert "The capital of France is Paris." in result["content"]
|
||||||
|
|
||||||
|
def test_list_content_thinking_extracted(self, agent):
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content=MAGISTRAL_CONTENT_BLOCKS,
|
||||||
|
tool_calls=None,
|
||||||
|
)
|
||||||
|
result = agent._build_assistant_message(msg, "stop")
|
||||||
|
assert result["reasoning"] is not None
|
||||||
|
assert "step by step" in result["reasoning"]
|
||||||
|
|
||||||
|
def test_string_content_unchanged(self, agent):
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content="Normal string response",
|
||||||
|
tool_calls=None,
|
||||||
|
)
|
||||||
|
result = agent._build_assistant_message(msg, "stop")
|
||||||
|
assert result["content"] == "Normal string response"
|
||||||
|
|
||||||
|
def test_list_content_with_tool_calls(self, agent):
|
||||||
|
tool_call = SimpleNamespace(
|
||||||
|
id="call_123",
|
||||||
|
type="function",
|
||||||
|
function=SimpleNamespace(name="web_search", arguments='{"query": "test"}'),
|
||||||
|
)
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content=MAGISTRAL_CONTENT_BLOCKS,
|
||||||
|
tool_calls=[tool_call],
|
||||||
|
)
|
||||||
|
result = agent._build_assistant_message(msg, "tool_calls")
|
||||||
|
assert isinstance(result["content"], str)
|
||||||
|
assert "tool_calls" in result
|
||||||
|
|
||||||
|
def test_text_only_blocks_no_reasoning(self, agent):
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content=MAGISTRAL_TEXT_ONLY_BLOCKS,
|
||||||
|
tool_calls=None,
|
||||||
|
)
|
||||||
|
result = agent._build_assistant_message(msg, "stop")
|
||||||
|
assert result["content"] == "Hello, how can I help?"
|
||||||
|
assert result["reasoning"] is None
|
||||||
|
|
||||||
|
def test_structured_thinking_not_duplicated_with_reasoning_content(self, agent):
|
||||||
|
"""When reasoning_content is set AND content has thinking blocks,
|
||||||
|
don't duplicate the reasoning."""
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content=MAGISTRAL_CONTENT_BLOCKS,
|
||||||
|
tool_calls=None,
|
||||||
|
reasoning_content="Already extracted reasoning",
|
||||||
|
)
|
||||||
|
result = agent._build_assistant_message(msg, "stop")
|
||||||
|
# Should use the already-set reasoning_content, not duplicate
|
||||||
|
assert result["reasoning"] == "Already extracted reasoning"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests: Non-streaming content normalization ─────────────────────────
|
||||||
|
|
||||||
|
class TestNonStreamingContentNormalization:
|
||||||
|
"""Tests for the non-streaming content normalization block in the agent loop."""
|
||||||
|
|
||||||
|
def test_list_content_normalized(self, agent):
|
||||||
|
"""Simulate the normalization block that runs after getting the
|
||||||
|
assistant_message from response.choices[0].message."""
|
||||||
|
msg = SimpleNamespace(content=MAGISTRAL_CONTENT_BLOCKS, tool_calls=None)
|
||||||
|
|
||||||
|
# Simulate the normalization block from run_agent.py
|
||||||
|
if msg.content is not None and not isinstance(msg.content, str):
|
||||||
|
raw = msg.content
|
||||||
|
if isinstance(raw, list):
|
||||||
|
text, thinking = _normalize_structured_content(raw)
|
||||||
|
msg.content = text
|
||||||
|
if thinking and not getattr(msg, "reasoning_content", None):
|
||||||
|
msg.reasoning_content = thinking
|
||||||
|
|
||||||
|
assert isinstance(msg.content, str)
|
||||||
|
assert "The capital of France is Paris." in msg.content
|
||||||
|
assert hasattr(msg, "reasoning_content")
|
||||||
|
assert "step by step" in msg.reasoning_content
|
||||||
|
|
||||||
|
def test_dict_content_handled(self, agent):
|
||||||
|
"""Dict content (from llama-server etc.) should still work."""
|
||||||
|
msg = SimpleNamespace(content={"text": "Hello from dict"}, tool_calls=None)
|
||||||
|
|
||||||
|
if msg.content is not None and not isinstance(msg.content, str):
|
||||||
|
raw = msg.content
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
msg.content = raw.get("text", "") or raw.get("content", "") or str(raw)
|
||||||
|
|
||||||
|
assert msg.content == "Hello from dict"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests: Streaming delta normalization ───────────────────────────────
|
||||||
|
|
||||||
|
class TestStreamingDeltaNormalization:
|
||||||
|
"""Tests for the streaming delta content normalization."""
|
||||||
|
|
||||||
|
def test_list_delta_content_split(self):
|
||||||
|
"""When delta.content is a list, text goes to content_parts
|
||||||
|
and thinking goes to reasoning_parts."""
|
||||||
|
content_parts = []
|
||||||
|
reasoning_parts = []
|
||||||
|
|
||||||
|
# Simulate the streaming normalization block
|
||||||
|
delta_content = MAGISTRAL_CONTENT_BLOCKS
|
||||||
|
if isinstance(delta_content, list):
|
||||||
|
text, thinking = _normalize_structured_content(delta_content)
|
||||||
|
if thinking:
|
||||||
|
reasoning_parts.append(thinking)
|
||||||
|
else:
|
||||||
|
text = delta_content
|
||||||
|
|
||||||
|
if text:
|
||||||
|
content_parts.append(text)
|
||||||
|
|
||||||
|
# Verify text and thinking are separated
|
||||||
|
assert len(content_parts) == 1
|
||||||
|
assert "The capital of France is Paris." in content_parts[0]
|
||||||
|
assert len(reasoning_parts) == 1
|
||||||
|
assert "step by step" in reasoning_parts[0]
|
||||||
|
|
||||||
|
# Verify join succeeds (this was the original crash)
|
||||||
|
full_content = "".join(content_parts)
|
||||||
|
assert isinstance(full_content, str)
|
||||||
|
|
||||||
|
def test_string_delta_passthrough(self):
|
||||||
|
"""Normal string deltas should work unchanged."""
|
||||||
|
content_parts = []
|
||||||
|
delta_content = "Hello"
|
||||||
|
|
||||||
|
if isinstance(delta_content, list):
|
||||||
|
text, _ = _normalize_structured_content(delta_content)
|
||||||
|
else:
|
||||||
|
text = delta_content
|
||||||
|
|
||||||
|
if text:
|
||||||
|
content_parts.append(text)
|
||||||
|
|
||||||
|
full_content = "".join(content_parts)
|
||||||
|
assert full_content == "Hello"
|
||||||
|
|
||||||
|
def test_thinking_only_delta(self):
|
||||||
|
"""Streaming delta with only thinking and no text."""
|
||||||
|
content_parts = []
|
||||||
|
reasoning_parts = []
|
||||||
|
|
||||||
|
delta_content = STREAMING_THINKING_DELTA
|
||||||
|
if isinstance(delta_content, list):
|
||||||
|
text, thinking = _normalize_structured_content(delta_content)
|
||||||
|
if thinking:
|
||||||
|
reasoning_parts.append(thinking)
|
||||||
|
else:
|
||||||
|
text = delta_content
|
||||||
|
|
||||||
|
if text:
|
||||||
|
content_parts.append(text)
|
||||||
|
|
||||||
|
# No text content, only reasoning
|
||||||
|
assert len(content_parts) == 0
|
||||||
|
assert len(reasoning_parts) == 1
|
||||||
|
assert reasoning_parts[0] == "Okay"
|
||||||
|
|
||||||
|
# Join should succeed (empty list)
|
||||||
|
full_content = "".join(content_parts) or None
|
||||||
|
assert full_content is None
|
||||||
|
|
||||||
|
def test_multiple_streaming_chunks_joined(self):
|
||||||
|
"""Multiple streaming chunks with mixed list and string content."""
|
||||||
|
content_parts = []
|
||||||
|
reasoning_parts = []
|
||||||
|
|
||||||
|
chunks = [
|
||||||
|
STREAMING_THINKING_DELTA, # list: thinking only
|
||||||
|
STREAMING_TEXT_DELTA, # list: text only
|
||||||
|
"more text", # string
|
||||||
|
]
|
||||||
|
|
||||||
|
for delta_content in chunks:
|
||||||
|
if isinstance(delta_content, list):
|
||||||
|
text, thinking = _normalize_structured_content(delta_content)
|
||||||
|
if thinking:
|
||||||
|
reasoning_parts.append(thinking)
|
||||||
|
else:
|
||||||
|
text = delta_content
|
||||||
|
|
||||||
|
if text:
|
||||||
|
content_parts.append(text)
|
||||||
|
|
||||||
|
full_content = "".join(content_parts)
|
||||||
|
full_reasoning = "".join(reasoning_parts) or None
|
||||||
|
|
||||||
|
assert full_content == "Hellomore text"
|
||||||
|
assert full_reasoning == "Okay"
|
||||||
Reference in New Issue
Block a user