Skip to content
Closed
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
58 changes: 29 additions & 29 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,32 @@ class AIAgent:
This class manages the conversation flow, tool execution, and response handling
for AI models that support function calling.
"""

# Fields allowed per role when sending messages to the model API.
# Internal fields (finish_reason, reasoning, _flush_sentinel, etc.) are
# excluded automatically, preventing 422 errors on strict providers like Mistral.
_API_FIELDS_BY_ROLE = {
"system": {"role", "content"},
"user": {"role", "content", "name"},
"assistant": {"role", "content", "tool_calls", "name", "refusal",
"reasoning_content", "reasoning_details"},
"tool": {"role", "content", "tool_call_id", "name"},
}

def _sanitize_for_api(self, msg: dict) -> dict:
"""Strip internal fields before sending a message to the model API.

Uses a per-role whitelist so any future internal bookkeeping fields
are excluded automatically.
"""
role = msg.get("role", "user")
allowed = self._API_FIELDS_BY_ROLE.get(role, {"role", "content"})
api_msg = {k: v for k, v in msg.items() if k in allowed}
# Copy reasoning -> reasoning_content for providers that support it
# (Moonshot AI, Novita, OpenRouter multi-turn reasoning)
if role == "assistant" and msg.get("reasoning"):
api_msg["reasoning_content"] = msg["reasoning"]
return api_msg

def __init__(
self,
Expand Down Expand Up @@ -1309,15 +1335,7 @@ def flush_memories(self, messages: list = None, min_turns: int = None):

try:
# Build API messages for the flush call
api_messages = []
for msg in messages:
api_msg = msg.copy()
if msg.get("role") == "assistant":
reasoning = msg.get("reasoning")
if reasoning:
api_msg["reasoning_content"] = reasoning
api_msg.pop("reasoning", None)
api_messages.append(api_msg)
api_messages = [self._sanitize_for_api(msg) for msg in messages]

if self._cached_system_prompt:
api_messages = [{"role": "system", "content": self._cached_system_prompt}] + api_messages
Expand Down Expand Up @@ -1641,7 +1659,7 @@ def _handle_max_iterations(self, messages: list, api_call_count: int) -> str:
messages.append({"role": "user", "content": summary_request})

try:
api_messages = messages.copy()
api_messages = [self._sanitize_for_api(msg) for msg in messages]
effective_system = self._cached_system_prompt or ""
if self.ephemeral_system_prompt:
effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip()
Expand Down Expand Up @@ -1814,25 +1832,7 @@ def run_conversation(
# Note: Reasoning is embedded in content via <think> tags for trajectory storage.
# However, providers like Moonshot AI require a separate 'reasoning_content' field
# on assistant messages with tool_calls. We handle both cases here.
api_messages = []
for msg in messages:
api_msg = msg.copy()

# For ALL assistant messages, pass reasoning back to the API
# This ensures multi-turn reasoning context is preserved
if msg.get("role") == "assistant":
reasoning_text = msg.get("reasoning")
if reasoning_text:
# Add reasoning_content for API compatibility (Moonshot AI, Novita, OpenRouter)
api_msg["reasoning_content"] = reasoning_text

# Remove 'reasoning' field - it's for trajectory storage only
# We've copied it to 'reasoning_content' for the API above
if "reasoning" in api_msg:
api_msg.pop("reasoning")
# Keep 'reasoning_details' - OpenRouter uses this for multi-turn reasoning context
# The signature field helps maintain reasoning continuity
api_messages.append(api_msg)
api_messages = [self._sanitize_for_api(msg) for msg in messages]

# Build the final system message: cached prompt + ephemeral system prompt.
# The ephemeral part is appended here (not baked into the cached prompt)
Expand Down
123 changes: 123 additions & 0 deletions tests/agent/test_sanitize_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Tests for AIAgent._sanitize_for_api — whitelist-based message sanitization."""

