diff --git a/packages/kosong/src/kosong/tooling/__init__.py b/packages/kosong/src/kosong/tooling/__init__.py index 54caf5a2e..8ac3c6147 100644 --- a/packages/kosong/src/kosong/tooling/__init__.py +++ b/packages/kosong/src/kosong/tooling/__init__.py @@ -187,6 +187,20 @@ def base(self) -> Tool: async def call(self, arguments: JsonType) -> ToolReturnValue: from kosong.tooling.error import ToolValidateError + # Fix LLM double-serialization: coerce string values that look like + # JSON arrays/objects back to their proper types before validation. + # LLMs sometimes emit: {"todos": "[{\\"title\\": ...}]"} instead of + # {"todos": [{"title": ...}]} + if isinstance(arguments, dict): + for k, v in list(arguments.items()): + if isinstance(v, str) and v.startswith(("[", "{")): + try: + parsed = json.loads(v, strict=False) + if isinstance(parsed, (list, dict)): + arguments[k] = parsed + except (json.JSONDecodeError, ValueError): + pass + try: jsonschema.validate(arguments, self.parameters) except jsonschema.ValidationError as e: diff --git a/src/kimi_cli/soul/toolset.py b/src/kimi_cli/soul/toolset.py index ff2231fba..a84a6fb1d 100644 --- a/src/kimi_cli/soul/toolset.py +++ b/src/kimi_cli/soul/toolset.py @@ -308,6 +308,20 @@ def handle(self, tool_call: ToolCall) -> HandleResult: ) return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e))) + # Fix LLM double-serialization: coerce string values that look like + # JSON arrays/objects back to their proper types before tool validation. + # LLMs sometimes emit: {"todos": "[{\\"title\\": ...}]"} instead of + # {"todos": [{"title": ...}]} + if isinstance(arguments, dict): + for k, v in list(arguments.items()): + if isinstance(v, str) and v.startswith(("[", "{")): + try: + parsed = json.loads(v, strict=False) + if isinstance(parsed, (list, dict)): + arguments[k] = parsed + except (json.JSONDecodeError, ValueError): + pass + canonical_args = _canonical_tool_arguments(arguments) call_key = (tool_name, canonical_args) call_index = len(self._current_step_calls) diff --git a/src/kimi_cli/telemetry/crash.py b/src/kimi_cli/telemetry/crash.py index 9b4eb8b8a..fa38e7a4e 100644 --- a/src/kimi_cli/telemetry/crash.py +++ b/src/kimi_cli/telemetry/crash.py @@ -114,6 +114,19 @@ def _asyncio_handler( exc = context.get("exception") # CancelledError during shutdown/cancellation is normal control flow. if exc is not None and not isinstance(exc, asyncio.CancelledError): + # Suppress MCP connection errors (server disconnect, broken pipe, etc.) + # These are expected when MCP servers restart or connections drop. + exc_msg = str(exc).lower() + _mcp_connection_patterns = ( + "connection closed", + "connection reset", + "connection refused", + "broken pipe", + "eof", + ) + if any(p in exc_msg for p in _mcp_connection_patterns): + return # Silently suppress — not a programming bug + try: from kimi_cli.telemetry import track diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index 4f551de4f..e4f2139be 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -1,10 +1,10 @@ from collections.abc import Callable from pathlib import Path -from typing import override +from typing import Any, override from kaos.path import KaosPath from kosong.tooling import CallableTool2, ToolError, ToolReturnValue -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from kimi_cli.soul.agent import Runtime from kimi_cli.soul.approval import Approval @@ -39,6 +39,32 @@ class Params(BaseModel): ) ) + @field_validator("edit", mode="before") + @classmethod + def _coerce_string_edit(cls, v: Any) -> Any: + """Handle LLM double-serialization: LLMs sometimes emit object/array + values as JSON-encoded strings instead of actual JSON structures. + + Example of the bug: + LLM outputs: {"edit": "{\"old\": \"foo\", \"new\": \"bar\"}"} + Expected: {"edit": {"old": "foo", "new": "bar"}} + + LLM outputs: {"edit": "[{\"old\": \"foo\", \"new\": \"bar\"}]"} + Expected: {"edit": [{"old": "foo", "new": "bar"}]} + + Context7 verified: Pydantic field_validator(mode='before') runs before + the standard validation, allowing type coercion at the boundary. + """ + if isinstance(v, str): + try: + import json + parsed = json.loads(v) + if isinstance(parsed, (dict, list)): + return parsed + except (json.JSONDecodeError, ValueError): + pass + return v + class StrReplaceFile(CallableTool2[Params]): name: str = "StrReplaceFile" diff --git a/src/kimi_cli/tools/todo/__init__.py b/src/kimi_cli/tools/todo/__init__.py index 2d5575cc9..caea58d77 100644 --- a/src/kimi_cli/tools/todo/__init__.py +++ b/src/kimi_cli/tools/todo/__init__.py @@ -3,7 +3,7 @@ from typing import Any, Literal, cast, override from kosong.tooling import CallableTool2, ToolReturnValue -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from kimi_cli.session_state import TodoItemState from kimi_cli.soul.agent import Runtime @@ -26,6 +26,28 @@ class Params(BaseModel): ), ) + @field_validator("todos", mode="before") + @classmethod + def _coerce_string_todos(cls, v: Any) -> Any: + """Handle LLM double-serialization: LLMs sometimes emit array values + as JSON-encoded strings instead of actual JSON arrays. + + Example of the bug: + LLM outputs: {"todos": "[{\"title\": \"Fix bug\", \"status\": \"pending\"}]"} + Expected: {"todos": [{"title": "Fix bug", "status": "pending"}]} + + Context7 verified: Pydantic field_validator(mode='before') runs before + the standard validation, allowing type coercion at the boundary. + """ + if isinstance(v, str): + try: + parsed = json.loads(v) + if isinstance(parsed, list): + return parsed + except (json.JSONDecodeError, ValueError): + pass + return v + class SetTodoList(CallableTool2[Params]): name: str = "SetTodoList"