Skip to content
Open
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
17 changes: 17 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1719,6 +1719,23 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
"Pre-call sanitizer: added %d stub tool result(s)",
len(missing_results),
)

# 3. Coerce non-string tool results to JSON strings (#29920).
# MCP tools and memory helpers may return Python dicts/lists as content.
# The OpenAI API rejects these with HTTP 400 "invalid message content type".
for msg in messages:
if msg.get("role") == "tool":
content = msg.get("content")
if content is not None and not isinstance(content, str):
try:
msg["content"] = json.dumps(content, ensure_ascii=False)
except (TypeError, ValueError):
_ra().logger.warning(
"Pre-call sanitizer: failed to JSON-serialize tool result for %s",
msg.get("name", "?"),
)
msg["content"] = repr(content)

return messages


Expand Down
26 changes: 26 additions & 0 deletions agent/transports/chat_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,32 @@ def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
if extra_body:
api_kwargs["extra_body"] = extra_body

# System-message guard (#29871): ensure persona/identity content reaches API.
# When a provider's hooks silently strip the role="system" message
# (known with some Ollama Cloud variants), re-inject from original input
# so SOUL.md is never lost mid-flight.
_had_system = (
len(params.get("messages", [])) > 0
and isinstance(params["messages"][0], dict)
and params["messages"][0].get("role") == "system"
)
_has_system = (
len(api_kwargs.get("messages", [])) > 0
and isinstance(api_kwargs["messages"][0], dict)
and api_kwargs["messages"][0].get("role") == "system"
)
if _had_system and not _has_system:
logger.debug(
"System-message guard (%s): profile/hooks stripped system role. "
"Re-injecting from input (input_msgs=%d, output_msgs=%d).",
profile.name,
len(params["messages"]),
len(api_kwargs["messages"]),
)
api_kwargs["messages"] = [
{"role": "system", "content": params["messages"][0]["content"]}
] + api_kwargs["messages"]

return api_kwargs

def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
Expand Down
22 changes: 20 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11216,6 +11216,7 @@ def run_agent():
agent_message = _srn + "\n\n" + agent_message
self._pending_skills_reload_note = None
try:
self._cli_last_run_old_session_id = getattr(self.agent, "session_id", None)
result = self.agent.run_conversation(
user_message=agent_message,
conversation_history=self.conversation_history[:-1], # Exclude the message we just added
Expand Down Expand Up @@ -11359,8 +11360,25 @@ def run_agent():
sys.stdout.flush()
time.sleep(0.15)

# Update history with full conversation
self.conversation_history = result.get("messages", self.conversation_history) if result else self.conversation_history
# Update history with full conversation.
# If auto-compression rotated the session mid-turn, result["messages"]
# is inflated (compressed baseline + this turn's growth). Use the
# agent's internal _session_messages instead — it holds the actual
# post-loop state the agent used for its final API call(s). Mirrors
# the gateway path fix in PR #29505.
if result:
compressed = getattr(self.agent, "_session_messages", None)
session_rotated = (
self.agent
and self._cli_last_run_old_session_id is not None
and getattr(self.agent, "session_id", None) != self._cli_last_run_old_session_id
)
if session_rotated and compressed:
self.conversation_history = list(compressed)
else:
self.conversation_history = result.get("messages", self.conversation_history)
elif self.conversation_history:
pass # Keep existing history on error/null result

# If auto-compression fired mid-turn, the agent created a new
# continuation session and mutated self.agent.session_id. Sync
Expand Down
25 changes: 25 additions & 0 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,12 @@ async def send(
if not self._client:
return SendResult(success=False, error="Not connected")

# ── Suppress NO_REPLY delivery (#29932) ──────────────────────
# Agent returns NO_REPLY as a silence token — not actual content.
if (content or "").strip() == "NO_REPLY":
logger.debug("[%s] Agent returned NO_REPLY — suppressing delivery", self.name)
return SendResult(success=True, message_id=None, raw_response={"suppressed_no_reply": True})

try:
# Determine target channel: thread_id in metadata takes precedence.
thread_id = None
Expand Down Expand Up @@ -3789,6 +3795,10 @@ async def _fetch_channel_context(
if not content:
continue

# Exclude NO_REPLY sentinel from backfill (#29932).
if content.strip() == "NO_REPLY":
continue

name = msg.author.display_name
if getattr(msg.author, "bot", False):
name = f"{name} [bot]"
Expand Down Expand Up @@ -4477,6 +4487,21 @@ async def _handle_message(self, message: DiscordMessage) -> None:
normalized_content = raw_content
mention_prefix = False

# ── NO_REPLY sentinel filter (#29932) ───────────────────────
# Drop bot-to-bot NO_REPLY messages before they enter the agent loop.
# NO_REPLY is a control/silence token — not a user prompt. When
# DISCORD_ALLOW_BOTS=mentions, other bots' NO_REPLY must be ignored.
_NO_REPLY_SENTINEL = "NO_REPLY"
if (
getattr(message.author, "bot", False)
and normalized_content.strip() == _NO_REPLY_SENTINEL
):
logger.debug(
"[%s] Dropping bot NO_REPLY sentinel from %s — not a user prompt.",
self.name, message.author.display_name,
)
return

snapshot_attachments = []
if hasattr(message, "message_snapshots") and message.message_snapshots:
snapshot_text_parts = []
Expand Down
12 changes: 12 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3348,7 +3348,19 @@ def _tool_result_content_for_active_model(self, tool_name: str, result: Any) ->
not receive those image parts, because a rejected tool result becomes
part of the canonical history and can make the next user turn fail before
the agent has a chance to recover.

Non-string results (Python dicts/lists from MCP tools or memory helpers)
are JSON-serialised here to prevent HTTP 400 ``invalid message content type``
errors on the API side (#29920).
"""
# JSON-serialize non-string, non-list results early (#29920).
if not isinstance(result, (str, list)):
try:
return json.dumps(result, ensure_ascii=False)
except (TypeError, ValueError):
logger.warning("Failed to JSON-serialize tool result for %s; using repr.", tool_name)
return repr(result)

if not _is_multimodal_tool_result(result):
return result

Expand Down
Loading