From f1888a7e36c56997343e50edcef4624cf7485676 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Mon, 29 Jun 2026 18:10:38 +0800 Subject: [PATCH 1/2] fix: ContextComponent.to_messages() returns smolagents-compatible list content Root cause: ContextComponent subclasses returned {"content": "string"} but smolagents v1.23 requires {"content": [{"type": "text", "text": "..."}]} when merging consecutive same-role messages in get_clean_message_list(). This caused 'Error: wrong content: ' assertion failures for all non-modelengine model providers when enable_context_manager was active. Changes: - agent_model.py: All 8 to_messages() methods now return list-format content via new _text_message() helper; estimate_tokens() handles list content - agent_context.py: prepare_run_context uses _extract_message_text() for stable_text assembly; _purpose_messages returns list-format content - Tests updated to match new content format --- sdk/nexent/core/agents/agent_context.py | 22 +++++++-- sdk/nexent/core/agents/agent_model.py | 47 ++++++++++++------- .../utils/test_context_component_types.py | 18 ++++--- .../sdk/core/agents/test_context_component.py | 2 +- .../agents/test_context_manager_assembly.py | 16 +++++-- 5 files changed, 76 insertions(+), 29 deletions(-) diff --git a/sdk/nexent/core/agents/agent_context.py b/sdk/nexent/core/agents/agent_context.py index eef688f67..ce4f57a34 100644 --- a/sdk/nexent/core/agents/agent_context.py +++ b/sdk/nexent/core/agents/agent_context.py @@ -1377,7 +1377,7 @@ def prepare_run_context( ] stable_text = "\n\n".join( - str(message.get("content", "")) for message in stable_messages + self._extract_message_text(message) for message in stable_messages ) memory.system_prompt = SystemPromptStep( system_prompt=stable_text or fallback_system_prompt @@ -1497,8 +1497,8 @@ def _purpose_messages( undefined=StrictUndefined, ).render(task=task or "") return ( - [{"role": "system", "content": pre_messages}], - [{"role": "user", "content": post_messages}], + [{"role": "system", "content": [{"type": "text", "text": pre_messages}]}], + [{"role": "user", "content": [{"type": "text", "text": post_messages}]}], ) @staticmethod @@ -1544,6 +1544,22 @@ def _estimate_tools_tokens(self, tools: Sequence[Any]) -> int: json.dumps(self._normalize_for_fingerprint(tools), ensure_ascii=False, sort_keys=True, default=str) ) + @staticmethod + def _extract_message_text(message: Any) -> str: + """Extract plain text from a message dict or ChatMessage.""" + content = ( + message.get("content", "") + if isinstance(message, dict) + else getattr(message, "content", "") + ) + if isinstance(content, list): + return "".join( + str(part.get("text", "")) + for part in content + if isinstance(part, dict) + ) + return "" if content is None else str(content) + @staticmethod def _message_role(message: Any) -> Optional[str]: if isinstance(message, dict): diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/agent_model.py index 5e5a3adfa..b82442dec 100644 --- a/sdk/nexent/core/agents/agent_model.py +++ b/sdk/nexent/core/agents/agent_model.py @@ -395,7 +395,7 @@ class ContextComponent(BaseModel, ABC): metadata: Dict[str, Any] = Field(description="Additional metadata", default_factory=dict) @abstractmethod - def to_messages(self) -> List[Dict[str, str]]: + def to_messages(self) -> List[Dict[str, Any]]: """Convert component content to message format for LLM. Returns: @@ -403,6 +403,11 @@ def to_messages(self) -> List[Dict[str, str]]: """ pass + @staticmethod + def _text_message(role: str, text: str) -> Dict[str, Any]: + """Build smolagents-compatible text-part message content.""" + return {"role": role, "content": [{"type": "text", "text": text}]} + def estimate_tokens(self, chars_per_token: float = 1.5) -> int: """Estimate token count from content length. @@ -412,7 +417,17 @@ def estimate_tokens(self, chars_per_token: float = 1.5) -> int: Returns: Estimated token count. """ - total_chars = sum(len(m.get("content", "")) for m in self.to_messages()) + total_chars = 0 + for m in self.to_messages(): + content = m.get("content", "") + if isinstance(content, list): + total_chars += sum( + len(part.get("text", "")) + for part in content + if isinstance(part, dict) + ) + else: + total_chars += len(str(content)) return int(total_chars / chars_per_token) @@ -422,8 +437,8 @@ class SystemPromptComponent(ContextComponent): content: str = Field(description="Rendered system prompt content") template_name: Optional[str] = Field(description="Source template name", default=None) - def to_messages(self) -> List[Dict[str, str]]: - return [{"role": "system", "content": self.content}] + def to_messages(self) -> List[Dict[str, Any]]: + return [self._text_message("system", self.content)] class ToolsComponent(ContextComponent): @@ -432,9 +447,9 @@ class ToolsComponent(ContextComponent): tools: List[Dict[str, Any]] = Field(description="List of tool definitions", default_factory=list) formatted_description: str = Field(description="Pre-formatted tool descriptions text", default="") - def to_messages(self) -> List[Dict[str, str]]: + def to_messages(self) -> List[Dict[str, Any]]: if self.formatted_description: - return [{"role": "system", "content": self.formatted_description}] + return [self._text_message("system", self.formatted_description)] return [] def add_tool(self, name: str, description: str, inputs: str, output_type: str) -> None: @@ -453,9 +468,9 @@ class SkillsComponent(ContextComponent): skills: List[Dict[str, Any]] = Field(description="List of skill definitions", default_factory=list) formatted_description: str = Field(description="Pre-formatted skill summaries text", default="") - def to_messages(self) -> List[Dict[str, str]]: + def to_messages(self) -> List[Dict[str, Any]]: if self.formatted_description: - return [{"role": "system", "content": self.formatted_description}] + return [self._text_message("system", self.formatted_description)] return [] def add_skill(self, name: str, description: str) -> None: @@ -473,12 +488,12 @@ class MemoryComponent(ContextComponent): formatted_content: str = Field(description="Pre-formatted memory context text", default="") search_query: Optional[str] = Field(description="Query used to search memory", default=None) - def to_messages(self) -> List[Dict[str, str]]: + def to_messages(self) -> List[Dict[str, Any]]: if self.formatted_content: # Memory is user/session-specific dynamic context. Keeping it out # of the authoritative system prefix preserves cross-turn cache # reuse without changing its content or selection semantics. - return [{"role": "user", "content": self.formatted_content}] + return [self._text_message("user", self.formatted_content)] return [] def add_memory(self, content: str, memory_type: str = "user", metadata: Dict[str, Any] = None) -> None: @@ -496,12 +511,12 @@ class KnowledgeBaseComponent(ContextComponent): summary: str = Field(description="Knowledge base summary text", default="") kb_ids: List[str] = Field(description="Knowledge base IDs used", default_factory=list) - def to_messages(self) -> List[Dict[str, str]]: + def to_messages(self) -> List[Dict[str, Any]]: if self.summary: # Retrieved knowledge is request-dependent evidence, not # authoritative instruction. Keeping it dynamic protects the # stable cache prefix when retrieval results change between turns. - return [{"role": "user", "content": self.summary}] + return [self._text_message("user", self.summary)] return [] @@ -511,9 +526,9 @@ class ManagedAgentsComponent(ContextComponent): agents: List[Dict[str, Any]] = Field(description="Managed agent definitions", default_factory=list) formatted_description: str = Field(description="Pre-formatted agent descriptions", default="") - def to_messages(self) -> List[Dict[str, str]]: + def to_messages(self) -> List[Dict[str, Any]]: if self.formatted_description: - return [{"role": "system", "content": self.formatted_description}] + return [self._text_message("system", self.formatted_description)] return [] def add_agent(self, name: str, description: str, tools: List[str] = None) -> None: @@ -531,9 +546,9 @@ class ExternalAgentsComponent(ContextComponent): agents: List[Dict[str, Any]] = Field(description="External A2A agent definitions", default_factory=list) formatted_description: str = Field(description="Pre-formatted agent descriptions", default="") - def to_messages(self) -> List[Dict[str, str]]: + def to_messages(self) -> List[Dict[str, Any]]: if self.formatted_description: - return [{"role": "system", "content": self.formatted_description}] + return [self._text_message("system", self.formatted_description)] return [] def add_agent(self, agent_id: str, name: str, description: str, url: str) -> None: diff --git a/test/backend/utils/test_context_component_types.py b/test/backend/utils/test_context_component_types.py index d58e72ed4..52dacb8a6 100644 --- a/test/backend/utils/test_context_component_types.py +++ b/test/backend/utils/test_context_component_types.py @@ -464,14 +464,14 @@ def test_skills_component_to_messages(self): formatted_description="test desc", ) messages = comp.to_messages() - assert messages == [{"role": "system", "content": "test desc"}] + assert messages == [{"role": "system", "content": [{"type": "text", "text": "test desc"}]}] def test_knowledge_base_component_to_messages(self): from nexent.core.agents.agent_model import KnowledgeBaseComponent comp = KnowledgeBaseComponent(summary="KB summary") messages = comp.to_messages() - assert messages == [{"role": "user", "content": "KB summary"}] + assert messages == [{"role": "user", "content": [{"type": "text", "text": "KB summary"}]}] def test_knowledge_base_component_empty_summary_no_messages(self): from nexent.core.agents.agent_model import KnowledgeBaseComponent @@ -485,14 +485,14 @@ def test_memory_component_to_messages(self): comp = MemoryComponent(formatted_content="memory text") messages = comp.to_messages() - assert messages == [{"role": "user", "content": "memory text"}] + assert messages == [{"role": "user", "content": [{"type": "text", "text": "memory text"}]}] def test_tools_component_to_messages(self): from nexent.core.agents.agent_model import ToolsComponent comp = ToolsComponent(formatted_description="tools text") messages = comp.to_messages() - assert messages == [{"role": "system", "content": "tools text"}] + assert messages == [{"role": "system", "content": [{"type": "text", "text": "tools text"}]}] class TestFullPromptAssembly: @@ -519,7 +519,10 @@ def test_full_assembly_contains_key_sections(self): all_messages = [] for comp in components: all_messages.extend(comp.to_messages()) - combined = "\n".join(msg["content"] for msg in all_messages) + combined = "\n".join( + msg["content"][0]["text"] if isinstance(msg["content"], list) else msg["content"] + for msg in all_messages + ) assert "\u57fa\u672c\u4fe1\u606f" in combined or "Basic Information" in combined assert "\u6838\u5fc3\u804c\u8d23" in combined or "Core Responsibilities" in combined assert "\u6267\u884c\u6d41\u7a0b" in combined or "Execution Process" in combined @@ -537,7 +540,10 @@ def test_english_language_produces_english_content(self): all_messages = [] for comp in components: all_messages.extend(comp.to_messages()) - combined = "\n".join(msg["content"] for msg in all_messages) + combined = "\n".join( + msg["content"][0]["text"] if isinstance(msg["content"], list) else msg["content"] + for msg in all_messages + ) assert "Basic Information" in combined assert "Core Responsibilities" in combined assert "Execution Process" in combined diff --git a/test/sdk/core/agents/test_context_component.py b/test/sdk/core/agents/test_context_component.py index fca4935fd..617a75d31 100644 --- a/test/sdk/core/agents/test_context_component.py +++ b/test/sdk/core/agents/test_context_component.py @@ -324,7 +324,7 @@ def test_to_messages_returns_system_role(self): messages = comp.to_messages() assert len(messages) == 1 assert messages[0]["role"] == "system" - assert messages[0]["content"] == "Test prompt content" + assert messages[0]["content"] == [{"type": "text", "text": "Test prompt content"}] def test_with_template_name(self): comp = agent_model_module.SystemPromptComponent( diff --git a/test/sdk/core/agents/test_context_manager_assembly.py b/test/sdk/core/agents/test_context_manager_assembly.py index 809bef7a3..73f32ef7a 100644 --- a/test/sdk/core/agents/test_context_manager_assembly.py +++ b/test/sdk/core/agents/test_context_manager_assembly.py @@ -10,6 +10,16 @@ from nexent.core.agents.summary_config import ContextManagerConfig +def _message_text(message): + """Extract text from list-format or string-format message content.""" + content = message["content"] if isinstance(message, dict) else message.content + if isinstance(content, list): + return "".join( + part.get("text", "") for part in content if isinstance(part, dict) + ) + return content + + class _Memory: def __init__(self): self.system_prompt = None @@ -22,7 +32,7 @@ def __init__(self, role, content): self.content = content def to_messages(self): - return [{"role": self.role, "content": self.content}] + return [{"role": self.role, "content": [{"type": "text", "text": self.content}]}] def test_context_manager_assembles_stable_dynamic_and_history_messages(): @@ -41,7 +51,7 @@ def test_context_manager_assembles_stable_dynamic_and_history_messages(): tools=[{"name": "z"}, {"name": "a"}], ) - assert [message["content"] for message in final.messages] == [ + assert [_message_text(message) for message in final.messages] == [ "stable policy", "memory fact", "kb fact", @@ -82,7 +92,7 @@ def test_context_manager_owns_final_answer_assembly(): "user", "assistant", ] - assert [message["content"] for message in final.messages[:4]] == [ + assert [_message_text(message) for message in final.messages[:4]] == [ "stable policy", "final instruction", "memory fact", From 1308d9194a41a7a25992dacdd75eb1149955de91 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Mon, 29 Jun 2026 18:59:27 +0800 Subject: [PATCH 2/2] test: add coverage for estimate_tokens string fallback and _extract_message_text branches - estimate_tokens: test string content fallback path (else branch) - _extract_message_text: test ChatMessage object input, string content, None content, missing key, and mixed list content --- .../unit/test_component_management.py | 34 ++++++++++++++++++- .../sdk/core/agents/test_context_component.py | 10 +++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/test/sdk/core/agents/test_agent_context/unit/test_component_management.py b/test/sdk/core/agents/test_agent_context/unit/test_component_management.py index 8e4304044..fab52b7f2 100644 --- a/test/sdk/core/agents/test_agent_context/unit/test_component_management.py +++ b/test/sdk/core/agents/test_agent_context/unit/test_component_management.py @@ -20,7 +20,7 @@ if _path not in sys.path: sys.path.insert(0, _path) -from loader import ContextManager, ContextManagerConfig +from loader import ContextManager, ContextManagerConfig, ChatMessage, MessageRole from stubs import _SystemPromptStep @@ -320,6 +320,38 @@ def test_chars_per_token_used_in_estimation(self): assert registered[0].token_estimate > 0 +class TestExtractMessageText: + """Tests for ContextManager._extract_message_text static method.""" + + def test_dict_with_list_content(self): + msg = {"role": "system", "content": [{"type": "text", "text": "hello"}]} + assert ContextManager._extract_message_text(msg) == "hello" + + def test_dict_with_string_content(self): + msg = {"role": "system", "content": "plain text"} + assert ContextManager._extract_message_text(msg) == "plain text" + + def test_dict_with_none_content(self): + msg = {"role": "system", "content": None} + assert ContextManager._extract_message_text(msg) == "" + + def test_chatmessage_object_with_list_content(self): + msg = ChatMessage(role=MessageRole.SYSTEM, content=[{"type": "text", "text": "from object"}]) + assert ContextManager._extract_message_text(msg) == "from object" + + def test_chatmessage_object_with_string_content(self): + msg = ChatMessage(role=MessageRole.SYSTEM, content="string from object") + assert ContextManager._extract_message_text(msg) == "string from object" + + def test_dict_missing_content_key(self): + msg = {"role": "system"} + assert ContextManager._extract_message_text(msg) == "" + + def test_list_content_with_non_dict_parts(self): + msg = {"role": "system", "content": [{"type": "text", "text": "a"}, "raw_string"]} + assert ContextManager._extract_message_text(msg) == "a" + + if __name__ == "__main__": import pytest pytest.main([__file__]) \ No newline at end of file diff --git a/test/sdk/core/agents/test_context_component.py b/test/sdk/core/agents/test_context_component.py index 617a75d31..472fbf42c 100644 --- a/test/sdk/core/agents/test_context_component.py +++ b/test/sdk/core/agents/test_context_component.py @@ -23,7 +23,7 @@ import importlib.util from pathlib import Path from types import ModuleType -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -341,6 +341,14 @@ def test_estimate_tokens(self): assert tokens > 0 assert tokens == int(len("This is a test prompt with some words.") / 1.5) + def test_estimate_tokens_with_string_content_fallback(self): + """estimate_tokens handles legacy string content via str() fallback.""" + comp = agent_model_module.SystemPromptComponent(content="hello world") + with patch.object(agent_model_module.SystemPromptComponent, "to_messages", + return_value=[{"role": "system", "content": "hello world"}]): + tokens = comp.estimate_tokens(chars_per_token=1.5) + assert tokens == int(len("hello world") / 1.5) + def test_default_priority(self): comp = agent_model_module.SystemPromptComponent(content="test") assert comp.priority == 10