import pytest
from unittest.mock import patch, MagicMock

from run_agent import AIAgent


@pytest.fixture
def agent():
"""Create a minimal AIAgent with mocked dependencies."""
with patch("run_agent.OpenAI"), \
patch("run_agent.get_tool_definitions", return_value=[]), \
patch("run_agent.check_toolset_requirements", return_value={}):
return AIAgent(
api_key="test-key",
base_url="https://api.mistral.ai/v1",
quiet_mode=True,
)


class TestSanitizeForApi:
"""Tests for the _sanitize_for_api whitelist sanitizer."""

def test_finish_reason_stripped(self, agent):
"""finish_reason must not leak to the API (the actual Mistral 422 bug)."""
msg = {
"role": "assistant",
"content": "Hello!",
"finish_reason": "stop",
}
result = agent._sanitize_for_api(msg)
assert "finish_reason" not in result
assert result["content"] == "Hello!"
assert result["role"] == "assistant"

def test_reasoning_becomes_reasoning_content(self, agent):
"""Internal 'reasoning' field should be converted to 'reasoning_content'."""
msg = {
"role": "assistant",
"content": "Answer.",
"reasoning": "Thinking step by step...",
"finish_reason": "stop",
}
result = agent._sanitize_for_api(msg)
assert "reasoning" not in result
assert "finish_reason" not in result
assert result["reasoning_content"] == "Thinking step by step..."

def test_reasoning_content_not_added_when_empty(self, agent):
"""No reasoning_content if reasoning is None/empty."""
msg = {
"role": "assistant",
"content": "Hello!",
"reasoning": None,
"finish_reason": "stop",
}
result = agent._sanitize_for_api(msg)
assert "reasoning_content" not in result

def test_flush_sentinel_stripped(self, agent):
"""Internal _flush_sentinel on user messages must not leak."""
msg = {
"role": "user",
"content": "Please save memories.",
"_flush_sentinel": "__flush_12345",
}
result = agent._sanitize_for_api(msg)
assert "_flush_sentinel" not in result
assert result["content"] == "Please save memories."

def test_standard_assistant_fields_preserved(self, agent):
"""Standard API fields (content, tool_calls, reasoning_details) pass through."""
tool_calls = [{"id": "tc_1", "type": "function", "function": {"name": "search", "arguments": "{}"}}]
reasoning_details = [{"type": "reasoning.summary", "text": "...", "signature": "abc"}]
msg = {
"role": "assistant",
"content": "Let me search.",
"tool_calls": tool_calls,
"reasoning_details": reasoning_details,
"finish_reason": "tool_calls",
"reasoning": "I should search.",
}
result = agent._sanitize_for_api(msg)
assert result["tool_calls"] == tool_calls
assert result["reasoning_details"] == reasoning_details
assert result["reasoning_content"] == "I should search."
assert "finish_reason" not in result
assert "reasoning" not in result

def test_tool_message_preserves_tool_call_id(self, agent):
"""Tool messages keep tool_call_id and name, drop anything extra."""
msg = {
"role": "tool",
"content": '{"result": "ok"}',
"tool_call_id": "tc_1",
"name": "search",
"some_internal_field": True,
}
result = agent._sanitize_for_api(msg)
assert result["tool_call_id"] == "tc_1"
assert result["name"] == "search"
assert "some_internal_field" not in result

def test_system_message_minimal(self, agent):
"""System messages only keep role and content."""
msg = {
"role": "system",
"content": "You are an assistant.",
"extra": "should be dropped",
}
result = agent._sanitize_for_api(msg)
assert result == {"role": "system", "content": "You are an assistant."}

def test_unknown_role_defaults_to_role_content(self, agent):
"""Unknown roles fall back to keeping just role + content."""
msg = {
"role": "developer",
"content": "Some content.",
"extra": "dropped",
}
result = agent._sanitize_for_api(msg)
assert result == {"role": "developer", "content": "Some content."}