fix(agent): restore JSON-encoded string bodies in api argument canonicalization

This commit is contained in:
q10710
2026-10-02 22:19:07 +08:00
parent eb76fcafd6
commit 37d9680674
3 changed files with 52 additions and 5 deletions

View File

@@ -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", {})

View File

@@ -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):
"""

View File

@@ -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",
}
)