mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
fix(agent): restore JSON-encoded string bodies in api argument canonicalization
This commit is contained in:
@@ -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", {})
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user