Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions sdk/nexent/core/agents/agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
47 changes: 31 additions & 16 deletions sdk/nexent/core/agents/agent_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,14 +395,19 @@ 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:
List of message dicts with 'role' and 'content' keys.
"""
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.

Expand All @@ -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)


Expand All @@ -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):
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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 []


Expand All @@ -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:
Expand All @@ -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:
Expand Down
18 changes: 12 additions & 6 deletions test/backend/utils/test_context_component_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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__])
12 changes: 10 additions & 2 deletions test/sdk/core/agents/test_context_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
16 changes: 13 additions & 3 deletions test/sdk/core/agents/test_context_manager_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading