From 19b837002c1e3e10407cdce9a48b5d5dd06c0f09 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 16:34:21 +0000 Subject: [PATCH] fix(agent): JSON-serialize non-string tool results to prevent API 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-multimodal tool results (especially from MCP tools and memory_search/ memory_recall) return Python dicts/lists. The OpenAI SDK rejects these with HTTP 400 'invalid message content type: map[string]interface{}'. Two layers of defence: 1. run_agent.py:_tool_result_content_for_active_model — serializes any non-str result before appending to messages. 2. agent_runtime_helpers.py:sanitize_api_messages — coerces tool role content to JSON string as a last safety-net before API call. Closes the 'model provider failed after retries' loop caused by tool result dicts slipping into message history. --- agent/agent_runtime_helpers.py | 8 ++++++++ run_agent.py | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index b98fe4b44e77e..16ffa5b36ccc9 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1671,6 +1671,14 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] filtered.append(msg) messages = filtered + # 0. Coerce non-string tool message content to strings so the API never + # receives a dict/list in ``content`` (which OpenAI rejects with 400). + for msg in messages: + if msg.get("role") == "tool": + content = msg.get("content") + if not isinstance(content, str): + msg["content"] = json.dumps(content, ensure_ascii=False, default=str) + surviving_call_ids: set = set() for msg in messages: if msg.get("role") == "assistant": diff --git a/run_agent.py b/run_agent.py index 6c4d54d7581bc..115717d21de61 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3346,6 +3346,13 @@ def _tool_result_content_for_active_model(self, tool_name: str, result: Any) -> the agent has a chance to recover. """ if not _is_multimodal_tool_result(result): + # Non-multimodal results must be string-safe for the API. MCP tools + # (and a few native helpers) can return Python dicts/lists that the + # OpenAI SDK serializes as nested objects, causing HTTP 400 + # "invalid message content type: map[string]interface {}" because + # the API expects `content` to be a string or a content-parts list. + if not isinstance(result, str): + return json.dumps(result, ensure_ascii=False, default=str) return result content = result.get("content") or [] @@ -3658,6 +3665,10 @@ def _needs_kimi_tool_reasoning(self) -> bool: or base_url_host_matches(self.base_url, "api.kimi.com") or base_url_host_matches(self.base_url, "moonshot.ai") or base_url_host_matches(self.base_url, "moonshot.cn") + or ( + "kimi" in (self.model or "").lower() + and self.provider == "ollama-cloud" + ) ) def _needs_deepseek_tool_reasoning(self) -> bool: