diff --git a/app/agent/api/arguments.py b/app/agent/api/arguments.py index b7e9599f7..bef7bc369 100644 --- a/app/agent/api/arguments.py +++ b/app/agent/api/arguments.py @@ -1,5 +1,6 @@ """按已生成的 API 输入合同规范实际请求,使相同写入使用稳定参数身份。""" +import json import re from copy import deepcopy from typing import Any @@ -18,6 +19,24 @@ _MISSING = object() _CONTRACT_DESCRIPTION_MAX_CHARS = 180 +def coerce_string_body(value: Any) -> Any: + """把模型联合类型 schema 下常见的 JSON 字符串请求体还原为原生值。 + + 部分模型在 body 声明为 anyOf(对象/数组/字面量)时会把整个请求体 + 编码成 JSON 字符串;此处仅在解析成功且结果为对象或数组时替换, + 其余输入保持原值,交由 operation 合同继续校验并给出纠错回执。 + """ + if not isinstance(value, str): + return value + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return value + if isinstance(parsed, (dict, list, type(None))): + return parsed + return value + + class ApiArgumentError(ValueError): """只携带字段位置和合同说明,不回显模型提交的参数值。""" @@ -270,6 +289,9 @@ def canonical_api_arguments(arguments: dict[str, Any], schema: dict[str, Any]) - raise ValueError("API 操作没有规范参数合同") _validate_argument_locations(arguments, schema) values = deepcopy(arguments) + # 模型可能把联合类型的 body 编码为 JSON 字符串;先还原再按合同规范。 + if "body" in values: + values["body"] = coerce_string_body(values["body"]) if route.method == "GET" and isinstance(values.get("body"), dict): values["query"] = {**(values.get("query") or {}), **values.pop("body")} properties = branch.get("properties", {}) diff --git a/app/agent/tools/impl/api.py b/app/agent/tools/impl/api.py index df952855f..04e1ba7b2 100644 --- a/app/agent/tools/impl/api.py +++ b/app/agent/tools/impl/api.py @@ -7,9 +7,13 @@ from pathlib import Path from typing import Any, Dict, Literal, Optional, Type, Union, cast from langchain_core.tools import ToolException -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator -from app.agent.api.arguments import api_input_contract, canonical_api_arguments +from app.agent.api.arguments import ( + api_input_contract, + canonical_api_arguments, + coerce_string_body, +) from app.agent.api.executor import ApiExecutionContext, ApiExecutionError, MoviePilotApiExecutor from app.agent.policy.api import resolve_api_operation from app.agent.policy.contracts import ExecutionOutcome, PrincipalRole @@ -76,6 +80,12 @@ class MoviePilotApiInput(BaseModel): # type: ignore[misc] ), ) + @field_validator("body", mode="before") + @classmethod + def _restore_json_string_body(cls, value: Any) -> Any: + """模型在联合类型 schema 下可能把 body 编码为 JSON 字符串,入模前还原。""" + return coerce_string_body(value) + class MoviePilotApiTool(MoviePilotTool): """ diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index 7993dd91a..cea600e17 100644 --- a/tests/test_agent_api_gateway.py +++ b/tests/test_agent_api_gateway.py @@ -198,8 +198,12 @@ def test_mcp_tools_list_preserves_all_moviepilot_api_operation_branches() -> Non assert operation_ids == set(API_OPERATION_ROUTES) -def test_local_agent_tool_rejects_json_encoded_object_bodies() -> None: - """本地 Agent 工具 schema 不得把 JSON 对象字符串化作为合法请求体。""" +def test_local_agent_tool_restores_json_encoded_object_bodies() -> None: + """本地 Agent 工具 schema 不得把 JSON 对象字符串化作为合法请求体。 + + 部分模型在 body 声明为 anyOf 联合类型时会把整个请求体编码成 JSON 字符串; + 规范化阶段将其还原为原生对象,非 JSON 的非法字符串仍按合同报错。 + """ tool = MoviePilotApiTool(session_id="session", user_id="api_user") schema = tool.tool_call_schema assert not isinstance(schema, dict) @@ -223,11 +227,22 @@ def test_local_agent_tool_rejects_json_encoded_object_bodies() -> None: canonical = tool.canonical_arguments({"operation_id": "download.add", "body": body}) assert canonical["body"]["torrent_in"]["enclosure"] == body["torrent_in"]["enclosure"] + # 模型把 body 编码为 JSON 字符串时,规范化阶段还原为原生对象。 + restored = tool.canonical_arguments( + { + "operation_id": "download.add", + "body": json.dumps(body, ensure_ascii=False), + } + ) + assert isinstance(restored["body"], dict) + assert restored["body"]["torrent_in"]["enclosure"] == body["torrent_in"]["enclosure"] + + # 无法还原为对象/数组的非法字符串仍按合同报错。 with pytest.raises(ValueError): tool.canonical_arguments( { "operation_id": "download.add", - "body": json.dumps(body, ensure_ascii=False), + "body": "not-a-json-object", } )