mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
feat(agent): harden tool outcomes, recovery and discovery
This commit is contained in:
@@ -23,6 +23,7 @@ urllib3.disable_warnings(InsecureRequestWarning)
|
||||
|
||||
# 业务模块只依赖网络适配器暴露的异常边界,不直接导入具体 transport。
|
||||
HttpRequestError = requests.exceptions.RequestException
|
||||
AsyncHttpRequestError = httpx2.RequestError
|
||||
|
||||
_default_user_agent: Optional[str] = None
|
||||
|
||||
|
||||
139
app/agent/api/arguments.py
Normal file
139
app/agent/api/arguments.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""按已生成的 API 输入合同规范实际请求,使相同写入使用稳定参数身份。"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.agent.policy.api import resolve_api_route
|
||||
|
||||
_SCALAR_ADAPTERS = {
|
||||
"boolean": TypeAdapter(bool),
|
||||
"integer": TypeAdapter(int),
|
||||
"number": TypeAdapter(float),
|
||||
"string": TypeAdapter(str),
|
||||
}
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _resolve_schema(schema: dict[str, Any], definitions: dict[str, Any]) -> dict[str, Any]:
|
||||
"""只展开缓存合同的本地引用,拒绝循环引用和任何远程 schema。"""
|
||||
seen = set()
|
||||
while "$ref" in schema:
|
||||
reference = schema["$ref"]
|
||||
if not isinstance(reference, str) or not reference.startswith("#/$defs/") or reference in seen:
|
||||
raise ValueError("API 参数合同引用无效")
|
||||
seen.add(reference)
|
||||
name = reference.removeprefix("#/$defs/").replace("~1", "/").replace("~0", "~")
|
||||
target = definitions.get(name)
|
||||
if not isinstance(target, dict):
|
||||
raise ValueError("API 参数合同引用缺失")
|
||||
schema = {**target, **{key: value for key, value in schema.items() if key != "$ref"}}
|
||||
return schema
|
||||
|
||||
|
||||
def _value_type(value: Any) -> str:
|
||||
"""按 JSON 原始类型选择联合分支,布尔值不能误当成整数。"""
|
||||
return {
|
||||
type(None): "null", bool: "boolean", int: "integer", float: "number",
|
||||
str: "string", dict: "object", list: "array",
|
||||
}.get(type(value), "")
|
||||
|
||||
|
||||
def _normalize_union(value: Any, schema: dict[str, Any], definitions: dict[str, Any], depth: int) -> Any:
|
||||
"""优先选择原始类型匹配的联合,歧义时不猜测不同分支的默认值。"""
|
||||
alternatives = [_resolve_schema(item, definitions) for item in schema.get("oneOf", schema.get("anyOf", []))]
|
||||
exact = [item for item in alternatives if item.get("type") == _value_type(value)]
|
||||
candidates = []
|
||||
for option in exact or alternatives:
|
||||
try:
|
||||
candidates.append(_normalize_value(value, option, definitions, depth + 1))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not candidates:
|
||||
raise ValueError("API 参数不符合联合类型合同")
|
||||
if all(candidate == candidates[0] for candidate in candidates):
|
||||
return candidates[0]
|
||||
# 同一输入可合法落入多个不同对象分支时保留原值,避免合并不同业务意图。
|
||||
return deepcopy(value)
|
||||
|
||||
|
||||
def _normalize_object(value: dict[str, Any], schema: dict[str, Any], definitions: dict[str, Any], depth: int) -> dict[str, Any]:
|
||||
"""按模型字段补明确默认值;自由字典保留原内容,forbid 额外字段明确拒绝。"""
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
return deepcopy(value)
|
||||
extra = schema.get("additionalProperties")
|
||||
unknown = set(value) - set(properties)
|
||||
if unknown and extra is False:
|
||||
raise ValueError("API 参数包含合同未声明的字段")
|
||||
normalized = {key: deepcopy(value[key]) for key in unknown} if extra is True or isinstance(extra, dict) else {}
|
||||
required = schema.get("required", [])
|
||||
for key, declaration in properties.items():
|
||||
field_schema = _resolve_schema(declaration, definitions)
|
||||
current = value.get(key, field_schema.get("default", _MISSING))
|
||||
if current is _MISSING:
|
||||
if key in required:
|
||||
raise ValueError("API 参数缺少必需字段")
|
||||
continue
|
||||
normalized[key] = _normalize_value(current, field_schema, definitions, depth + 1)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_value(value: Any, declaration: dict[str, Any], definitions: dict[str, Any], depth: int = 0) -> Any:
|
||||
"""递归规范有限深度的 JSON 值,类型转换与实际请求共用同一份参数。"""
|
||||
if depth > 32:
|
||||
raise ValueError("API 参数嵌套过深")
|
||||
schema = _resolve_schema(declaration, definitions)
|
||||
if "anyOf" in schema or "oneOf" in schema:
|
||||
return _normalize_union(value, schema, definitions, depth)
|
||||
kind = schema.get("type")
|
||||
# JSON 布尔值不是数字;尤其不能把路径中的 true 改成 ID 1 后执行写入。
|
||||
if kind in {"integer", "number"} and isinstance(value, bool):
|
||||
raise ValueError("API 数值参数不能使用布尔值")
|
||||
if kind == "object":
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("API 参数必须是对象")
|
||||
return _normalize_object(value, schema, definitions, depth)
|
||||
if kind == "array":
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("API 参数必须是数组")
|
||||
return [_normalize_value(item, schema.get("items", {}), definitions, depth + 1) for item in value]
|
||||
if kind == "null" and value is not None:
|
||||
raise ValueError("API 参数必须为空值")
|
||||
adapter = _SCALAR_ADAPTERS.get(kind) if isinstance(kind, str) else None
|
||||
if adapter is not None:
|
||||
value = adapter.validate_python(value)
|
||||
if "const" in schema and value != schema["const"]:
|
||||
raise ValueError("API 参数常量不匹配")
|
||||
if "enum" in schema and value not in schema["enum"]:
|
||||
raise ValueError("API 参数枚举值无效")
|
||||
return deepcopy(value)
|
||||
|
||||
|
||||
def canonical_api_arguments(arguments: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""先采用执行器真实 GET 参数投影,再按唯一 operation 合同规范请求。"""
|
||||
operation_id = arguments.get("operation_id")
|
||||
branch = next((item for item in schema.get("oneOf", [])
|
||||
if item.get("properties", {}).get("operation_id", {}).get("const") == operation_id), None)
|
||||
route = resolve_api_route(str(operation_id or ""))
|
||||
if branch is None or route is None:
|
||||
raise ValueError("API 操作没有规范参数合同")
|
||||
values = deepcopy(arguments)
|
||||
if route.method == "GET" and isinstance(values.get("body"), dict):
|
||||
values["query"] = {**(values.get("query") or {}), **values.pop("body")}
|
||||
properties = branch.get("properties", {})
|
||||
# 网关公共 schema 的空容器只代表没有参数,不应被 operation 分支当作额外字段。
|
||||
for field in ("path_params", "query", "body"):
|
||||
if field not in properties and values.get(field) in (None, {}):
|
||||
values.pop(field, None)
|
||||
elif field in {"path_params", "query"} and field in properties:
|
||||
values.setdefault(field, {})
|
||||
try:
|
||||
normalized = _normalize_object(values, branch, schema.get("$defs", {}), 0)
|
||||
except ValidationError as error:
|
||||
raise ValueError("API 参数类型不符合输入合同") from error
|
||||
# ToolNode 会按网关公共 schema 注入这些缺省字段,指纹与 handler 实参保持相同。
|
||||
for key, default in (("path_params", {}), ("query", {}), ("body", None)):
|
||||
normalized.setdefault(key, default)
|
||||
return normalized
|
||||
@@ -7,8 +7,10 @@ from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.adapters.network.http import AsyncRequestUtils
|
||||
from app.agent.policy.api import ApiOperationRoute, resolve_api_route
|
||||
from app.adapters.network.http import AsyncHttpRequestError, AsyncRequestUtils
|
||||
from app.agent.policy.api import ApiOperationRoute, resolve_api_operation, resolve_api_route
|
||||
from app.agent.policy.contracts import ActionEffect
|
||||
from app.agent.policy.sanitizer import stable_type_name
|
||||
from app.application.security.token import create_access_token
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
@@ -17,6 +19,11 @@ from app.runtime.settings import get_runtime_setting
|
||||
class ApiExecutionError(RuntimeError):
|
||||
"""固定 API 路由无法构造或请求失败。"""
|
||||
|
||||
def __init__(self, message: str, *, external_may_continue: bool = False) -> None:
|
||||
"""区分调用前拒绝和请求发出后无法确认的外部副作用。"""
|
||||
super().__init__(message)
|
||||
self.external_may_continue = external_may_continue
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiExecutionContext:
|
||||
@@ -175,6 +182,9 @@ class MoviePilotApiExecutor:
|
||||
verify=False,
|
||||
trust_env=False,
|
||||
)
|
||||
spec = resolve_api_operation(operation_id)
|
||||
may_change_state = spec is None or spec.effect not in {ActionEffect.SAFE_READ, ActionEffect.SENSITIVE_READ}
|
||||
status_code: int | None = None
|
||||
try:
|
||||
response = await request.request(
|
||||
method=route.method,
|
||||
@@ -184,18 +194,23 @@ class MoviePilotApiExecutor:
|
||||
raise_exception=True,
|
||||
)
|
||||
if response is None:
|
||||
raise ApiExecutionError("MoviePilot API 没有返回响应")
|
||||
raise ApiExecutionError("MoviePilot API 没有返回响应", external_may_continue=may_change_state)
|
||||
try:
|
||||
payload = response.json()
|
||||
status_code = response.status_code
|
||||
payload = response.json()
|
||||
response_headers = dict(response.headers)
|
||||
finally:
|
||||
await response.aclose()
|
||||
except ApiExecutionError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.warning(f"Agent API 请求失败: operation={operation_id} error={error}")
|
||||
raise ApiExecutionError(f"MoviePilot API 请求失败: {operation_id}") from error
|
||||
logger.warning(f"Agent API 请求失败: operation={operation_id} error_type={stable_type_name(error)}")
|
||||
transport_failed = isinstance(error, (AsyncHttpRequestError, ConnectionError, TimeoutError))
|
||||
response_unreadable = status_code is not None and status_code < 400
|
||||
raise ApiExecutionError(
|
||||
f"MoviePilot API 请求失败: {operation_id}",
|
||||
external_may_continue=may_change_state and (transport_failed or response_unreadable),
|
||||
) from error
|
||||
if status_code >= 400:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "api_error", "status_code": status_code, "data": payload},
|
||||
|
||||
248
app/agent/middleware/invocation.py
Normal file
248
app/agent/middleware/invocation.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""在副作用之前持久认领工具调用,并对未知写入执行只读核验。"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated, Any, TypedDict
|
||||
|
||||
from langchain.agents.middleware.types import AgentMiddleware, PrivateStateAttr, ToolCallRequest
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import StructuredTool, create_schema_from_function
|
||||
from pydantic import Field
|
||||
|
||||
from app.agent.policy.api import resolve_api_operation
|
||||
from app.agent.policy.contracts import ActionEffect, ExecutionOutcome, ToolPolicyContext
|
||||
from app.agent.policy.sanitizer import sanitize_for_host
|
||||
from app.agent.tools.base import run_agent_blocking
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.agent.tools.impl.mcp import McpExternalTool
|
||||
from app.agent.tools.result import inspect_tool_result
|
||||
from app.application.invocation import (
|
||||
InvocationConflictError,
|
||||
InvocationFinalStatus,
|
||||
InvocationIdentity,
|
||||
InvocationRepository,
|
||||
InvocationSnapshot,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
|
||||
GET_TOOL_EXECUTION_NAME = "get_tool_execution"
|
||||
|
||||
|
||||
class InvocationState(TypedDict):
|
||||
"""在同一轮模型工具循环内提供稳定去重范围,用户新请求使用新范围。"""
|
||||
|
||||
invocation_turn_id: Annotated[str, PrivateStateAttr]
|
||||
|
||||
|
||||
def _canonical_arguments(request: ToolCallRequest) -> dict[str, Any]:
|
||||
"""利用实际工具 schema 规范默认值,保证同一写入的参数指纹稳定。"""
|
||||
arguments = dict(request.tool_call.get("args") or {})
|
||||
if isinstance(request.tool, MoviePilotApiTool):
|
||||
return dict(request.tool.canonical_arguments(arguments))
|
||||
schema = getattr(request.tool, "args_schema", None)
|
||||
if isinstance(schema, type) and hasattr(schema, "model_validate"):
|
||||
return dict(schema.model_validate(arguments).model_dump(mode="json"))
|
||||
return arguments
|
||||
|
||||
|
||||
def _fingerprint(value: Any) -> str:
|
||||
"""只存储规范 JSON 的单向摘要,不把写入参数或凭据写入回执。"""
|
||||
serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# follow_imports=skip 下第三方中间件基类按 Any 处理,仅忽略 SDK 边界。
|
||||
class InvocationMiddleware(AgentMiddleware): # type: ignore[misc]
|
||||
"""维护真实工具写入的持久回执;未认领、未知或已完成调用不会盲目重放。"""
|
||||
|
||||
state_schema = InvocationState
|
||||
|
||||
def __init__(self, context: ToolPolicyContext, repository: InvocationRepository, tools: list[Any]) -> None:
|
||||
"""绑定组合根注入的持久端口及宿主用户身份。"""
|
||||
self.context = context
|
||||
self.repository = repository
|
||||
self._guarded_tools = tuple(tools)
|
||||
self.tools = [StructuredTool.from_function(
|
||||
name=GET_TOOL_EXECUTION_NAME,
|
||||
description=(
|
||||
"Read a durable tool execution receipt by invocation_id returned by a previous tool. "
|
||||
"A receipt contains host status only, never raw credentials or original output. "
|
||||
"Unknown outcomes require read-only reconciliation; querying does not retry an action."
|
||||
),
|
||||
coroutine=self._get_execution,
|
||||
args_schema=create_schema_from_function("ToolExecutionInput", self._get_execution),
|
||||
tags=["agent_tool", "read"],
|
||||
)]
|
||||
object.__setattr__(self.tools[0], "_agent_tool_source", "middleware:invocation")
|
||||
|
||||
async def abefore_agent(self, state: InvocationState, runtime: Any) -> dict[str, Any]:
|
||||
"""新用户请求创建独立意图范围,同一轮中的重复 API 参数合并执行。"""
|
||||
return {"invocation_turn_id": uuid.uuid4().hex}
|
||||
|
||||
def _identity(self, invocation_id: str) -> InvocationIdentity:
|
||||
"""身份只来自宿主上下文,工具参数不能切换用户或会话。"""
|
||||
return InvocationIdentity(str(self.context.user_id or ""), self.context.session_id, invocation_id)
|
||||
|
||||
def _is_write(self, request: ToolCallRequest) -> bool:
|
||||
"""API 按操作副作用分类,其余工具沿用明确只读标签与内部能力边界。"""
|
||||
if request.tool is None or not any(request.tool is tool for tool in self._guarded_tools):
|
||||
return False
|
||||
if isinstance(request.tool, MoviePilotApiTool):
|
||||
operation = resolve_api_operation(str(request.tool_call.get("args", {}).get("operation_id", "")))
|
||||
return operation is not None and operation.effect not in {ActionEffect.SAFE_READ, ActionEffect.SENSITIVE_READ}
|
||||
if isinstance(request.tool, McpExternalTool) or str(getattr(request.tool, "_agent_tool_source", "")).startswith("mcp:"):
|
||||
return True
|
||||
if str(getattr(request.tool, "_agent_tool_source", "")).startswith("middleware:"):
|
||||
return False
|
||||
tags = set(getattr(request.tool, "tags", None) or [])
|
||||
return "write" in tags or "read" not in tags
|
||||
|
||||
@staticmethod
|
||||
def _message(request: ToolCallRequest, outcome: str, message: str, **extra: Any) -> ToolMessage:
|
||||
"""给模型明确结果状态,同时保留 LangChain 二态兼容字段。"""
|
||||
return ToolMessage(
|
||||
content=json.dumps({"execution_outcome": outcome, "message": message, **extra}, ensure_ascii=False),
|
||||
tool_call_id=str(request.tool_call.get("id") or ""),
|
||||
name=str(request.tool_call.get("name") or "unknown"),
|
||||
status="success" if outcome in {"succeeded", "pending"} else "error",
|
||||
additional_kwargs={"moviepilot_execution_outcome": outcome},
|
||||
)
|
||||
|
||||
async def _get_execution(
|
||||
self, invocation_id: Annotated[str, Field(min_length=1, max_length=128)],
|
||||
) -> str:
|
||||
"""只查询当前用户和会话的固定回执,不开放任意状态覆盖入口。"""
|
||||
record = await run_agent_blocking("db", self.repository.get, self._identity(invocation_id))
|
||||
if record is None:
|
||||
return json.dumps({"success": False, "error": "execution_not_found"})
|
||||
return json.dumps({
|
||||
"success": True, "invocation_id": record.identity.invocation_id,
|
||||
"tool_name": record.tool_name, "execution_status": record.status,
|
||||
"summary": record.summary, "updated_at": record.updated_at,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
async def _finish(self, record: InvocationSnapshot, status: InvocationFinalStatus) -> bool:
|
||||
"""短事务收口失败仍保留原认领,后续调用不能因记录失败重放副作用。"""
|
||||
try:
|
||||
return bool(await run_agent_blocking(
|
||||
"db", self.repository.finish, record.identity,
|
||||
claim_token=record.claim_token, status=status,
|
||||
))
|
||||
except Exception as error:
|
||||
logger.warning(f"保存工具执行回执失败: {type(error).__name__}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def _verify_setting(request: ToolCallRequest) -> bool:
|
||||
"""只对非敏感设置的完整替换核验当前值;不以猜测收口其他写操作。"""
|
||||
arguments = request.tool_call.get("args") or {}
|
||||
body = arguments.get("body")
|
||||
if (
|
||||
not isinstance(request.tool, MoviePilotApiTool)
|
||||
or arguments.get("operation_id") != "config.system.update"
|
||||
or not isinstance(body, dict) or body.get("operation", "replace") != "replace"
|
||||
or not body.get("setting_key") or "value" not in body
|
||||
or sanitize_for_host(body) != body
|
||||
):
|
||||
return False
|
||||
try:
|
||||
result = await request.tool.ainvoke({
|
||||
"operation_id": "config.system.get",
|
||||
"query": {"setting_key": body["setting_key"], "include_values": True, "show_secrets": False},
|
||||
})
|
||||
payload = json.loads(result)
|
||||
items = payload.get("data", {}).get("settings", [])
|
||||
if payload.get("success") is not True or len(items) != 1:
|
||||
return False
|
||||
item = items[0]
|
||||
return bool(
|
||||
not item.get("redacted") and "value" in item
|
||||
and item.get("setting_key") == body["setting_key"]
|
||||
and _fingerprint(item["value"]) == _fingerprint(body["value"])
|
||||
)
|
||||
except Exception as error:
|
||||
logger.info(f"工具写入只读核验未完成: {type(error).__name__}")
|
||||
return False
|
||||
|
||||
async def _existing(self, request: ToolCallRequest, record: InvocationSnapshot) -> ToolMessage:
|
||||
"""已完成直接返回回执,未知结果仅在宿主只读核验成功后收口。"""
|
||||
if record.status == "unknown" and await self._verify_setting(request):
|
||||
if await self._finish(record, "succeeded"):
|
||||
return self._message(request, "succeeded", "已只读核验目标设置生效,没有重复写入。",
|
||||
invocation_id=record.identity.invocation_id, reconciled=True)
|
||||
outcome = "pending" if record.status == "running" else record.status
|
||||
return self._message(
|
||||
request, outcome,
|
||||
f"已有执行记录:{record.summary}。本次未重复执行;结果未知时请先核验实际状态。",
|
||||
invocation_id=record.identity.invocation_id, replayed=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _attach_receipt(result: ToolMessage, record: InvocationSnapshot, outcome: ExecutionOutcome) -> ToolMessage:
|
||||
"""保留业务字段并公开宿主回执编号,让模型能够调用状态查询工具。"""
|
||||
try:
|
||||
payload = json.loads(result.content) if isinstance(result.content, str) else None
|
||||
except (TypeError, ValueError):
|
||||
payload = None
|
||||
payload = dict(payload) if isinstance(payload, dict) else {"result": result.content}
|
||||
payload["_tool_execution"] = {"invocation_id": record.identity.invocation_id, "outcome": outcome.value}
|
||||
return result.model_copy(update={
|
||||
"content": json.dumps(payload, ensure_ascii=False),
|
||||
"additional_kwargs": {
|
||||
**result.additional_kwargs, "moviepilot_invocation_id": record.identity.invocation_id,
|
||||
"moviepilot_execution_outcome": outcome.value,
|
||||
},
|
||||
})
|
||||
|
||||
async def awrap_tool_call(
|
||||
self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], Awaitable[Any]],
|
||||
) -> Any:
|
||||
"""在外部副作用前提交认领,跨重试保留真实已知与未知状态。"""
|
||||
if not self._is_write(request):
|
||||
return await handler(request)
|
||||
try:
|
||||
canonical_arguments = _canonical_arguments(request)
|
||||
arguments_digest = _fingerprint(canonical_arguments)
|
||||
if isinstance(request.tool, MoviePilotApiTool):
|
||||
request = request.override(tool_call={**request.tool_call, "args": canonical_arguments})
|
||||
previous = await run_agent_blocking(
|
||||
"db", self.repository.find_unresolved, str(self.context.user_id or ""), self.context.session_id,
|
||||
tool_name=request.tool.name, arguments_digest=arguments_digest,
|
||||
)
|
||||
if previous is not None:
|
||||
return await self._existing(request, previous)
|
||||
invocation_id = _fingerprint([request.state.get("invocation_turn_id"), request.tool.name, arguments_digest])
|
||||
else:
|
||||
invocation_id = str(request.tool_call.get("id") or uuid.uuid4().hex)
|
||||
claim = await run_agent_blocking(
|
||||
"db", self.repository.claim, self._identity(invocation_id),
|
||||
tool_name=request.tool.name, arguments_digest=arguments_digest,
|
||||
)
|
||||
except InvocationConflictError:
|
||||
return self._message(request, "failed", "同一调用 ID 的工具或参数发生变化,未执行。")
|
||||
except Exception as error:
|
||||
logger.warning(f"工具执行认领失败: {type(error).__name__}")
|
||||
return self._message(request, "failed", "无法持久记录本次写入,未执行工具。")
|
||||
if not claim.acquired:
|
||||
return await self._existing(request, claim.record)
|
||||
try:
|
||||
result = await handler(request)
|
||||
except (asyncio.CancelledError, TimeoutError):
|
||||
await self._finish(claim.record, "unknown")
|
||||
raise
|
||||
except Exception:
|
||||
await self._finish(claim.record, "unknown")
|
||||
return self._message(request, "unknown", "工具执行异常,写入结果未知,请先核验实际状态。",
|
||||
invocation_id=claim.record.identity.invocation_id)
|
||||
outcome = inspect_tool_result(result)
|
||||
status: InvocationFinalStatus = outcome.value
|
||||
settled = await self._finish(claim.record, status)
|
||||
if not settled:
|
||||
return self._message(request, "unknown", "工具已经返回,但持久回执未确认;请先核验,勿直接重试。",
|
||||
invocation_id=claim.record.identity.invocation_id)
|
||||
if isinstance(result, ToolMessage):
|
||||
return self._attach_receipt(result, claim.record, outcome)
|
||||
return result
|
||||
148
app/agent/middleware/output.py
Normal file
148
app/agent/middleware/output.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""为当前会话保留有界工具结果,并按字符位置继续读取。"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
|
||||
from langchain.agents.middleware.types import AgentMiddleware, ToolCallRequest
|
||||
from langchain.tools import ToolRuntime
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import StructuredTool, create_schema_from_function
|
||||
from pydantic import Field
|
||||
|
||||
from app.agent.policy.contracts import ToolPolicyContext
|
||||
from app.agent.tools.base import TOOL_RESULT_RECORDER, format_tool_result_for_agent
|
||||
from app.agent.tools.result import inspect_tool_result
|
||||
|
||||
READ_TOOL_RESULT_NAME = "read_tool_result"
|
||||
MAX_RESULT_BYTES = 1024 * 1024
|
||||
MAX_TOTAL_BYTES = 4 * MAX_RESULT_BYTES
|
||||
MAX_RESULTS = 8
|
||||
RESULT_TTL_SECONDS = 15 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StoredResult:
|
||||
"""内存中短期保留原始授权结果,不复制到日志、记忆或磁盘。"""
|
||||
|
||||
thread_id: str
|
||||
text: str
|
||||
byte_size: int
|
||||
tool_name: str
|
||||
expires_at: float
|
||||
requires_admin: bool
|
||||
|
||||
|
||||
# follow_imports=skip 不分析第三方基类,仅在框架继承边界忽略 misc。
|
||||
class ToolOutputMiddleware(AgentMiddleware): # type: ignore[misc]
|
||||
"""包装当前图的工具结果;读取按会话与当前管理员身份双重隔离。"""
|
||||
|
||||
def __init__(self, context: ToolPolicyContext) -> None:
|
||||
"""注册内部续读工具,缓存容量和寿命均受固定上限约束。"""
|
||||
self.context = context
|
||||
self._results: OrderedDict[str, _StoredResult] = OrderedDict()
|
||||
self._lock = threading.RLock()
|
||||
self.tools = [StructuredTool.from_function(
|
||||
name=READ_TOOL_RESULT_NAME,
|
||||
description=(
|
||||
"Read the next page of an archived tool result using its result_id and next_offset. "
|
||||
"Offsets count Unicode characters. Results expire after 15 minutes, on eviction, "
|
||||
"or when this conversation graph is rebuilt. Never guess result IDs."
|
||||
),
|
||||
coroutine=self._read_result,
|
||||
args_schema=create_schema_from_function(
|
||||
"ReadToolResultInput", self._read_result, filter_args=["runtime"],
|
||||
),
|
||||
tags=["agent_tool", "read"],
|
||||
)]
|
||||
object.__setattr__(self.tools[0], "_agent_tool_source", "middleware:output")
|
||||
|
||||
@staticmethod
|
||||
def _thread_id(runtime: ToolRuntime) -> str:
|
||||
"""使用图的真实线程身份,避免同一中间件被不同图线程误复用。"""
|
||||
return str(runtime.config.get("configurable", {}).get("thread_id") or "")
|
||||
|
||||
def _prune(self) -> None:
|
||||
"""调用方持锁时删除到期结果,并按写入顺序执行 UTF-8 字节容量淘汰。"""
|
||||
now = time.monotonic()
|
||||
for result_id, result in list(self._results.items()):
|
||||
if result.expires_at <= now:
|
||||
del self._results[result_id]
|
||||
size = sum(result.byte_size for result in self._results.values())
|
||||
while self._results and (len(self._results) > MAX_RESULTS or size > MAX_TOTAL_BYTES):
|
||||
_, result = self._results.popitem(last=False)
|
||||
size -= result.byte_size
|
||||
|
||||
def _store(self, thread_id: str, tool_name: str, text: str, requires_admin: bool) -> dict[str, Any]:
|
||||
"""只保存完整的有界结果,超过容量时明确说明无法续读。"""
|
||||
if not thread_id:
|
||||
return {"result_unavailable": "thread_unavailable"}
|
||||
# 先用字符数下界拒绝超大文本,避免为明显无法归档的输出再分配编码副本。
|
||||
if len(text) > MAX_RESULT_BYTES or (byte_size := len(text.encode("utf-8"))) > MAX_RESULT_BYTES:
|
||||
return {"result_unavailable": "result_too_large", "result_limit_bytes": MAX_RESULT_BYTES}
|
||||
result_id = uuid.uuid4().hex
|
||||
with self._lock:
|
||||
self._results[result_id] = _StoredResult(
|
||||
thread_id=thread_id, text=text, byte_size=byte_size, tool_name=tool_name,
|
||||
expires_at=time.monotonic() + RESULT_TTL_SECONDS,
|
||||
requires_admin=requires_admin,
|
||||
)
|
||||
self._prune()
|
||||
return {
|
||||
"result_id": result_id,
|
||||
"read_tool": READ_TOOL_RESULT_NAME,
|
||||
"expires_in_seconds": RESULT_TTL_SECONDS,
|
||||
"offset_unit": "unicode_characters",
|
||||
}
|
||||
|
||||
async def _read_result(
|
||||
self,
|
||||
result_id: Annotated[str, Field(min_length=32, max_length=32)],
|
||||
runtime: ToolRuntime,
|
||||
offset: Annotated[int, Field(ge=0)] = 0,
|
||||
limit: Annotated[int, Field(ge=1, le=16000)] = 4000,
|
||||
) -> str:
|
||||
"""按显式游标续读;过期、越权和未知 ID 使用同一不可用响应。"""
|
||||
with self._lock:
|
||||
self._prune()
|
||||
result = self._results.get(result_id)
|
||||
if (
|
||||
not self._thread_id(runtime) or result is None or result.thread_id != self._thread_id(runtime)
|
||||
or (result.requires_admin and not self.context.agent_context.get("is_admin"))
|
||||
):
|
||||
return json.dumps({"success": False, "error": "result_unavailable"})
|
||||
if offset > len(result.text):
|
||||
return json.dumps({"success": False, "error": "offset_out_of_range", "total_chars": len(result.text)})
|
||||
end = min(offset + limit, len(result.text))
|
||||
return json.dumps({
|
||||
"success": True, "result_id": result_id, "tool_name": result.tool_name,
|
||||
"offset": offset, "next_offset": end if end < len(result.text) else None,
|
||||
"total_chars": len(result.text), "content": result.text[offset:end],
|
||||
}, ensure_ascii=False)
|
||||
|
||||
async def awrap_tool_call(
|
||||
self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], Awaitable[Any]],
|
||||
) -> Any:
|
||||
"""让内置工具在截断前归档,并覆盖返回大文本的外部 MCP 工具。"""
|
||||
if request.tool_call.get("name") == READ_TOOL_RESULT_NAME:
|
||||
return await handler(request)
|
||||
thread_id = self._thread_id(request.runtime)
|
||||
requires_admin = bool(self.context.agent_context.get("is_admin"))
|
||||
token = TOOL_RESULT_RECORDER.set(lambda name, text: self._store(thread_id, name, text, requires_admin))
|
||||
try:
|
||||
result = await handler(request)
|
||||
if isinstance(result, ToolMessage) and isinstance(result.content, str):
|
||||
content = format_tool_result_for_agent(result.content, tool_name=result.name)
|
||||
if content != result.content:
|
||||
payload = json.loads(content)
|
||||
payload["execution_outcome"] = inspect_tool_result(result).value
|
||||
content = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
return result.model_copy(update={"content": content})
|
||||
return result
|
||||
finally:
|
||||
TOOL_RESULT_RECORDER.reset(token)
|
||||
@@ -8,6 +8,8 @@ from langchain.agents.middleware import AgentMiddleware, ToolCallRequest, hook_c
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
from app.agent.policy.contracts import (
|
||||
ExecutionOutcome,
|
||||
ExecutionReceipt,
|
||||
ToolOrigin,
|
||||
ToolPolicyContext,
|
||||
)
|
||||
@@ -17,15 +19,18 @@ from app.agent.policy.orchestrator import (
|
||||
call_policy_hook,
|
||||
)
|
||||
from app.agent.policy.registry import requests_system_setting_secrets
|
||||
from app.agent.policy.sanitizer import stable_type_name
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.agent.tools.result import EXECUTION_OUTCOME_KEY, ToolExecutionError, annotate_tool_result
|
||||
|
||||
POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。"
|
||||
POLICY_UNAVAILABLE_MESSAGE = "宿主策略暂时不可用,未执行该工具。"
|
||||
TOOL_TIMEOUT_MESSAGE = "工具执行超时,已停止等待结果;若工具包含外部写操作,操作可能仍在继续,请先确认实际状态再重试。"
|
||||
|
||||
|
||||
class AgentPolicyMiddleware(AgentMiddleware):
|
||||
# follow_imports=skip 下只在第三方中间件基类和装饰器边界忽略 misc。
|
||||
class AgentPolicyMiddleware(AgentMiddleware): # type: ignore[misc]
|
||||
"""观测进入本地 ToolNode 的 client-side 工具调用和结果。
|
||||
|
||||
模型供应商原生 server tools 在供应商侧执行,不经过本地 middleware,
|
||||
@@ -46,7 +51,7 @@ class AgentPolicyMiddleware(AgentMiddleware):
|
||||
self.catalog = catalog
|
||||
self._tools = {tool.name: tool for tool in (tools or []) if getattr(tool, "name", None)}
|
||||
|
||||
@hook_config(can_jump_to=["end"])
|
||||
@hook_config(can_jump_to=["end"]) # type: ignore[misc]
|
||||
async def aafter_model(self, state: dict[str, Any], runtime: Any) -> Any:
|
||||
"""在 ToolNode 前暂停需要用户确认的敏感设置读取。"""
|
||||
messages = state.get("messages") or []
|
||||
@@ -104,24 +109,37 @@ class AgentPolicyMiddleware(AgentMiddleware):
|
||||
arguments = tool_call.get("args") or {}
|
||||
if not isinstance(arguments, dict):
|
||||
arguments = {}
|
||||
try:
|
||||
_, result = await self.execute_tool_call(
|
||||
tool=request.tool,
|
||||
arguments=arguments,
|
||||
invocation_id=tool_call.get("id"),
|
||||
handler=lambda: handler(request),
|
||||
enforce_decision=False,
|
||||
)
|
||||
except TimeoutError:
|
||||
tool_name = str(getattr(request.tool, "name", None) or "unknown")
|
||||
return ToolMessage(
|
||||
content=TOOL_TIMEOUT_MESSAGE,
|
||||
tool_call_id=str(tool_call.get("id") or ""),
|
||||
name=tool_name,
|
||||
status="error",
|
||||
)
|
||||
_, result = await self.execute_tool_call(
|
||||
tool=request.tool,
|
||||
arguments=arguments,
|
||||
invocation_id=tool_call.get("id"),
|
||||
handler=lambda: handler(request),
|
||||
enforce_decision=False,
|
||||
)
|
||||
# 普通 ToolNode 保持 shadow 观测;已确认调用使用默认的强制决策语义。
|
||||
return result
|
||||
return annotate_tool_result(result)
|
||||
|
||||
@staticmethod
|
||||
def _error_result(
|
||||
tool: Any, invocation_id: str | None, error: Exception, receipt: ExecutionReceipt | None,
|
||||
) -> ToolMessage:
|
||||
"""把普通故障转为可恢复回执,异常私有正文不进入模型上下文。"""
|
||||
outcome = receipt.outcome if isinstance(receipt, ExecutionReceipt) else ExecutionOutcome.FAILED
|
||||
if isinstance(error, TimeoutError):
|
||||
content = TOOL_TIMEOUT_MESSAGE
|
||||
if not isinstance(receipt, ExecutionReceipt):
|
||||
outcome = ExecutionOutcome.UNKNOWN
|
||||
elif isinstance(error, ToolExecutionError):
|
||||
content = str(error)
|
||||
else:
|
||||
content = f"工具执行失败({stable_type_name(error)})。请检查调用参数或查询当前状态后继续处理。"
|
||||
return ToolMessage(
|
||||
content=content,
|
||||
tool_call_id=str(invocation_id or ""),
|
||||
name=str(getattr(tool, "name", None) or "unknown"),
|
||||
status="error",
|
||||
additional_kwargs={EXECUTION_OUTCOME_KEY: outcome.value},
|
||||
)
|
||||
|
||||
async def execute_tool_call(
|
||||
self,
|
||||
@@ -143,7 +161,7 @@ class AgentPolicyMiddleware(AgentMiddleware):
|
||||
)
|
||||
if enforce_decision and observation is None:
|
||||
return False, POLICY_UNAVAILABLE_MESSAGE
|
||||
if enforce_decision and observation.decision.allowed is False:
|
||||
if enforce_decision and observation is not None and observation.decision.allowed is False:
|
||||
return False, POLICY_DENIED_MESSAGE
|
||||
try:
|
||||
result = await handler()
|
||||
@@ -157,13 +175,16 @@ class AgentPolicyMiddleware(AgentMiddleware):
|
||||
)
|
||||
raise
|
||||
except Exception as error:
|
||||
receipt = None
|
||||
if observation is not None:
|
||||
call_policy_hook(
|
||||
receipt = call_policy_hook(
|
||||
"fail",
|
||||
self.orchestrator.fail,
|
||||
observation,
|
||||
error,
|
||||
)
|
||||
if not enforce_decision:
|
||||
return True, self._error_result(tool, invocation_id, error, receipt)
|
||||
raise
|
||||
if observation is not None:
|
||||
call_policy_hook(
|
||||
|
||||
@@ -23,6 +23,7 @@ from langchain.agents.middleware.tool_selection import (
|
||||
from langchain.tools import ToolRuntime
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.messages.utils import count_tokens_approximately
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.tools import BaseTool, StructuredTool, create_schema_from_function
|
||||
from langgraph.runtime import Runtime
|
||||
@@ -31,6 +32,8 @@ from pydantic import Field
|
||||
from typing_extensions import TypedDict # noqa
|
||||
|
||||
from app.agent.llm.helper import LLMHelper
|
||||
from app.agent.middleware.usage import UsageMiddleware
|
||||
from app.agent.middleware.utils import append_to_system_message
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.runtime.log import logger
|
||||
|
||||
@@ -43,6 +46,38 @@ TOOL_DISCOVERY_MAX_RESULTS = 8
|
||||
TOOL_DISCOVERY_DESCRIPTION_CHARS = 400
|
||||
TOOL_DISCOVERY_MAX_TAGS = 6
|
||||
TOOL_DISCOVERY_TAG_CHARS = 48
|
||||
TOOL_DISCOVERY_WINDOW_SIZE = 16
|
||||
TOOL_DISCOVERY_SCHEMA_TOKENS = 4096
|
||||
TOOL_DISCOVERY_SCHEMA_WINDOW_FRACTION = 0.10
|
||||
TOOL_DISCOVERY_INPUT_FRACTION = 0.85
|
||||
TOOL_DISCOVERY_STOP_WORDS = frozenset({
|
||||
"the", "a", "an", "and", "or", "for", "to", "of", "in", "on", "with",
|
||||
"please", "find", "show", "help", "me", "my", "tool", "tools",
|
||||
})
|
||||
TOOL_DISCOVERY_ALIASES = {
|
||||
ToolTag.Media.value: ("media", "movie", "movies", "tv", "电影", "电视剧", "剧集"),
|
||||
ToolTag.Resource.value: ("resource", "torrent", "种子", "资源"),
|
||||
ToolTag.Site.value: ("site", "sites", "tracker", "站点"),
|
||||
ToolTag.Subscription.value: ("subscription", "subscriptions", "subscribe", "订阅", "追剧"),
|
||||
ToolTag.Download.value: ("download", "downloads", "downloader", "下载"),
|
||||
ToolTag.Library.value: ("library", "mediaserver", "媒体库", "媒体服务器"),
|
||||
ToolTag.Transfer.value: ("transfer", "organize", "整理", "转移"),
|
||||
ToolTag.System.value: ("system", "系统"),
|
||||
ToolTag.Settings.value: ("settings", "configuration", "配置", "设置"),
|
||||
ToolTag.Plugin.value: ("plugin", "plugins", "插件"),
|
||||
ToolTag.Workflow.value: ("workflow", "workflows", "工作流"),
|
||||
ToolTag.Scheduler.value: ("scheduler", "schedule", "定时", "调度"),
|
||||
ToolTag.AgentTask.value: ("agent_task", "智能体任务", "后台任务"),
|
||||
ToolTag.File.value: ("file", "files", "文件"),
|
||||
ToolTag.Directory.value: ("directory", "directories", "folder", "folders", "目录", "文件夹"),
|
||||
ToolTag.Web.value: ("web", "internet", "网页", "互联网"),
|
||||
ToolTag.Command.value: ("command", "shell", "命令", "终端"),
|
||||
ToolTag.FilterRule.value: ("filter", "过滤", "筛选规则"),
|
||||
ToolTag.Persona.value: ("persona", "人格"),
|
||||
ToolTag.Recommendation.value: ("recommendation", "recommend", "推荐"),
|
||||
ToolTag.Metadata.value: ("metadata", "scrape", "元数据", "刮削"),
|
||||
ToolTag.Skill.value: ("skill", "skills", "技能"),
|
||||
}
|
||||
TOOL_GROUP_EXCLUDED_TAGS = frozenset(
|
||||
{
|
||||
ToolTag.AgentTool.value,
|
||||
@@ -65,8 +100,9 @@ MoviePilot tool-chain hints:
|
||||
|
||||
|
||||
def _merge_discovered_tool_names(current: list[str], added: list[str]) -> list[str]:
|
||||
"""合并并行工具发现的增量,避免各工具调用互相覆盖启用结果。"""
|
||||
return list(dict.fromkeys([*current, *added]))
|
||||
"""按图更新顺序合并最近发现窗口,重复发现刷新优先级且窗口外能力自动淘汰。"""
|
||||
latest = list(dict.fromkeys(added))
|
||||
return [*[name for name in current if name not in latest], *latest][-TOOL_DISCOVERY_WINDOW_SIZE:]
|
||||
|
||||
|
||||
class ToolSelectionState(AgentState):
|
||||
@@ -123,7 +159,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
always_include: list[str] | None = None,
|
||||
enable_discovery: bool = False,
|
||||
) -> None:
|
||||
"""配置首轮筛选及可选的本轮工具发现,后者每次最多额外启用八个工具。"""
|
||||
"""配置首轮筛选与按需发现,新增能力受最近窗口和额外参数预算约束。"""
|
||||
super().__init__(
|
||||
model=model,
|
||||
system_prompt=self._append_tool_selection_hint(system_prompt),
|
||||
@@ -144,8 +180,9 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
name=TOOL_DISCOVERY_NAME,
|
||||
description=(
|
||||
"Find and enable tools missing from the current tool list. Search by exact tool name "
|
||||
"or concise keywords from descriptions or capability tags. Matching tools become "
|
||||
"available with their full input schemas on the next model call in this user turn. "
|
||||
"or Chinese/English capability keywords. Matches are candidates for the next model call, "
|
||||
"subject to the latest 16 discoveries and an additional schema/context budget. "
|
||||
"The next model call includes an authoritative availability notice with reasons. "
|
||||
"Use when the task changes or a skill references an unavailable tool. "
|
||||
"This searches only the current authorized catalog; no match enables nothing."
|
||||
),
|
||||
@@ -157,27 +194,41 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
object.__setattr__(tool, "_agent_tool_source", "middleware:selection")
|
||||
return tool
|
||||
|
||||
@staticmethod
|
||||
def _matches_discovery_term(term: str, text: str) -> bool:
|
||||
"""中文短语允许句内匹配,英文使用完整单词避免把子串误认成能力。"""
|
||||
if re.search(r"[\u4e00-\u9fff]", term):
|
||||
return term in text
|
||||
return bool(re.search(rf"(?<![a-z0-9]){re.escape(term)}(?![a-z0-9])", text))
|
||||
|
||||
@classmethod
|
||||
def _discovery_score(cls, tool: BaseTool, search: str, keywords: list[str]) -> int:
|
||||
def _discovery_score(cls, tool: BaseTool, search: str, keywords: list[str], capabilities: set[str]) -> int:
|
||||
"""按工具名、能力标签和说明排序,优先保留精确名称命中的能力。"""
|
||||
name = tool.name.casefold()
|
||||
description = str(tool.description or "").casefold()
|
||||
tags = " ".join(cls._normalize_tool_tags(tool)).casefold()
|
||||
tool_tags = cls._normalize_tool_tags(tool)
|
||||
tags = " ".join(tool_tags).casefold()
|
||||
if search == name:
|
||||
return 1000
|
||||
return sum(
|
||||
10 * (keyword in name) + 5 * (keyword in tags) + (keyword in description)
|
||||
return 10000
|
||||
return 100 * len(capabilities.intersection(tool_tags)) + sum(
|
||||
10 * cls._matches_discovery_term(keyword, name)
|
||||
+ 5 * cls._matches_discovery_term(keyword, tags)
|
||||
+ cls._matches_discovery_term(keyword, description)
|
||||
for keyword in keywords
|
||||
)
|
||||
|
||||
def _find_discovery_tools(self, search: str, limit: int) -> list[BaseTool]:
|
||||
"""只在当前授权目录内离线匹配关键词,空白或无关查询不会展开全量工具。"""
|
||||
query = search.strip().casefold()
|
||||
keywords = list(dict.fromkeys(re.findall(r"[^\W_]+", query)))
|
||||
keywords = [word for word in dict.fromkeys(re.findall(r"[^\W_]+", query)) if word not in TOOL_DISCOVERY_STOP_WORDS]
|
||||
if not keywords:
|
||||
return []
|
||||
capabilities = {
|
||||
tag for tag, aliases in TOOL_DISCOVERY_ALIASES.items()
|
||||
if any(self._matches_discovery_term(alias, query) for alias in aliases)
|
||||
}
|
||||
ranked_tools = [
|
||||
(self._discovery_score(tool, query, keywords), tool)
|
||||
(self._discovery_score(tool, query, keywords, capabilities), tool)
|
||||
for tool in self.selection_tools
|
||||
if not isinstance(tool, dict) and tool.name != TOOL_DISCOVERY_NAME
|
||||
]
|
||||
@@ -198,6 +249,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
) -> Command:
|
||||
"""返回有界工具目录并以图状态增量启用结果,不修改共享中间件实例。"""
|
||||
matched_tools = self._find_discovery_tools(search, limit)
|
||||
baseline_names = set(self.always_include) | set(getattr(runtime, "state", {}).get("selected_tool_names") or [])
|
||||
catalog = [
|
||||
{
|
||||
"name": tool.name,
|
||||
@@ -206,18 +258,20 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
tag[:TOOL_DISCOVERY_TAG_CHARS]
|
||||
for tag in self._normalize_tool_tags(tool)[:TOOL_DISCOVERY_MAX_TAGS]
|
||||
],
|
||||
"status": "already_selected" if tool.name in baseline_names else "pending_budget_check",
|
||||
}
|
||||
for tool in matched_tools
|
||||
]
|
||||
payload = {
|
||||
"tools": catalog,
|
||||
"message": (
|
||||
"Matching tools are enabled for the next model call in this user turn."
|
||||
"Candidates registered. The next model call reports which tools fit the recent discovery window "
|
||||
"and schema/context budget. Earlier discoveries may be evicted; search an exact name to refresh it."
|
||||
if matched_tools else "No matching tools. Try a precise tool name or different keywords."
|
||||
),
|
||||
}
|
||||
return Command(update={
|
||||
"discovered_tool_names": [tool.name for tool in matched_tools],
|
||||
"discovered_tool_names": [tool.name for tool in reversed(matched_tools) if tool.name not in baseline_names],
|
||||
"messages": [ToolMessage(
|
||||
content=json.dumps(payload, ensure_ascii=False),
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
@@ -232,6 +286,84 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
update["discovered_tool_names"] = Overwrite([])
|
||||
return update
|
||||
|
||||
@staticmethod
|
||||
def _discovery_availability_notice(statuses: dict[str, str]) -> str:
|
||||
"""给模型提供当前绑定结果,覆盖发现工具此前返回的候选状态。"""
|
||||
return (
|
||||
"<tool_discovery_availability>\n"
|
||||
"Authoritative availability for this model call (supersedes pending search_tools results):\n"
|
||||
f"{json.dumps(statuses, ensure_ascii=False)}\n"
|
||||
f"Only the latest {TOOL_DISCOVERY_WINDOW_SIZE} discovered tools are candidates. "
|
||||
"Older discoveries are evicted unless initially selected or mandatory. Search an exact name to refresh it. "
|
||||
"Only enabled/already_selected tools are available; budget-disabled tools may fit after context shrinks.\n"
|
||||
"</tool_discovery_availability>"
|
||||
)
|
||||
|
||||
def _discovery_budget(self, request: ModelRequest, statuses: dict[str, str]) -> tuple[int, int]:
|
||||
"""复用最终请求估算器,为额外工具预留上下文余量并限制结构化参数总成本。"""
|
||||
budget_request = request.override(system_message=append_to_system_message(
|
||||
request.system_message, self._discovery_availability_notice(statuses),
|
||||
))
|
||||
budget = UsageMiddleware.estimate_request(budget_request)
|
||||
schema_budget = TOOL_DISCOVERY_SCHEMA_TOKENS
|
||||
context_budget = schema_budget
|
||||
context_window = budget.get("context_window_tokens")
|
||||
if isinstance(context_window, int) and context_window > 0:
|
||||
schema_budget = min(schema_budget, int(context_window * TOOL_DISCOVERY_SCHEMA_WINDOW_FRACTION))
|
||||
context_budget = max(0, int(context_window * TOOL_DISCOVERY_INPUT_FRACTION) - budget["estimated_input_tokens"])
|
||||
return schema_budget, context_budget
|
||||
|
||||
def _budget_discovery_tools(self, request: ModelRequest, candidates: list[BaseTool], statuses: dict[str, str]) -> list[str]:
|
||||
"""按最近发现优先接纳可容纳的工具,估算失败时只禁用额外发现能力。"""
|
||||
try:
|
||||
schema_budget, context_budget = self._discovery_budget(request, statuses)
|
||||
except Exception as error:
|
||||
logger.warning(f"工具发现预算估算失败,保留首轮工具: {type(error).__name__}")
|
||||
statuses.update({tool.name: "budget_estimate_unavailable" for tool in candidates})
|
||||
return []
|
||||
enabled = []
|
||||
for tool in candidates:
|
||||
try:
|
||||
cost = count_tokens_approximately([], tools=[tool], use_usage_metadata_scaling=False)
|
||||
except Exception as error:
|
||||
logger.warning(f"工具参数预算无法估算: {tool.name}, {type(error).__name__}")
|
||||
statuses[tool.name] = "budget_estimate_unavailable"
|
||||
continue
|
||||
if cost > schema_budget:
|
||||
statuses[tool.name] = "schema_budget_exceeded"
|
||||
continue
|
||||
if cost > context_budget:
|
||||
statuses[tool.name] = "context_budget_exceeded"
|
||||
continue
|
||||
statuses[tool.name] = "enabled"
|
||||
enabled.append(tool.name)
|
||||
schema_budget -= cost
|
||||
context_budget -= cost
|
||||
return enabled
|
||||
|
||||
def _bind_discovery_tools(self, request: ModelRequest, selected_tool_names: list[str]) -> ModelRequest:
|
||||
"""从当前请求精确工具实例中绑定预算内发现项,并保留初选、强制和供应商工具。"""
|
||||
baseline_names = list(dict.fromkeys([*selected_tool_names, *self.always_include]))
|
||||
discovered_names = list(reversed(request.state.get("discovered_tool_names", [])[-TOOL_DISCOVERY_WINDOW_SIZE:]))
|
||||
baseline = self._apply_selected_tools(request, baseline_names)
|
||||
if not discovered_names:
|
||||
return baseline
|
||||
current_tools = {tool.name: tool for tool in request.tools if not isinstance(tool, dict)}
|
||||
statuses = {
|
||||
name: "already_selected" if name in baseline_names and name in current_tools else "unavailable_in_current_catalog"
|
||||
for name in discovered_names
|
||||
}
|
||||
candidates = [current_tools[name] for name in discovered_names if name not in baseline_names and name in current_tools]
|
||||
# 先按最长预算拒绝文案估算提示成本,实际启用文案只会缩短。
|
||||
statuses.update({tool.name: "budget_estimate_unavailable" for tool in candidates})
|
||||
enabled = self._budget_discovery_tools(baseline, candidates, statuses)
|
||||
bound = self._apply_selected_tools(request, list(dict.fromkeys([
|
||||
*selected_tool_names, *enabled, *self.always_include,
|
||||
])))
|
||||
return bound.override(system_message=append_to_system_message(
|
||||
request.system_message, self._discovery_availability_notice(statuses),
|
||||
))
|
||||
|
||||
@classmethod
|
||||
def _render_recent_conversation_context(
|
||||
cls,
|
||||
@@ -767,11 +899,6 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
request.state["selected_tool_names"] = selected_tool_names # noqa
|
||||
|
||||
if selected_tool_names is not None:
|
||||
selected_tool_names = list(dict.fromkeys([
|
||||
*selected_tool_names,
|
||||
*request.state.get("discovered_tool_names", []),
|
||||
*self.always_include,
|
||||
]))
|
||||
request = self._apply_selected_tools(request, selected_tool_names)
|
||||
request = self._bind_discovery_tools(request, selected_tool_names)
|
||||
|
||||
return await handler(request)
|
||||
|
||||
@@ -29,10 +29,12 @@ from app.agent.mcp import agent_mcp_manager
|
||||
from app.agent.memory import MemoryManager, memory_manager
|
||||
from app.agent.middleware.activity import ActivityLogMiddleware
|
||||
from app.agent.middleware.config import RuntimeConfigMiddleware
|
||||
from app.agent.middleware.invocation import InvocationMiddleware
|
||||
from app.agent.middleware.jobs import (
|
||||
JobsMiddleware,
|
||||
)
|
||||
from app.agent.middleware.memory import MemoryMiddleware
|
||||
from app.agent.middleware.output import ToolOutputMiddleware
|
||||
from app.agent.middleware.patching import PatchToolCallsMiddleware
|
||||
from app.agent.middleware.plan import PLAN_SNAPSHOT_KEY, PlanMiddleware, attach_plan_snapshot
|
||||
from app.agent.middleware.policy import AgentPolicyMiddleware
|
||||
@@ -1893,8 +1895,13 @@ class MoviePilotAgent:
|
||||
)
|
||||
temporary_subagent_middlewares = tuple(subagent_middlewares)
|
||||
plan_middleware = PlanMiddleware()
|
||||
output_middleware = ToolOutputMiddleware(policy_context)
|
||||
invocation_repository = getattr(self._data, "invocations", None)
|
||||
invocation_middlewares = [InvocationMiddleware(policy_context, invocation_repository, tools)] if invocation_repository else []
|
||||
internal_tools = [
|
||||
*skill_tools, *activity_log_tools, *subagent_task_tools, *plan_middleware.tools,
|
||||
*output_middleware.tools,
|
||||
*(tool for middleware in invocation_middlewares for tool in middleware.tools),
|
||||
]
|
||||
tool_selector = self._initialize_tool_selector(tools, internal_tools, non_streaming_model)
|
||||
# 严格目录必须覆盖 LangGraph ToolNode 可执行的全部 client-side 工具。
|
||||
@@ -1940,6 +1947,8 @@ class MoviePilotAgent:
|
||||
catalog=tool_catalog,
|
||||
tools=tools,
|
||||
),
|
||||
output_middleware,
|
||||
*invocation_middlewares,
|
||||
# Skills
|
||||
skills_middleware,
|
||||
# Jobs 任务管理
|
||||
|
||||
@@ -93,10 +93,12 @@ class MigrationState(str, Enum):
|
||||
|
||||
|
||||
class ExecutionOutcome(str, Enum):
|
||||
"""工具 handler 观测终态;成功不代表业务授权或副作用已完成。"""
|
||||
"""工具实际回执状态;提交未完成和结果未知不能视为业务成功。"""
|
||||
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
PENDING = "pending"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Mapping, Optional, TypeVar
|
||||
from typing import Any, Mapping, Optional, TypeVar, cast
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from pydantic import ValidationError
|
||||
@@ -29,6 +29,7 @@ from app.agent.policy.sanitizer import (
|
||||
summarize_input,
|
||||
summarize_result,
|
||||
)
|
||||
from app.agent.tools.result import inspect_tool_result
|
||||
from app.runtime.log import logger
|
||||
|
||||
_HookResult = TypeVar("_HookResult")
|
||||
@@ -59,7 +60,7 @@ def _normalize_policy_arguments(tool: Any, arguments: Mapping[str, Any]) -> dict
|
||||
return raw_arguments
|
||||
try:
|
||||
validated = args_schema.model_validate(raw_arguments)
|
||||
return validated.model_dump(mode="json")
|
||||
return cast(dict[str, Any], validated.model_dump(mode="json"))
|
||||
except (AttributeError, TypeError, ValueError, ValidationError):
|
||||
# 实际 handler 仍负责既有参数错误语义;策略观测按原始值保守处理。
|
||||
return raw_arguments
|
||||
@@ -143,7 +144,8 @@ class AgentToolPolicyOrchestrator:
|
||||
|
||||
@staticmethod
|
||||
def finish(observation: PolicyObservation, result: Any) -> ExecutionReceipt:
|
||||
"""生成成功回执 envelope,并只记录脱敏结果摘要。"""
|
||||
"""依据实际工具协议生成回执,提交中或业务失败不能标为成功。"""
|
||||
outcome = inspect_tool_result(result)
|
||||
if observation.policy.result_sensitivity is ResultSensitivity.SECRET:
|
||||
result_summary = '{"protected_result": "***"}'
|
||||
else:
|
||||
@@ -153,18 +155,20 @@ class AgentToolPolicyOrchestrator:
|
||||
tool_name=observation.invocation.tool_name,
|
||||
origin=observation.invocation.origin,
|
||||
decision=observation.decision,
|
||||
outcome=ExecutionOutcome.SUCCEEDED,
|
||||
outcome=outcome,
|
||||
input_summary=observation.input_summary,
|
||||
result_summary=result_summary,
|
||||
external_may_continue=outcome in {ExecutionOutcome.PENDING, ExecutionOutcome.UNKNOWN},
|
||||
needs_reconcile=outcome is ExecutionOutcome.UNKNOWN,
|
||||
duration_ms=max(
|
||||
0,
|
||||
int((time.monotonic() - observation.started_at) * 1000),
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Agent工具执行完成: tool={receipt.tool_name}, "
|
||||
f"Agent工具执行结果: tool={receipt.tool_name}, "
|
||||
f"origin={receipt.origin.value}, shadow={receipt.decision.shadow}, "
|
||||
f"duration_ms={receipt.duration_ms}, result={result_summary}"
|
||||
f"duration_ms={receipt.duration_ms}, outcome={outcome.value}, result={result_summary}"
|
||||
)
|
||||
return receipt
|
||||
|
||||
@@ -199,7 +203,7 @@ class AgentToolPolicyOrchestrator:
|
||||
tool_name=observation.invocation.tool_name,
|
||||
origin=observation.invocation.origin,
|
||||
decision=observation.decision,
|
||||
outcome=ExecutionOutcome.FAILED,
|
||||
outcome=ExecutionOutcome.UNKNOWN if external_may_continue else ExecutionOutcome.FAILED,
|
||||
input_summary=observation.input_summary,
|
||||
error_summary=error_summary,
|
||||
duration_ms=max(
|
||||
|
||||
@@ -61,6 +61,8 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
|
||||
<tool_strategy>
|
||||
- If `search_tools` is available and a needed tool is missing, search by its exact name from a Skill or concise capability keywords. Read the returned tool schema on the next model call and continue the task; finding a tool does not authorize its execution.
|
||||
- If a tool preview includes `result_id` and `next_offset`, use `read_tool_result` to continue reading instead of executing the original tool again. Expired or unavailable result IDs require a narrower fresh query.
|
||||
- Respect the host's `succeeded`, `failed`, `pending`, and `unknown` outcomes. A pending task or unknown write is not complete. Use `get_tool_execution` with the returned invocation ID to inspect a durable write receipt. Reuse completed results, and do not change parameters merely to bypass duplicate or unresolved-write protection.
|
||||
- Use parallel tool calls by default for independent read-only or diagnostic work. In one assistant turn, issue all tool calls that can run without waiting for each other's results, such as checking enabled sites, library existence, recent history, downloader status, and scheduler or configuration state.
|
||||
- Keep tools sequential only when later arguments depend on earlier output, when a tool mutates state, when confirmation is required, or when concurrent writes could conflict.
|
||||
- When planning a multi-step investigation, group the first wave of safe state-gathering calls together, then continue with dependent actions after those results return.
|
||||
|
||||
@@ -5,7 +5,7 @@ import threading
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from concurrent.futures import Future as ConcurrentFuture
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextvars import Context, copy_context
|
||||
from contextvars import Context, ContextVar, copy_context
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional, Protocol
|
||||
@@ -14,10 +14,12 @@ from langchain_core.tools import BaseTool
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from app.agent.policy.sanitizer import (
|
||||
stable_type_name,
|
||||
summarize_error,
|
||||
summarize_input,
|
||||
summarize_result,
|
||||
)
|
||||
from app.agent.tools.result import ToolExecutionError, inspect_tool_result
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.application.agent import AgentDataContext
|
||||
from app.application.messaging.agent import matches_channel_admin
|
||||
@@ -91,6 +93,8 @@ def __getattr__(name: str) -> Any:
|
||||
|
||||
|
||||
class ToolChain(ChainBase):
|
||||
"""为工具提供宿主业务链入口。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -114,6 +118,11 @@ def serialize_tool_result_for_agent(result: Any) -> str:
|
||||
return str(result)
|
||||
|
||||
|
||||
TOOL_RESULT_RECORDER: ContextVar[Optional[Callable[[str, str], dict[str, Any]]]] = ContextVar(
|
||||
"agent_tool_result_recorder", default=None,
|
||||
)
|
||||
|
||||
|
||||
def format_tool_result_for_agent(
|
||||
result: Any,
|
||||
*,
|
||||
@@ -129,15 +138,28 @@ def format_tool_result_for_agent(
|
||||
if not max_chars or max_chars <= 0 or len(formatted_result) <= max_chars:
|
||||
return formatted_result
|
||||
|
||||
reference: dict[str, Any] = {}
|
||||
recorder = TOOL_RESULT_RECORDER.get()
|
||||
if recorder is not None:
|
||||
reference = recorder(tool_name or "unknown", formatted_result)
|
||||
if reference.get("result_id"):
|
||||
max_chars = min(max_chars, 8192)
|
||||
outcome = inspect_tool_result(result).value
|
||||
|
||||
def _dump_preview(preview: str) -> str:
|
||||
"""序列化截断结果,并让 returned_chars 与实际预览保持一致。"""
|
||||
payload = {
|
||||
**reference,
|
||||
**({"next_offset": len(preview)} if reference.get("result_id") else {}),
|
||||
"execution_outcome": outcome,
|
||||
"tool_result_truncated": True,
|
||||
"tool_name": tool_name,
|
||||
"total_chars": len(formatted_result),
|
||||
"returned_chars": len(preview),
|
||||
"content_preview": preview,
|
||||
"message": (
|
||||
"完整结果已在当前会话临时保存;使用 read_tool_result 的 result_id 和 next_offset 继续读取,无需重复执行原工具。"
|
||||
if reference.get("result_id") else
|
||||
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
|
||||
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
|
||||
),
|
||||
@@ -418,6 +440,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
return sorted(explicit_tags | {ToolTag.AgentTool.value})
|
||||
|
||||
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""拒绝同步执行,确保工具遵循异步超时与宿主策略边界。"""
|
||||
raise NotImplementedError("MoviePilotTool 只支持异步调用,请使用 _arun")
|
||||
|
||||
async def _arun(self, *args: Any, **kwargs: Any) -> str:
|
||||
@@ -436,7 +459,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
# 不会产生工具消息或统计摘要;补一个换行分隔符,避免随后的失败说明
|
||||
# 与引导文本直接连在一起。
|
||||
self._ensure_tool_boundary_separator()
|
||||
return permission_result
|
||||
return json.dumps({"success": False, "error": permission_result}, ensure_ascii=False)
|
||||
|
||||
# 获取工具执行提示消息
|
||||
tool_message = self.get_tool_message(**kwargs)
|
||||
@@ -500,7 +523,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
result = await self.run_with_timeout(**kwargs)
|
||||
|
||||
logger.info(
|
||||
f"Agent工具 {self.name} 执行完成,"
|
||||
f"Agent工具 {self.name} 返回结果,状态: {inspect_tool_result(result).value},"
|
||||
f"结果摘要: {summarize_result(result)}"
|
||||
)
|
||||
|
||||
@@ -509,9 +532,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
logger.warning(error_message)
|
||||
raise
|
||||
except Exception as e:
|
||||
error_message = f"工具执行异常: {summarize_error(e)}"
|
||||
error_message = f"工具执行异常({stable_type_name(e)}),请检查参数或查询当前状态后继续处理。"
|
||||
logger.error(f"Tool {self.name} execution failed: {summarize_error(e)}")
|
||||
result = error_message
|
||||
raise ToolExecutionError(error_message) from e
|
||||
|
||||
return format_tool_result_for_agent(
|
||||
result, tool_name=self.name, max_chars=self.result_max_chars
|
||||
|
||||
@@ -8,11 +8,13 @@ from typing import Any, Dict, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field, PrivateAttr
|
||||
|
||||
from app.agent.api.arguments import canonical_api_arguments
|
||||
from app.agent.api.executor import ApiExecutionContext, ApiExecutionError, MoviePilotApiExecutor
|
||||
from app.agent.policy.api import resolve_api_operation
|
||||
from app.agent.policy.contracts import PrincipalRole
|
||||
from app.agent.policy.contracts import ExecutionOutcome, PrincipalRole
|
||||
from app.agent.policy.sanitizer import summarize_input
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.result import inspect_tool_result
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
@@ -128,6 +130,11 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
"""返回包含全部白名单 operation 精确参数的 MCP JSON Schema。"""
|
||||
return deepcopy(_load_api_mcp_input_schema())
|
||||
|
||||
def canonical_arguments(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""用缓存的 operation 合同生成实际执行与持久指纹共用的参数。"""
|
||||
validated = MoviePilotApiInput.model_validate(arguments).model_dump(mode="json")
|
||||
return canonical_api_arguments(validated, _load_api_mcp_input_schema())
|
||||
|
||||
async def _resolve_superuser_integration_identity(
|
||||
self,
|
||||
) -> tuple[str, Optional[str], bool]:
|
||||
@@ -271,19 +278,29 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return await executor.execute(
|
||||
result = await executor.execute(
|
||||
operation_id,
|
||||
path_params=path_params,
|
||||
query=query,
|
||||
body=body,
|
||||
)
|
||||
if operation_id == "scheduler.run" and inspect_tool_result(result) is ExecutionOutcome.SUCCEEDED:
|
||||
payload = json.loads(result)
|
||||
if isinstance(payload, dict) and payload.get("success") is True:
|
||||
payload["execution_outcome"] = "pending"
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
return result
|
||||
except ApiExecutionError as error:
|
||||
failure: dict[str, Any] = {
|
||||
"success": False,
|
||||
"error": "operation_unavailable",
|
||||
"message": str(error),
|
||||
}
|
||||
if error.external_may_continue:
|
||||
failure["execution_outcome"] = "unknown"
|
||||
failure["message"] += ";操作可能已生效,请先只读核验实际状态,避免重复执行。"
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "operation_unavailable",
|
||||
"message": str(error),
|
||||
},
|
||||
failure,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class McpExternalTool(MoviePilotTool):
|
||||
_spec: AgentMcpToolSpec = PrivateAttr()
|
||||
|
||||
def __init__(self, spec: AgentMcpToolSpec, session_id: str, user_id: str) -> None:
|
||||
"""绑定已配置的 MCP 工具及其输入协议。"""
|
||||
super().__init__(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
@@ -58,6 +59,8 @@ class McpExternalTool(MoviePilotTool):
|
||||
def _format_mcp_result(result: Any) -> str:
|
||||
"""将 MCP tools/call 返回结构转换为 Agent 可读文本。"""
|
||||
if isinstance(result, dict):
|
||||
if result.get("isError") is True:
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
content = result.get("content")
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
@@ -70,8 +73,6 @@ class McpExternalTool(MoviePilotTool):
|
||||
parts.append(json.dumps(item, ensure_ascii=False, default=str))
|
||||
if parts:
|
||||
return "\n".join(parts)
|
||||
if result.get("isError"):
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
@@ -434,12 +434,13 @@ class MoviePilotToolsManager:
|
||||
call_policy_hook("cancel", policy_orchestrator.fail, observation, e)
|
||||
raise
|
||||
except ToolExecutionTimeoutError as e:
|
||||
receipt = None
|
||||
if observation is not None and policy_orchestrator is not None:
|
||||
call_policy_hook("fail", policy_orchestrator.fail, observation, e)
|
||||
receipt = call_policy_hook("fail", policy_orchestrator.fail, observation, e)
|
||||
error_summary = self._summarize_error(e)
|
||||
logger.warning(error_summary)
|
||||
return format_tool_result_for_agent(
|
||||
error_summary,
|
||||
{"error": error_summary, "execution_outcome": receipt.outcome.value if receipt else "unknown"},
|
||||
tool_name=tool_name,
|
||||
max_chars=getattr(tool_instance, "result_max_chars", None),
|
||||
)
|
||||
@@ -458,7 +459,7 @@ class MoviePilotToolsManager:
|
||||
"finish",
|
||||
policy_orchestrator.finish,
|
||||
observation,
|
||||
str_result,
|
||||
result,
|
||||
)
|
||||
return str_result
|
||||
|
||||
|
||||
117
app/agent/tools/result.py
Normal file
117
app/agent/tools/result.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""解析工具明确的结果协议,保持正常业务载荷和自然语言原样。"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import ToolException
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.agent.policy.contracts import ExecutionOutcome
|
||||
|
||||
EXECUTION_OUTCOME_KEY = "moviepilot_execution_outcome"
|
||||
_ERROR_ENVELOPE_KEYS = frozenset({
|
||||
"error", "message", "detail", "code", "status", "state", "success", "tool_name", "action",
|
||||
"execution_outcome", EXECUTION_OUTCOME_KEY,
|
||||
})
|
||||
|
||||
|
||||
# follow_imports=skip 下第三方异常基类按 Any 处理,仅忽略 SDK 边界。
|
||||
class ToolExecutionError(ToolException): # type: ignore[misc]
|
||||
"""携带宿主已脱敏说明的可恢复工具故障,避免伪装为成功字符串。"""
|
||||
|
||||
|
||||
def _aggregate_outcomes(outcomes: list[ExecutionOutcome]) -> ExecutionOutcome:
|
||||
"""未知副作用优先于失败,未完成任务优先于纯成功回执。"""
|
||||
for outcome in (ExecutionOutcome.UNKNOWN, ExecutionOutcome.FAILED, ExecutionOutcome.PENDING):
|
||||
if outcome in outcomes:
|
||||
return outcome
|
||||
return ExecutionOutcome.SUCCEEDED
|
||||
|
||||
|
||||
def _task_outcome(payload: Mapping[str, Any]) -> ExecutionOutcome:
|
||||
"""只解析执行任务和终端会话的状态,不把下载器资源状态当成工具终态。"""
|
||||
state = payload.get("status", payload.get("state"))
|
||||
state = state if isinstance(state, str) else ""
|
||||
if state in {"pending", "queued", "running", "accepted", "in_progress", "starting"}:
|
||||
return ExecutionOutcome.PENDING
|
||||
if state in {"failed", "error", "cancelled", "canceled", "killed"}:
|
||||
return ExecutionOutcome.FAILED
|
||||
if state in {"unknown", "interrupted"}:
|
||||
return ExecutionOutcome.UNKNOWN
|
||||
exit_code = payload.get("exit_code")
|
||||
if isinstance(exit_code, int) and exit_code != 0:
|
||||
return ExecutionOutcome.FAILED
|
||||
return ExecutionOutcome.SUCCEEDED
|
||||
|
||||
|
||||
def _mapping_outcome(payload: Mapping[str, Any]) -> ExecutionOutcome:
|
||||
"""识别明确错误 envelope 和既有任务协议,业务数据中的可选 error 不算失败。"""
|
||||
explicit = payload.get(EXECUTION_OUTCOME_KEY, payload.get("execution_outcome"))
|
||||
if isinstance(explicit, str) and explicit in ExecutionOutcome._value2member_map_:
|
||||
return ExecutionOutcome(explicit)
|
||||
if payload.get("isError") is True or payload.get("success") is False or payload.get("state") is False:
|
||||
return ExecutionOutcome.FAILED
|
||||
if (
|
||||
payload.get("error") and set(payload).issubset(_ERROR_ENVELOPE_KEYS)
|
||||
and payload.get("success") is not True and payload.get("state") is not True
|
||||
):
|
||||
return ExecutionOutcome.FAILED
|
||||
if any(payload.get(key) for key in ("task_id", "operation_id", "execution_id")) or (
|
||||
payload.get("session_id") and "command" in payload and "exit_code" in payload
|
||||
):
|
||||
return _task_outcome(payload)
|
||||
tasks = payload.get("tasks")
|
||||
action = payload.get("action")
|
||||
if isinstance(action, str) and action in {"start", "run", "pipeline", "status", "wait", "cancel"} and isinstance(tasks, list):
|
||||
return _aggregate_outcomes([
|
||||
_task_outcome(task) for task in tasks if isinstance(task, dict) and "task_id" in task
|
||||
])
|
||||
return ExecutionOutcome.SUCCEEDED
|
||||
|
||||
|
||||
def inspect_tool_result(result: Any) -> ExecutionOutcome:
|
||||
"""从工具消息、Command 或 JSON 提取明确执行状态,不依据自然语言猜测。"""
|
||||
if isinstance(result, ToolMessage):
|
||||
explicit = result.additional_kwargs.get(EXECUTION_OUTCOME_KEY)
|
||||
if isinstance(explicit, str) and explicit in ExecutionOutcome._value2member_map_:
|
||||
return ExecutionOutcome(explicit)
|
||||
if result.status == "error":
|
||||
return ExecutionOutcome.FAILED
|
||||
return inspect_tool_result(result.content)
|
||||
if isinstance(result, Command):
|
||||
update = result.update
|
||||
if isinstance(update, dict):
|
||||
return _aggregate_outcomes([
|
||||
inspect_tool_result(message) for message in update.get("messages", [])
|
||||
if isinstance(message, ToolMessage)
|
||||
])
|
||||
return ExecutionOutcome.SUCCEEDED
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
result = json.loads(result)
|
||||
except (ValueError, TypeError, RecursionError):
|
||||
return ExecutionOutcome.SUCCEEDED
|
||||
if isinstance(result, Mapping):
|
||||
return _mapping_outcome(result)
|
||||
# MCP 单文本块可能携带结构化工具结果;多块正文与业务列表不递归猜测。
|
||||
if isinstance(result, list) and len(result) == 1 and isinstance(result[0], dict):
|
||||
block = result[0]
|
||||
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
||||
return inspect_tool_result(block["text"])
|
||||
return ExecutionOutcome.SUCCEEDED
|
||||
|
||||
|
||||
def annotate_tool_result(result: Any) -> Any:
|
||||
"""将已识别状态附到工具协议元数据,成功载荷及 Command 状态更新保持不变。"""
|
||||
if isinstance(result, ToolMessage):
|
||||
outcome = inspect_tool_result(result)
|
||||
result.additional_kwargs[EXECUTION_OUTCOME_KEY] = outcome.value
|
||||
if outcome in {ExecutionOutcome.FAILED, ExecutionOutcome.UNKNOWN}:
|
||||
result.status = "error"
|
||||
elif isinstance(result, Command) and isinstance(result.update, dict):
|
||||
for message in result.update.get("messages", []):
|
||||
if isinstance(message, ToolMessage):
|
||||
annotate_tool_result(message)
|
||||
return result
|
||||
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
from app.application.agenttask import AgentTaskRepository
|
||||
from app.application.history import DownloadHistoryRepository, TransferHistoryRepository
|
||||
from app.application.invocation import InvocationRepository
|
||||
from app.application.messaging.chat import (
|
||||
AgentChatPersistenceService,
|
||||
AgentChatService,
|
||||
@@ -54,6 +55,7 @@ class AgentDataContext:
|
||||
transfer_execution: TransferExecutionRepository
|
||||
download_history: DownloadHistoryRepository
|
||||
plugin_data: PluginDataQueryRepository
|
||||
invocations: InvocationRepository | None = None
|
||||
|
||||
|
||||
Provider = Callable[[], Any]
|
||||
|
||||
89
app/application/invocation.py
Normal file
89
app/application/invocation.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Agent 写工具调用的持久身份、回执与原子认领端口。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol
|
||||
|
||||
InvocationStatus = Literal["running", "succeeded", "failed", "pending", "unknown"]
|
||||
InvocationFinalStatus = Literal["succeeded", "failed", "pending", "unknown"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InvocationIdentity:
|
||||
"""以宿主用户、会话和工具调用 ID 隔离一次写入。"""
|
||||
|
||||
principal_id: str
|
||||
session_id: str
|
||||
invocation_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InvocationSnapshot:
|
||||
"""脱离数据库会话的回执;只包含身份、指纹和宿主固定摘要。"""
|
||||
|
||||
identity: InvocationIdentity
|
||||
tool_name: str
|
||||
arguments_digest: str
|
||||
claim_token: str
|
||||
status: InvocationStatus
|
||||
summary: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InvocationClaim:
|
||||
"""仅 acquired 为真时允许发起副作用,已有回执不能再次执行。"""
|
||||
|
||||
record: InvocationSnapshot
|
||||
acquired: bool
|
||||
|
||||
|
||||
class InvocationConflictError(ValueError):
|
||||
"""同一调用 ID 携带不同工具或参数,拒绝重新解释已有写入。"""
|
||||
|
||||
|
||||
class InvocationRepository(Protocol):
|
||||
"""每次操作使用独立短事务,取消或超时不构成重放写入的授权。"""
|
||||
|
||||
def claim(
|
||||
self,
|
||||
identity: InvocationIdentity,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments_digest: str,
|
||||
) -> InvocationClaim:
|
||||
"""原子首次认领;相同输入返回已有回执,不同输入抛冲突异常。"""
|
||||
...
|
||||
|
||||
def get(self, identity: InvocationIdentity) -> InvocationSnapshot | None:
|
||||
"""按完整 owner 身份读取状态,不暴露原始参数或输出。"""
|
||||
...
|
||||
|
||||
def find_unresolved(
|
||||
self,
|
||||
principal_id: str,
|
||||
session_id: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments_digest: str,
|
||||
) -> InvocationSnapshot | None:
|
||||
"""读取同会话最近的同参数不确定写入;已确认提交的 pending 不阻止新意图。"""
|
||||
...
|
||||
|
||||
def finish(
|
||||
self,
|
||||
identity: InvocationIdentity,
|
||||
*,
|
||||
claim_token: str,
|
||||
status: InvocationFinalStatus,
|
||||
) -> bool:
|
||||
"""以当前 token 收口执行或核验结果;陈旧 owner 不得覆盖状态。"""
|
||||
...
|
||||
|
||||
def recover_running(self) -> int:
|
||||
"""仅由冷启动调用:运行中记录转未知并轮换 token,禁止自动重放。"""
|
||||
...
|
||||
|
||||
def delete_session(self, principal_id: str, session_id: str) -> int:
|
||||
"""在会话结束后移除已确认提交或终态回执,保留运行中和未知记录。"""
|
||||
...
|
||||
@@ -86,6 +86,10 @@ class CleanupRepository(Protocol):
|
||||
"""删除早于截止时间且未被任务引用的 Agent 会话。"""
|
||||
...
|
||||
|
||||
def delete_agent_invocations(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除过期且没有所属会话的已确认提交或终态写工具回执。"""
|
||||
...
|
||||
|
||||
def delete_agent_task_runs(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间且不再承担恢复语义的 Agent 运行历史。"""
|
||||
...
|
||||
@@ -289,11 +293,12 @@ class DataCleanupService:
|
||||
policy.agent_task_run_days,
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
outbox_completed_cutoff = self._outbox_cutoff(
|
||||
agent_invocation_cutoff = self._utc_cutoff(started_at, policy.agent_chat_days)
|
||||
outbox_completed_cutoff = self._utc_cutoff(
|
||||
started_at,
|
||||
policy.outbox_completed_days,
|
||||
)
|
||||
outbox_dead_cutoff = self._outbox_cutoff(
|
||||
outbox_dead_cutoff = self._utc_cutoff(
|
||||
started_at,
|
||||
policy.outbox_dead_days,
|
||||
)
|
||||
@@ -360,6 +365,14 @@ class DataCleanupService:
|
||||
db, agent_chat_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"agentinvocation",
|
||||
policy.agent_chat_days,
|
||||
agent_invocation_cutoff,
|
||||
lambda db: self._repository.delete_agent_invocations(
|
||||
db, agent_invocation_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"agenttaskrun",
|
||||
policy.agent_task_run_days,
|
||||
@@ -415,8 +428,8 @@ class DataCleanupService:
|
||||
return (started_at - timedelta(days=retention_days)).strftime(pattern)
|
||||
|
||||
@staticmethod
|
||||
def _outbox_cutoff(started_at: datetime, retention_days: int) -> str:
|
||||
"""按 Outbox 的 UTC ISO 格式生成可排序截止时间。"""
|
||||
def _utc_cutoff(started_at: datetime, retention_days: int) -> str:
|
||||
"""按回执与 Outbox 的 UTC ISO 格式生成可排序截止时间。"""
|
||||
aware_started_at = (
|
||||
started_at.astimezone()
|
||||
if started_at.tzinfo is None
|
||||
|
||||
161
app/db/adapters/invocation.py
Normal file
161
app/db/adapters/invocation.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Agent 写工具持久回执端口的 SQLAlchemy 短事务实现。"""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.invocation import (
|
||||
InvocationClaim,
|
||||
InvocationConflictError,
|
||||
InvocationFinalStatus,
|
||||
InvocationIdentity,
|
||||
InvocationSnapshot,
|
||||
InvocationStatus,
|
||||
)
|
||||
from app.db.models.agentinvocation import AgentInvocation
|
||||
from app.db.oper.agentinvocation import AgentInvocationOper
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
|
||||
_SUMMARIES = {
|
||||
"running": "写操作已开始,等待执行结果",
|
||||
"succeeded": "写操作已确认成功",
|
||||
"failed": "写操作已确认失败",
|
||||
"pending": "写操作已确认提交,后续任务完成情况尚未观测",
|
||||
"unknown": "写操作结果未知,必须核验状态后再决定下一步",
|
||||
}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
"""记录带时区时间;时间只用于审计,不用于允许重放。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _validate_identity(identity: InvocationIdentity) -> None:
|
||||
"""拒绝缺失或过长身份,避免调用落入共享空 owner 命名空间。"""
|
||||
for value in (identity.principal_id, identity.session_id, identity.invocation_id):
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > 255:
|
||||
raise ValueError("Agent 写调用身份必须为非空且不超过 255 字符的字符串")
|
||||
|
||||
|
||||
def _project(record: AgentInvocation) -> InvocationSnapshot:
|
||||
"""在事务内投影不可变回执,ORM 和 Session 不越过端口。"""
|
||||
return InvocationSnapshot(
|
||||
identity=InvocationIdentity(record.principal_id, record.session_id, record.invocation_id),
|
||||
tool_name=record.tool_name,
|
||||
arguments_digest=record.arguments_digest,
|
||||
claim_token=record.claim_token,
|
||||
status=cast(InvocationStatus, record.status),
|
||||
summary=record.summary,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class TransactionalInvocationRepository:
|
||||
"""每次端口调用独占一个短事务,构造本身不访问数据库。"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""保存会话工厂,允许测试使用独立 SQLite 文件验证竞争。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
def claim(
|
||||
self,
|
||||
identity: InvocationIdentity,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments_digest: str,
|
||||
) -> InvocationClaim:
|
||||
"""提交成功后才返回执行权,工具和参数不匹配时拒绝复用调用 ID。"""
|
||||
_validate_identity(identity)
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", tool_name):
|
||||
raise ValueError("Agent 工具名称无效")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", arguments_digest):
|
||||
raise ValueError("Agent 参数指纹必须为 SHA-256 摘要")
|
||||
now = _now()
|
||||
record = AgentInvocation(
|
||||
principal_id=identity.principal_id,
|
||||
session_id=identity.session_id,
|
||||
invocation_id=identity.invocation_id,
|
||||
tool_name=tool_name,
|
||||
arguments_digest=arguments_digest,
|
||||
claim_token=uuid4().hex,
|
||||
status="running", summary=_SUMMARIES["running"],
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
with self._session_factory() as session:
|
||||
oper = AgentInvocationOper(session)
|
||||
acquired = oper.stage_claim(record)
|
||||
stored = oper.get(identity.principal_id, identity.session_id, identity.invocation_id)
|
||||
if stored is None:
|
||||
raise RuntimeError("Agent 调用认领后回执不存在")
|
||||
if stored.tool_name != tool_name or stored.arguments_digest != arguments_digest:
|
||||
raise InvocationConflictError("已有 Agent 写调用的工具或参数不同,禁止重放")
|
||||
snapshot = _project(stored)
|
||||
SqlAlchemyUnitOfWork(session).commit()
|
||||
return InvocationClaim(record=snapshot, acquired=acquired)
|
||||
|
||||
def get(self, identity: InvocationIdentity) -> InvocationSnapshot | None:
|
||||
"""仅以完整身份读取回执。"""
|
||||
_validate_identity(identity)
|
||||
with self._session_factory() as session:
|
||||
record = AgentInvocationOper(session).get(
|
||||
identity.principal_id, identity.session_id, identity.invocation_id,
|
||||
)
|
||||
return _project(record) if record is not None else None
|
||||
|
||||
def finish(
|
||||
self,
|
||||
identity: InvocationIdentity,
|
||||
*,
|
||||
claim_token: str,
|
||||
status: InvocationFinalStatus,
|
||||
) -> bool:
|
||||
"""按 token 收口,摘要来自宿主固定文案,凭据与任意结果文本不落盘。"""
|
||||
_validate_identity(identity)
|
||||
if status not in ("succeeded", "failed", "pending", "unknown"):
|
||||
raise ValueError("Agent 调用收口状态无效")
|
||||
with self._session_factory() as session:
|
||||
oper = AgentInvocationOper(session)
|
||||
record = oper.get(identity.principal_id, identity.session_id, identity.invocation_id)
|
||||
changed = record is not None and oper.stage_finish(
|
||||
record, claim_token=claim_token, status=status,
|
||||
summary=_SUMMARIES[status], updated_at=_now(),
|
||||
)
|
||||
SqlAlchemyUnitOfWork(session).commit()
|
||||
return changed
|
||||
|
||||
def find_unresolved(
|
||||
self,
|
||||
principal_id: str,
|
||||
session_id: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments_digest: str,
|
||||
) -> InvocationSnapshot | None:
|
||||
"""以只读短事务查找旧未收口副作用,新请求不得盲目重复执行。"""
|
||||
with self._session_factory() as session:
|
||||
record = AgentInvocationOper(session).find_unresolved(
|
||||
principal_id, session_id,
|
||||
tool_name=tool_name, arguments_digest=arguments_digest,
|
||||
)
|
||||
return _project(record) if record is not None else None
|
||||
|
||||
def recover_running(self) -> int:
|
||||
"""冷启动撤销上轮执行 token,保留不可自动重放的未知回执。"""
|
||||
with self._session_factory() as session:
|
||||
changed = AgentInvocationOper(session).stage_recover(
|
||||
claim_token=uuid4().hex, summary=_SUMMARIES["unknown"], updated_at=_now(),
|
||||
)
|
||||
SqlAlchemyUnitOfWork(session).commit()
|
||||
return changed
|
||||
|
||||
def delete_session(self, principal_id: str, session_id: str) -> int:
|
||||
"""随会话移除已确认提交和终态历史,运行中与未知记录保留到核验。"""
|
||||
with self._session_factory() as session:
|
||||
changed = AgentInvocationOper(session).stage_delete_session(principal_id, session_id)
|
||||
SqlAlchemyUnitOfWork(session).commit()
|
||||
return changed
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import delete, exists, select
|
||||
|
||||
from app.db.base import execute_dml
|
||||
from app.db.models.agentchat import AgentChat
|
||||
from app.db.models.agentinvocation import AgentInvocation
|
||||
from app.db.models.agenttask import AgentTask
|
||||
from app.db.models.agenttaskrun import AgentTaskRun
|
||||
from app.db.models.downloadfailure import DownloadFailure
|
||||
@@ -88,14 +89,52 @@ class DatabaseCleanupRepository:
|
||||
|
||||
@staticmethod
|
||||
def delete_agent_chats(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""清理旧会话,但保留仍被 Agent 定时任务引用的上下文。"""
|
||||
"""回收旧会话和已确认回执,保留任务引用和投递结果不确定的上下文。"""
|
||||
task_reference = exists(
|
||||
select(AgentTask.id).where(AgentTask.session_id == AgentChat.session_id)
|
||||
)
|
||||
invocation_reference = exists(
|
||||
select(AgentInvocation.id).where(
|
||||
AgentInvocation.principal_id == AgentChat.user_id,
|
||||
AgentInvocation.session_id == AgentChat.session_id,
|
||||
AgentInvocation.status.in_(("running", "unknown")),
|
||||
)
|
||||
)
|
||||
ids = list(db.execute(
|
||||
select(AgentChat.id).where(
|
||||
AgentChat.updated_at < cutoff, ~task_reference, ~invocation_reference,
|
||||
).order_by(AgentChat.id).limit(limit)
|
||||
).scalars())
|
||||
if not ids:
|
||||
return 0
|
||||
execute_dml(
|
||||
db,
|
||||
delete(AgentInvocation).where(
|
||||
AgentInvocation.status.in_(("succeeded", "failed", "pending")),
|
||||
exists(select(AgentChat.id).where(
|
||||
AgentChat.id.in_(ids),
|
||||
AgentChat.user_id == AgentInvocation.principal_id,
|
||||
AgentChat.session_id == AgentInvocation.session_id,
|
||||
)),
|
||||
),
|
||||
)
|
||||
return execute_dml(db, delete(AgentChat).where(AgentChat.id.in_(ids)))
|
||||
|
||||
@staticmethod
|
||||
def delete_agent_invocations(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""清理无聊天行的旧提交回执和终态历史,不回收投递结果不确定的恢复状态。"""
|
||||
chat_reference = exists(select(AgentChat.id).where(
|
||||
AgentChat.user_id == AgentInvocation.principal_id,
|
||||
AgentChat.session_id == AgentInvocation.session_id,
|
||||
))
|
||||
return DatabaseCleanupRepository._delete_selected_ids(
|
||||
db=db,
|
||||
model=AgentChat,
|
||||
condition=(AgentChat.updated_at < cutoff) & ~task_reference,
|
||||
model=AgentInvocation,
|
||||
condition=(
|
||||
AgentInvocation.status.in_(("succeeded", "failed", "pending"))
|
||||
& (AgentInvocation.updated_at < cutoff)
|
||||
& ~chat_reference
|
||||
),
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from . import _identity # noqa: F401 注册全局媒体身份写入不变量
|
||||
|
||||
_MODEL_EXPORTS = {
|
||||
"AgentChat": ("app.db.models.agentchat", "AgentChat"),
|
||||
"AgentInvocation": ("app.db.models.agentinvocation", "AgentInvocation"),
|
||||
"AgentTask": ("app.db.models.agenttask", "AgentTask"),
|
||||
"AgentTaskRun": ("app.db.models.agenttaskrun", "AgentTaskRun"),
|
||||
"DownloadFailure": ("app.db.models.downloadfailure", "DownloadFailure"),
|
||||
|
||||
35
app/db/models/agentinvocation.py
Normal file
35
app/db/models/agentinvocation.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Agent 写工具调用的持久回执表。"""
|
||||
|
||||
from sqlalchemy import CheckConstraint, Index, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
|
||||
class AgentInvocation(Base):
|
||||
"""保存一次写调用的稳定身份与结果状态,不保存敏感参数或原始结果。"""
|
||||
|
||||
id = get_id_column()
|
||||
principal_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
session_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
invocation_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
tool_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
arguments_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
claim_token: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
summary: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(String, nullable=False)
|
||||
updated_at: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_agentinvocation_identity",
|
||||
"principal_id", "session_id", "invocation_id",
|
||||
unique=True,
|
||||
),
|
||||
Index("ix_agentinvocation_status_updated_id", "status", "updated_at", "id"),
|
||||
CheckConstraint(
|
||||
"status IN ('running', 'succeeded', 'failed', 'pending', 'unknown')",
|
||||
name="ck_agentinvocation_status",
|
||||
),
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.agentchat import AgentChat
|
||||
from app.db.oper.agentinvocation import AgentInvocationOper, terminal_session_delete
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
DEFAULT_AGENT_CHAT_TITLE = "未命名会话"
|
||||
@@ -17,6 +18,7 @@ class AgentChatOper(DbOper):
|
||||
"""
|
||||
|
||||
def __init__(self, db: Optional[Union[Session, AsyncSession]] = None):
|
||||
"""复用调用方会话,兼容既有插件入口的事务委托。"""
|
||||
super().__init__(db)
|
||||
|
||||
@staticmethod
|
||||
@@ -315,15 +317,24 @@ class AgentChatOper(DbOper):
|
||||
"""
|
||||
异步删除 Agent 会话历史。
|
||||
"""
|
||||
chat = await self.async_get(session_id=session_id, user_id=user_id)
|
||||
if not chat:
|
||||
return False
|
||||
await self._stage_async_delete(AgentChat, chat.id)
|
||||
return True
|
||||
async def stage(session: AsyncSession) -> bool:
|
||||
"""在同一事务删除会话和已确认回执,保留未知写入的恢复依据。"""
|
||||
return await AgentChatOper(session).async_stage_delete(session_id, user_id)
|
||||
|
||||
return await self._execute_async_write(stage)
|
||||
|
||||
def delete_by_id(self, chat_id: int) -> None:
|
||||
"""在 Oper 事务边界内按主键删除 Agent 会话。"""
|
||||
self._stage_delete(AgentChat, chat_id)
|
||||
"""在同一事务按主键删除 Agent 会话及对应已确认回执。"""
|
||||
def stage(session: Session) -> None:
|
||||
"""仅在会话存在时回收成功、失败或已确认提交的调用记录。"""
|
||||
chat = AgentChat.get(session, chat_id)
|
||||
if chat is None:
|
||||
return
|
||||
if chat.user_id is not None:
|
||||
AgentInvocationOper(session).stage_delete_session(chat.user_id, chat.session_id)
|
||||
session.delete(chat)
|
||||
|
||||
self._execute_sync_write(stage)
|
||||
|
||||
async def async_stage_delete(
|
||||
self,
|
||||
@@ -336,6 +347,10 @@ class AgentChatOper(DbOper):
|
||||
chat = await self.async_get(session_id=session_id, user_id=user_id)
|
||||
if not chat:
|
||||
return False
|
||||
if chat.user_id is not None:
|
||||
await self._db.execute(
|
||||
terminal_session_delete(chat.user_id, chat.session_id)
|
||||
)
|
||||
await self._db.delete(chat)
|
||||
await self._db.flush()
|
||||
return True
|
||||
|
||||
121
app/db/oper/agentinvocation.py
Normal file
121
app/db/oper/agentinvocation.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""调用方事务内的 Agent 写工具回执数据操作。"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as postgres_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.dml import Delete
|
||||
|
||||
from app.db.base import DbOper, execute_dml
|
||||
from app.db.models.agentinvocation import AgentInvocation
|
||||
|
||||
|
||||
def terminal_session_delete(principal_id: str, session_id: str) -> Delete:
|
||||
"""只构造已确认提交和终态历史删除语句,不创建会话或访问数据库。"""
|
||||
return delete(AgentInvocation).where(
|
||||
AgentInvocation.principal_id == principal_id,
|
||||
AgentInvocation.session_id == session_id,
|
||||
AgentInvocation.status.in_(("succeeded", "failed", "pending")),
|
||||
)
|
||||
|
||||
|
||||
class AgentInvocationOper(DbOper):
|
||||
"""必须显式传入 Session;唯一键认领和 token 条件更新均由数据库保证。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
"""保存调用方事务的独占 Session,不自行提交。"""
|
||||
super().__init__(db)
|
||||
self._session = db
|
||||
|
||||
def get(
|
||||
self, principal_id: str, session_id: str, invocation_id: str,
|
||||
) -> AgentInvocation | None:
|
||||
"""按完整身份读取单条回执,避免不同用户和会话交叉复用。"""
|
||||
return cast(AgentInvocation | None, self._session.execute(
|
||||
select(AgentInvocation).where(
|
||||
AgentInvocation.principal_id == principal_id,
|
||||
AgentInvocation.session_id == session_id,
|
||||
AgentInvocation.invocation_id == invocation_id,
|
||||
)
|
||||
).scalar_one_or_none())
|
||||
|
||||
def find_unresolved(
|
||||
self,
|
||||
principal_id: str,
|
||||
session_id: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments_digest: str,
|
||||
) -> AgentInvocation | None:
|
||||
"""查询最近的相同参数恢复状态,不跨用户、会话或工具复用。"""
|
||||
return cast(AgentInvocation | None, self._session.execute(
|
||||
select(AgentInvocation).where(
|
||||
AgentInvocation.principal_id == principal_id,
|
||||
AgentInvocation.session_id == session_id,
|
||||
AgentInvocation.tool_name == tool_name,
|
||||
AgentInvocation.arguments_digest == arguments_digest,
|
||||
AgentInvocation.status.in_(("running", "unknown")),
|
||||
).order_by(AgentInvocation.id.desc()).limit(1)
|
||||
).scalar_one_or_none())
|
||||
|
||||
def stage_claim(self, record: AgentInvocation) -> bool:
|
||||
"""唯一身份首次插入才获得执行权,并发竞争不会先查后写。"""
|
||||
dialect = self._session.get_bind().dialect.name
|
||||
if dialect == "postgresql":
|
||||
statement = postgres_insert(AgentInvocation)
|
||||
elif dialect == "sqlite":
|
||||
statement = sqlite_insert(AgentInvocation)
|
||||
else:
|
||||
raise RuntimeError(f"不支持的 Agent 调用数据库:{dialect}")
|
||||
values = {
|
||||
column.name: getattr(record, column.name)
|
||||
for column in AgentInvocation.__table__.columns
|
||||
if column.name != "id"
|
||||
}
|
||||
inserted = self._session.execute(
|
||||
statement.values(**values).on_conflict_do_nothing(
|
||||
index_elements=["principal_id", "session_id", "invocation_id"],
|
||||
).returning(AgentInvocation.id)
|
||||
).scalar_one_or_none()
|
||||
return inserted is not None
|
||||
|
||||
def stage_finish(
|
||||
self,
|
||||
record: AgentInvocation,
|
||||
*,
|
||||
claim_token: str,
|
||||
status: str,
|
||||
summary: str,
|
||||
updated_at: str,
|
||||
) -> bool:
|
||||
"""只有当前未收口 owner 可以记录执行或核验结果。"""
|
||||
return bool(execute_dml(
|
||||
self._session,
|
||||
update(AgentInvocation).where(
|
||||
AgentInvocation.id == record.id,
|
||||
AgentInvocation.claim_token == claim_token,
|
||||
AgentInvocation.status.in_(("running", "unknown")),
|
||||
).values(status=status, summary=summary, updated_at=updated_at),
|
||||
execution_options={"synchronize_session": False},
|
||||
))
|
||||
|
||||
def stage_recover(self, *, claim_token: str, summary: str, updated_at: str) -> int:
|
||||
"""冷启动撤销旧执行权,未知状态不会依据时间再次进入运行态。"""
|
||||
return execute_dml(
|
||||
self._session,
|
||||
update(AgentInvocation).where(AgentInvocation.status == "running").values(
|
||||
status="unknown", claim_token=claim_token,
|
||||
summary=summary, updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
def stage_delete_session(self, principal_id: str, session_id: str) -> int:
|
||||
"""仅暂存指定会话的已确认提交和终态回执删除。"""
|
||||
return execute_dml(
|
||||
self._session,
|
||||
terminal_session_delete(principal_id, session_id),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
@@ -26,6 +26,7 @@ from app.db.adapters.agent import (
|
||||
TransactionalAgentTaskRepository,
|
||||
TransactionalPluginDataRepository,
|
||||
)
|
||||
from app.db.adapters.invocation import TransactionalInvocationRepository
|
||||
from app.db.oper.agentchat import AgentChatOper
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.session import SessionFactory, async_session_scope
|
||||
@@ -92,6 +93,7 @@ def compose_agent(
|
||||
transfer_execution=dependencies.transfer_execution,
|
||||
download_history=dependencies.download_history,
|
||||
plugin_data=TransactionalPluginDataRepository(async_session_scope),
|
||||
invocations=TransactionalInvocationRepository(SessionFactory),
|
||||
)
|
||||
return AgentComposition(
|
||||
data=data,
|
||||
|
||||
@@ -665,6 +665,8 @@ async def _initialize_modules() -> HostRuntime:
|
||||
system_config=system_config,
|
||||
dependencies=runtime_dependencies,
|
||||
)
|
||||
if agent_composition.data.invocations is not None:
|
||||
await database_runtime.worker.run(agent_composition.data.invocations.recover_running)
|
||||
security_composition = configure_security_services()
|
||||
runtime_composition = compose_runtime(
|
||||
RuntimeInputs(
|
||||
|
||||
53
database/versions/b2d4f6a8c1e3_3_0_33.py
Normal file
53
database/versions/b2d4f6a8c1e3_3_0_33.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""3.0.33 增加 Agent 写工具调用的持久回执与原子防重身份。"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "b2d4f6a8c1e3"
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _id_column(dialect_name: str) -> sa.Column:
|
||||
"""与宿主当前模型使用同一种主键自增语义。"""
|
||||
if dialect_name == "postgresql":
|
||||
return sa.Column("id", sa.Integer(), sa.Identity(start=1, cycle=True), primary_key=True)
|
||||
return sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""兼容已有 SQLite/PostgreSQL 与 create_all 已建表的全新实例。"""
|
||||
bind = op.get_bind()
|
||||
if "agentinvocation" not in sa.inspect(bind).get_table_names():
|
||||
op.create_table(
|
||||
"agentinvocation",
|
||||
_id_column(bind.dialect.name),
|
||||
sa.Column("principal_id", sa.String(255), nullable=False),
|
||||
sa.Column("session_id", sa.String(255), nullable=False),
|
||||
sa.Column("invocation_id", sa.String(255), nullable=False),
|
||||
sa.Column("tool_name", sa.String(128), nullable=False),
|
||||
sa.Column("arguments_digest", sa.String(64), nullable=False),
|
||||
sa.Column("claim_token", sa.String(32), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False),
|
||||
sa.Column("summary", sa.String(128), nullable=False),
|
||||
sa.Column("created_at", sa.String(), nullable=False),
|
||||
sa.Column("updated_at", sa.String(), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('running', 'succeeded', 'failed', 'pending', 'unknown')",
|
||||
name="ck_agentinvocation_status",
|
||||
),
|
||||
)
|
||||
existing = {index["name"] for index in sa.inspect(bind).get_indexes("agentinvocation")}
|
||||
for name, columns, unique in (
|
||||
("ix_agentinvocation_identity", ["principal_id", "session_id", "invocation_id"], True),
|
||||
("ix_agentinvocation_status_updated_id", ["status", "updated_at", "id"], False),
|
||||
):
|
||||
if name not in existing:
|
||||
op.create_index(name, "agentinvocation", columns, unique=unique)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""仅移除新增调用回执表,不修改会话与工具业务数据。"""
|
||||
if "agentinvocation" in sa.inspect(op.get_bind()).get_table_names():
|
||||
op.drop_table("agentinvocation")
|
||||
@@ -16,9 +16,30 @@ MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务
|
||||
|
||||
设置 `LLM_MAX_TOOLS > 0` 时,Agent 首轮仍只筛选一批相关工具。后续读取 Skill 或发现新问题后,可以通过内部 `search_tools(search, limit)` 按名称、说明或标签搜索当前会话工具目录,并在下一次模型调用获得匹配工具的完整参数定义。
|
||||
|
||||
每次最多返回并启用 8 个匹配工具;没有匹配结果时不会展开全部工具。发现状态仅作用于当前用户请求,新的用户请求重新筛选。`LLM_MAX_TOOLS` 约束首轮筛选数量,显式按需发现可以增加工具。发现不会安装工具、连接新的 MCP 服务或扩大执行权限。
|
||||
每次最多返回 8 个匹配工具,并保留最近 16 个额外工具候选;再次发现会刷新候选优先级。匹配支持工具精确名称和有限中英文能力别名,例如“整理”对应 transfer。没有匹配结果时不会展开全部工具。
|
||||
|
||||
`update_plan` 和 `search_tools` 是 Agent 内部会话能力,不通过外部 MCP 或 `moviepilot tool` 发布。
|
||||
额外工具的参数定义共享最多 4096 tokens、且不超过已知模型窗口 10% 的预算,总输入保留 15% 余量。搜索先报告候选,下一次模型调用根据统一预算明确报告实际启用和未启用原因。初选、常驻和供应商工具保持可用。发现状态仅作用于当前用户请求,新的用户请求重新筛选。`LLM_MAX_TOOLS` 约束首轮筛选数量;发现不会安装工具、连接新的 MCP 服务或扩大执行权限。
|
||||
|
||||
`update_plan`、`search_tools`、`read_tool_result` 和 `get_tool_execution` 是 Agent 内部会话能力,不通过外部 MCP 或 `moviepilot tool` 发布。
|
||||
|
||||
## 工具结果与大结果续读
|
||||
|
||||
宿主统一区分 `succeeded`(成功)、`failed`(失败)、`pending`(任务未完成)、`unknown`(实际结果未知)。明确的业务失败、MCP `isError` 和普通异常会以失败消息返回模型;异步任务已提交不等于任务完成。普通业务对象中的错误说明或下载器状态不会被当成整个工具的失败或执行状态。
|
||||
|
||||
超长结果在当前会话图中临时保存,预览带 `result_id` 和 `next_offset`。Agent 使用 `read_tool_result` 按 Unicode 字符位置续读,无需重新执行原工具。每条完整结果最多 1 MiB,每个图最多 8 条、合计 4 MiB,15 分钟后过期;容量超限、图重建和进程退出也会使编号失效。无法保存的结果明确要求缩小查询范围。不同图线程不能互读,管理员结果在降权后不能续读。结果原文只短期驻留内存,不归档到磁盘或日志。
|
||||
|
||||
## 写工具的持久执行记录
|
||||
|
||||
生产 Agent 在副作用之前向 `agentinvocation` 表提交原子认领。记录只包含用户、会话、工具身份、参数指纹、执行 token、状态和固定宿主文案,不保存原始参数或工具输出。无法认领时不执行写入;相同调用 ID 携带不同参数时拒绝执行。
|
||||
|
||||
- 同一用户请求内,参数规范化后完全相同的 MoviePilot API 写调用共用一个执行身份,防止模型并行或重复调用;明确的新用户请求可再次执行已完成操作。
|
||||
- 普通工具使用原始工具调用 ID 去重,不把重复执行同一条诊断命令误合并为同一业务意图。
|
||||
- 上一轮相同 API 参数仍为运行中或未知时,新请求先核验旧记录,不盲目发起第二次写入。已确认提交的异步请求保存为 pending:同一执行身份不重复提交,新的明确用户意图可以再次提交,pending 不代表外部任务已完成。`get_tool_execution` 可凭宿主返回的 `invocation_id` 查询当前用户、当前会话的最近观测状态。
|
||||
- 当前自动核验支持非敏感设置完整替换:通过 `config.system.get` 只读确认目标值,匹配后收口为成功并跳过重复写入。敏感、未匹配、列表追加及其他无法确定的副作用保持未知,不允许模型自行把它改成成功或“未执行”。
|
||||
- 写入请求发出后的传输中断、超时和响应正文不可用保留未知;调用前参数错误和明确 HTTP 拒绝仍为失败。
|
||||
- 冷启动将未收口的 running 转为 unknown,并更换 token。旧执行者不能覆盖新恢复状态,也不会仅因时间过去而自动重放。
|
||||
|
||||
成功、失败和已确认提交的回执随会话删除回收;没有聊天记录的后台同类回执按共享会话保留期清理。pending 是已确认提交的历史,实际异步任务由原任务系统管理,不是待执行队列。`DATA_CLEANUP_ENABLE` 和保留期 0 的禁用语义保持一致,running/unknown 恢复状态不会按时间删除。这些机制不构成任意外部系统的“恰好一次”事务保证。
|
||||
|
||||
## 异常和取消后的继续
|
||||
|
||||
@@ -26,7 +47,7 @@ MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务
|
||||
|
||||
用户随后说“继续”时,Agent 可以读取已有结果和计划,从剩余工作继续。下载、修改配置、删除文件等操作如缺少回执,应先只读核验实际状态,再决定是否重试。取消仍会终止当前轮次,不会自动重放工具。
|
||||
|
||||
快照保存采用有界等待。进程强制退出、持久化失败,或工具在中断后才完成的外部副作用,不能保证被该快照记录。它不是外部操作的事务日志,也不保证恰好执行一次。
|
||||
聊天快照保存采用有界等待。进程强制退出、持久化失败,或工具在中断后才完成的外部副作用,不能保证被聊天快照记录;写工具通过上面的持久执行记录保留恢复边界。
|
||||
|
||||
## 子代理连续工作
|
||||
|
||||
@@ -36,6 +57,6 @@ MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务
|
||||
|
||||
## 验证范围
|
||||
|
||||
自动化测试使用脚本模型和真实 LangGraph 执行图,覆盖工具发现后的实际调用、并行发现合并、计划更新和消息序列化恢复、执行中断以及连续两轮子代理委派。测试不调用付费模型或外部服务。
|
||||
自动化测试使用脚本模型和真实 LangGraph 执行图,覆盖工具发现和预算、计划恢复、结果状态、分页续读、持久认领和只读核验、执行中断以及连续两轮子代理委派。SQLite 测试覆盖并发认领、token 隔离、迁移和清理。测试不调用付费模型或外部服务。
|
||||
|
||||
这些机制提高执行可靠性;真实任务的理解、规划质量和成功率仍取决于所选模型、上下文窗口、工具权限及外部服务状态。模型能力需要用真实业务场景单独评估。
|
||||
|
||||
@@ -756,8 +756,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 987 |
|
||||
| 内部导入边 | 8,395 |
|
||||
| Python 模块 | 995 |
|
||||
| 内部导入边 | 8,455 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
@@ -767,7 +767,7 @@ flowchart LR
|
||||
| Model/Oper 自动事务与自建 Session | 0 |
|
||||
| 组合根外 `SystemConfigOper()` | 0 |
|
||||
|
||||
整理失败反馈由 `app.application.transfer.feedback` 集中投影,冷启动各入口增加这一个纯应用模块:`app.startup.lifecycle` 为 542、`app.factory` 为 554、`app.main` 为 556。性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
|
||||
整理失败反馈由 `app.application.transfer.feedback` 集中投影;Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。当前 `app.startup.lifecycle` 为 546、`app.factory` 为 558、`app.main` 为 560。性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
|
||||
|
||||
架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event
|
||||
语义门禁,`Check host architecture snapshot` 再执行快照测试及一次
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 987 / 8,395 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复与 Agent 计划模块的受控依赖 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 995 / 8,455 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复与 Agent 计划模块的受控依赖 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -102,8 +102,8 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||
| 全量 mypy 历史债务 | 9,413 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 535 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 全量 mypy 历史债务 | 9,409 / 511 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 534 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
### 3.3 热点文件
|
||||
|
||||
@@ -35,7 +35,7 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K
|
||||
|
||||
### 动态插件工具
|
||||
|
||||
内置 Agent 的 `update_plan`、`search_tools` 为会话中间件工具,不通过外部 `tools/list` 发布;它们只维护计划或发现当前目录内的工具,不能授予业务操作权限。使用与恢复语义见 [Agent 复杂任务执行与恢复](agent.md)。
|
||||
内置 Agent 的 `update_plan`、`search_tools`、`read_tool_result`、`get_tool_execution` 为会话中间件工具,不通过外部 `tools/list` 发布;它们维护计划、发现工具、续读结果或查询执行回执,不能授予业务操作权限。使用与恢复语义见 [Agent 复杂任务执行与恢复](agent.md)。
|
||||
|
||||
`tools/list` 会同时返回 MoviePilot 内置工具和已启用插件通过 `get_agent_tools()` 声明的工具。插件启动、停止、重载或配置生效后,MCP 工具管理器会在下一次列出或调用工具时按注册表版本惰性刷新,避免继续暴露已移除的工具或遗漏新工具。
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ to make the directory tree look symmetrical.
|
||||
| `app/application/music/` | Multi-source music catalog orchestration |
|
||||
| `app/application/chain/` | Injectable Chain runtime capabilities: `context.py` owns the typed runtime and persistence dependency aggregate, and `events.py` owns durable event write contracts plus replayable payload conversion |
|
||||
| `app/application/agent.py` | Agent orchestration facade and typed `AgentDataContext`; startup injects one explicit data context into the manager, memory, tool and scheduler owners without a process-wide persistence locator |
|
||||
| `app/application/invocation.py` | Frozen Agent write-call identity, claim and receipt contracts; the injected repository provides atomic claim, fenced settlement and unresolved-state reads, while `db/adapters/invocation.py` owns short transactions and cold-start recovery is invoked by startup |
|
||||
| `app/application/network.py` | System network-test target catalog, immutable public/private projections, URL and redirect admission, response validation and the injected transport Port; startup owns concrete HTTP Adapter assembly |
|
||||
| `app/application/outbox.py` | Durable intent, transaction-only stager, short-transaction dispatch store, claim fencing and structured post-commit result contracts |
|
||||
| `app/application/transfer/` | Durable transfer use cases: `workflow.py` owns admission/planning/queue behavior; `execution.py` owns stable operation identity, step/checkpoint state, retry/manual-review commands and terminal-settlement DTOs; `recovery.py` owns failed/corrupt task cleanup and history detachment through the execution repository; `history.py` projects history write fields and file fingerprints; `feedback.py` owns failure stages, notification snapshots and message text, while Chain owns notification delivery and cleanup side effects |
|
||||
@@ -88,6 +89,10 @@ to make the directory tree look symmetrical.
|
||||
| `app/agent/orchestrator.py` | One `MoviePilotAgent` execution instance: prompt/tool/middleware assembly, model invocation, streaming and per-agent state |
|
||||
| `app/agent/middleware/plan.py` | Current task objective, step status and evidence in graph state; sanitized snapshots travel through existing message persistence and never authorize tool effects |
|
||||
| `app/agent/middleware/selection.py` | First-turn tool selection and bounded, on-demand discovery within the same authorized catalog; discovered tool names remain local to the current user request |
|
||||
| `app/agent/middleware/invocation.py` | Claims write executions through the injected Application port; owns per-turn API deduplication, durable receipt projection and narrowly scoped read-only reconciliation |
|
||||
| `app/agent/middleware/output.py` | Bounded, expiring in-memory tool output and thread-scoped pagination; never persists raw tool results |
|
||||
| `app/agent/tools/result.py` | Pure interpretation of explicit tool result protocols into succeeded, failed, pending and unknown outcomes |
|
||||
| `app/agent/api/arguments.py` | Canonical API request fingerprints from the generated operation schema and the executor's GET projection; no endpoint imports or live discovery |
|
||||
| `app/agent/shell.py` | Agent command-shell selection and subprocess text-encoding policy; Windows prefers Git Bash, then PowerShell 7, while POSIX keeps native shell/PTY behavior |
|
||||
| `app/agent/policy/api.py` | Fixed `moviepilot_api` operation registry, HTTP route templates and per-operation authorization/effect policy; no arbitrary URL or method input |
|
||||
| `app/agent/policy/mcp.py` | Generated external MCP input-contract builder for the fixed API registry; owns exact English oneOf parameter projection, not runtime authorization |
|
||||
|
||||
@@ -205,6 +205,20 @@ shared `DATA_CLEANUP_ENABLE` policy when it has a safe time boundary:
|
||||
- `agentchat` removes only expired sessions not referenced by an `agenttask`;
|
||||
`agenttaskrun` removes only expired terminal runs that are neither running nor
|
||||
the task's current `last_run_id`.
|
||||
- `agentinvocation` stores only write-call identity, argument digests and fixed
|
||||
host status summaries. Successful/failed receipts and pending receipts that
|
||||
confirm an asynchronous submission are deleted in the same transaction as
|
||||
their owning chat, including shared chat retention cleanup. Pending means
|
||||
submission is confirmed while downstream completion has not been observed;
|
||||
it is history rather than a queue to execute. Replaying the same invocation
|
||||
never repeats submission, but a new user intent may submit again.
|
||||
These history receipts without a chat (including background task calls) use the
|
||||
same Agent chat retention period, based on their UTC settlement timestamp;
|
||||
both the shared cleanup switch and a zero-day retention disable this cleanup.
|
||||
Running/unknown receipts are recovery state and have no age-based deletion;
|
||||
they also protect their chat from automatic retention cleanup. Cold startup
|
||||
changes running receipts to unknown and rotates their fencing token; elapsed
|
||||
time never grants permission to repeat the write.
|
||||
- `outboxmessage` has separate completed and dead-letter retention periods;
|
||||
pending and processing intents are recovery state and are never age-deleted.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: database-operation
|
||||
version: 6
|
||||
version: 7
|
||||
description: >-
|
||||
Use this skill when you need to inspect, query, maintain, or carefully modify
|
||||
the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper,
|
||||
@@ -122,6 +122,12 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
|
||||
- Write boundary: Owned by the Agent conversation service; do not rewrite message JSON, counters, or ownership.
|
||||
- Columns: `id`, `session_id`, `client_session_id`, `user_id`, `username`, `channel`, `source`, `original_chat_id`, `title`, `preview`, `agent_messages`, `display_messages`, `message_count`, `created_at`, `updated_at`
|
||||
|
||||
### `agentinvocation`
|
||||
- Purpose: Durable Agent write invocation identity and last observed outcome, including confirmed asynchronous submission.
|
||||
- Useful queries: Inspect an exact principal and session's running, unknown, pending, succeeded, or failed receipts; compare timestamps when diagnosing an interrupted write.
|
||||
- Write boundary: Owned by the host's atomic claim and reconciliation path. Never change IDs, fingerprints, claim tokens, or statuses to bypass duplicate protection. Running and unknown records are recovery state; ordinary retention does not delete them. Pending means submission was confirmed, not that the external task finished.
|
||||
- Columns: `id`, `principal_id`, `session_id`, `invocation_id`, `tool_name`, `arguments_digest`, `claim_token`, `status`, `summary`, `created_at`, `updated_at`. Raw arguments and tool output are not stored here.
|
||||
|
||||
### `agenttask`
|
||||
- Purpose: Stores one-shot or recurring Agent task definitions, triggers, and the latest execution summary.
|
||||
- Useful queries: Inspecting task ownership, enablement, cron/run_at settings, and the latest result.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 25
|
||||
version: 26
|
||||
description: >-
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
@@ -129,6 +129,14 @@ Call the gateway with this shape:
|
||||
`music_type=recording|album|artist`; an artist is browse-only.
|
||||
- Treat `success=false`, HTTP error data, empty results, and validation errors as
|
||||
real outcomes. Do not claim success without checking the response.
|
||||
- Respect an explicit `execution_outcome`: `pending` is accepted but unfinished,
|
||||
while `unknown` means a write may have happened. Do not repeat an unknown write
|
||||
or change defaults merely to evade duplicate protection. In the built-in Agent,
|
||||
use `get_tool_execution` with the returned invocation ID; the host can reconcile
|
||||
supported non-sensitive setting replacements through a read-only check.
|
||||
- When a built-in Agent preview contains `result_id` and `next_offset`, use
|
||||
`read_tool_result` for the next page instead of repeating the operation. These
|
||||
receipt and result tools are internal to the Agent, not external MCP tools.
|
||||
|
||||
## Collection Counts And Pagination
|
||||
|
||||
|
||||
@@ -1074,8 +1074,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8395,
|
||||
"edge_sha256": "0af2b2a8f5616e192c9e8db19f70b942513a34cf988170c1a43e13ce7c59b066",
|
||||
"edge_count": 8455,
|
||||
"edge_sha256": "d989cabfc34fcff77f417e01f671915adb188f00d7beb00deb82fdff2d135bdf",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -1289,12 +1289,17 @@
|
||||
"app.adapters.web.security.access -> app.runtime.settings",
|
||||
"app.adapters.web.security.access -> app.schemas",
|
||||
"app.adapters.web.security.access -> app.schemas.token",
|
||||
"app.agent.api.arguments -> app.agent",
|
||||
"app.agent.api.arguments -> app.agent.policy",
|
||||
"app.agent.api.arguments -> app.agent.policy.api",
|
||||
"app.agent.api.executor -> app.adapters",
|
||||
"app.agent.api.executor -> app.adapters.network",
|
||||
"app.agent.api.executor -> app.adapters.network.http",
|
||||
"app.agent.api.executor -> app.agent",
|
||||
"app.agent.api.executor -> app.agent.policy",
|
||||
"app.agent.api.executor -> app.agent.policy.api",
|
||||
"app.agent.api.executor -> app.agent.policy.contracts",
|
||||
"app.agent.api.executor -> app.agent.policy.sanitizer",
|
||||
"app.agent.api.executor -> app.application",
|
||||
"app.agent.api.executor -> app.application.security",
|
||||
"app.agent.api.executor -> app.application.security.token",
|
||||
@@ -1447,6 +1452,21 @@
|
||||
"app.agent.middleware.config -> app.agent.middleware",
|
||||
"app.agent.middleware.config -> app.agent.middleware.utils",
|
||||
"app.agent.middleware.config -> app.agent.runtime",
|
||||
"app.agent.middleware.invocation -> app.agent",
|
||||
"app.agent.middleware.invocation -> app.agent.policy",
|
||||
"app.agent.middleware.invocation -> app.agent.policy.api",
|
||||
"app.agent.middleware.invocation -> app.agent.policy.contracts",
|
||||
"app.agent.middleware.invocation -> app.agent.policy.sanitizer",
|
||||
"app.agent.middleware.invocation -> app.agent.tools",
|
||||
"app.agent.middleware.invocation -> app.agent.tools.base",
|
||||
"app.agent.middleware.invocation -> app.agent.tools.impl",
|
||||
"app.agent.middleware.invocation -> app.agent.tools.impl.api",
|
||||
"app.agent.middleware.invocation -> app.agent.tools.impl.mcp",
|
||||
"app.agent.middleware.invocation -> app.agent.tools.result",
|
||||
"app.agent.middleware.invocation -> app.application",
|
||||
"app.agent.middleware.invocation -> app.application.invocation",
|
||||
"app.agent.middleware.invocation -> app.runtime",
|
||||
"app.agent.middleware.invocation -> app.runtime.log",
|
||||
"app.agent.middleware.jobs -> app.agent",
|
||||
"app.agent.middleware.jobs -> app.agent.middleware",
|
||||
"app.agent.middleware.jobs -> app.agent.middleware.utils",
|
||||
@@ -1457,6 +1477,12 @@
|
||||
"app.agent.middleware.memory -> app.agent.middleware.utils",
|
||||
"app.agent.middleware.memory -> app.runtime",
|
||||
"app.agent.middleware.memory -> app.runtime.log",
|
||||
"app.agent.middleware.output -> app.agent",
|
||||
"app.agent.middleware.output -> app.agent.policy",
|
||||
"app.agent.middleware.output -> app.agent.policy.contracts",
|
||||
"app.agent.middleware.output -> app.agent.tools",
|
||||
"app.agent.middleware.output -> app.agent.tools.base",
|
||||
"app.agent.middleware.output -> app.agent.tools.result",
|
||||
"app.agent.middleware.plan -> app.agent",
|
||||
"app.agent.middleware.plan -> app.agent.middleware",
|
||||
"app.agent.middleware.plan -> app.agent.middleware.utils",
|
||||
@@ -1469,13 +1495,18 @@
|
||||
"app.agent.middleware.policy -> app.agent.policy.contracts",
|
||||
"app.agent.middleware.policy -> app.agent.policy.orchestrator",
|
||||
"app.agent.middleware.policy -> app.agent.policy.registry",
|
||||
"app.agent.middleware.policy -> app.agent.policy.sanitizer",
|
||||
"app.agent.middleware.policy -> app.agent.tools",
|
||||
"app.agent.middleware.policy -> app.agent.tools.catalog",
|
||||
"app.agent.middleware.policy -> app.agent.tools.impl",
|
||||
"app.agent.middleware.policy -> app.agent.tools.impl.api",
|
||||
"app.agent.middleware.policy -> app.agent.tools.result",
|
||||
"app.agent.middleware.selection -> app.agent",
|
||||
"app.agent.middleware.selection -> app.agent.llm",
|
||||
"app.agent.middleware.selection -> app.agent.llm.helper",
|
||||
"app.agent.middleware.selection -> app.agent.middleware",
|
||||
"app.agent.middleware.selection -> app.agent.middleware.usage",
|
||||
"app.agent.middleware.selection -> app.agent.middleware.utils",
|
||||
"app.agent.middleware.selection -> app.agent.tools",
|
||||
"app.agent.middleware.selection -> app.agent.tools.tags",
|
||||
"app.agent.middleware.selection -> app.runtime",
|
||||
@@ -1526,8 +1557,10 @@
|
||||
"app.agent.orchestrator -> app.agent.middleware",
|
||||
"app.agent.orchestrator -> app.agent.middleware.activity",
|
||||
"app.agent.orchestrator -> app.agent.middleware.config",
|
||||
"app.agent.orchestrator -> app.agent.middleware.invocation",
|
||||
"app.agent.orchestrator -> app.agent.middleware.jobs",
|
||||
"app.agent.orchestrator -> app.agent.middleware.memory",
|
||||
"app.agent.orchestrator -> app.agent.middleware.output",
|
||||
"app.agent.orchestrator -> app.agent.middleware.patching",
|
||||
"app.agent.orchestrator -> app.agent.middleware.plan",
|
||||
"app.agent.orchestrator -> app.agent.middleware.policy",
|
||||
@@ -1574,6 +1607,8 @@
|
||||
"app.agent.policy.orchestrator -> app.agent.policy.contracts",
|
||||
"app.agent.policy.orchestrator -> app.agent.policy.registry",
|
||||
"app.agent.policy.orchestrator -> app.agent.policy.sanitizer",
|
||||
"app.agent.policy.orchestrator -> app.agent.tools",
|
||||
"app.agent.policy.orchestrator -> app.agent.tools.result",
|
||||
"app.agent.policy.orchestrator -> app.runtime",
|
||||
"app.agent.policy.orchestrator -> app.runtime.log",
|
||||
"app.agent.policy.registry -> app.agent",
|
||||
@@ -1657,6 +1692,7 @@
|
||||
"app.agent.tools.base -> app.agent.policy",
|
||||
"app.agent.tools.base -> app.agent.policy.sanitizer",
|
||||
"app.agent.tools.base -> app.agent.tools",
|
||||
"app.agent.tools.base -> app.agent.tools.result",
|
||||
"app.agent.tools.base -> app.agent.tools.tags",
|
||||
"app.agent.tools.base -> app.application",
|
||||
"app.agent.tools.base -> app.application.agent",
|
||||
@@ -1727,6 +1763,7 @@
|
||||
"app.agent.tools.impl.agent_task -> app.runtime.settings",
|
||||
"app.agent.tools.impl.api -> app.agent",
|
||||
"app.agent.tools.impl.api -> app.agent.api",
|
||||
"app.agent.tools.impl.api -> app.agent.api.arguments",
|
||||
"app.agent.tools.impl.api -> app.agent.api.executor",
|
||||
"app.agent.tools.impl.api -> app.agent.policy",
|
||||
"app.agent.tools.impl.api -> app.agent.policy.api",
|
||||
@@ -1734,6 +1771,7 @@
|
||||
"app.agent.tools.impl.api -> app.agent.policy.sanitizer",
|
||||
"app.agent.tools.impl.api -> app.agent.tools",
|
||||
"app.agent.tools.impl.api -> app.agent.tools.base",
|
||||
"app.agent.tools.impl.api -> app.agent.tools.result",
|
||||
"app.agent.tools.impl.api -> app.agent.tools.tags",
|
||||
"app.agent.tools.impl.api -> app.application",
|
||||
"app.agent.tools.impl.api -> app.application.security",
|
||||
@@ -1891,6 +1929,9 @@
|
||||
"app.agent.tools.manager -> app.application.plugin.runtime",
|
||||
"app.agent.tools.manager -> app.runtime",
|
||||
"app.agent.tools.manager -> app.runtime.log",
|
||||
"app.agent.tools.result -> app.agent",
|
||||
"app.agent.tools.result -> app.agent.policy",
|
||||
"app.agent.tools.result -> app.agent.policy.contracts",
|
||||
"app.agent.web -> app.agent",
|
||||
"app.agent.web -> app.agent.callback",
|
||||
"app.agent.web -> app.agent.loader",
|
||||
@@ -3028,6 +3069,7 @@
|
||||
"app.application.agent -> app.application",
|
||||
"app.application.agent -> app.application.agenttask",
|
||||
"app.application.agent -> app.application.history",
|
||||
"app.application.agent -> app.application.invocation",
|
||||
"app.application.agent -> app.application.messaging",
|
||||
"app.application.agent -> app.application.messaging.chat",
|
||||
"app.application.agent -> app.application.plugin",
|
||||
@@ -5494,6 +5536,14 @@
|
||||
"app.db.adapters.history.transfer -> app.schemas",
|
||||
"app.db.adapters.history.transfer -> app.schemas.media",
|
||||
"app.db.adapters.history.transfer -> app.schemas.types",
|
||||
"app.db.adapters.invocation -> app.application",
|
||||
"app.db.adapters.invocation -> app.application.invocation",
|
||||
"app.db.adapters.invocation -> app.db",
|
||||
"app.db.adapters.invocation -> app.db.models",
|
||||
"app.db.adapters.invocation -> app.db.models.agentinvocation",
|
||||
"app.db.adapters.invocation -> app.db.oper",
|
||||
"app.db.adapters.invocation -> app.db.oper.agentinvocation",
|
||||
"app.db.adapters.invocation -> app.db.uow",
|
||||
"app.db.adapters.mediaserver -> app.application",
|
||||
"app.db.adapters.mediaserver -> app.application.mediaserver",
|
||||
"app.db.adapters.mediaserver -> app.db",
|
||||
@@ -5652,6 +5702,7 @@
|
||||
"app.db.maintenance -> app.db.base",
|
||||
"app.db.maintenance -> app.db.models",
|
||||
"app.db.maintenance -> app.db.models.agentchat",
|
||||
"app.db.maintenance -> app.db.models.agentinvocation",
|
||||
"app.db.maintenance -> app.db.models.agenttask",
|
||||
"app.db.maintenance -> app.db.models.agenttaskrun",
|
||||
"app.db.maintenance -> app.db.models.downloadfailure",
|
||||
@@ -5670,6 +5721,8 @@
|
||||
"app.db.models._identity -> app.schemas.media",
|
||||
"app.db.models.agentchat -> app.db",
|
||||
"app.db.models.agentchat -> app.db.base",
|
||||
"app.db.models.agentinvocation -> app.db",
|
||||
"app.db.models.agentinvocation -> app.db.base",
|
||||
"app.db.models.agenttask -> app.db",
|
||||
"app.db.models.agenttask -> app.db.base",
|
||||
"app.db.models.agenttaskrun -> app.db",
|
||||
@@ -5752,8 +5805,14 @@
|
||||
"app.db.oper.agentchat -> app.db.base",
|
||||
"app.db.oper.agentchat -> app.db.models",
|
||||
"app.db.oper.agentchat -> app.db.models.agentchat",
|
||||
"app.db.oper.agentchat -> app.db.oper",
|
||||
"app.db.oper.agentchat -> app.db.oper.agentinvocation",
|
||||
"app.db.oper.agentchat -> app.schemas",
|
||||
"app.db.oper.agentchat -> app.schemas.types",
|
||||
"app.db.oper.agentinvocation -> app.db",
|
||||
"app.db.oper.agentinvocation -> app.db.base",
|
||||
"app.db.oper.agentinvocation -> app.db.models",
|
||||
"app.db.oper.agentinvocation -> app.db.models.agentinvocation",
|
||||
"app.db.oper.agenttask -> app.db",
|
||||
"app.db.oper.agenttask -> app.db.base",
|
||||
"app.db.oper.agenttask -> app.db.models",
|
||||
@@ -8584,6 +8643,7 @@
|
||||
"app.startup.composition.agent -> app.db",
|
||||
"app.startup.composition.agent -> app.db.adapters",
|
||||
"app.startup.composition.agent -> app.db.adapters.agent",
|
||||
"app.startup.composition.agent -> app.db.adapters.invocation",
|
||||
"app.startup.composition.agent -> app.db.oper",
|
||||
"app.startup.composition.agent -> app.db.oper.agentchat",
|
||||
"app.startup.composition.agent -> app.db.oper.systemconfig",
|
||||
@@ -9473,7 +9533,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 987,
|
||||
"module_count": 995,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -9527,6 +9587,7 @@
|
||||
"app.adapters.web.security.access",
|
||||
"app.agent",
|
||||
"app.agent.api",
|
||||
"app.agent.api.arguments",
|
||||
"app.agent.api.executor",
|
||||
"app.agent.callback",
|
||||
"app.agent.capabilities",
|
||||
@@ -9551,8 +9612,10 @@
|
||||
"app.agent.middleware",
|
||||
"app.agent.middleware.activity",
|
||||
"app.agent.middleware.config",
|
||||
"app.agent.middleware.invocation",
|
||||
"app.agent.middleware.jobs",
|
||||
"app.agent.middleware.memory",
|
||||
"app.agent.middleware.output",
|
||||
"app.agent.middleware.patching",
|
||||
"app.agent.middleware.plan",
|
||||
"app.agent.middleware.policy",
|
||||
@@ -9606,6 +9669,7 @@
|
||||
"app.agent.tools.impl.service",
|
||||
"app.agent.tools.impl.write_file",
|
||||
"app.agent.tools.manager",
|
||||
"app.agent.tools.result",
|
||||
"app.agent.tools.tags",
|
||||
"app.agent.web",
|
||||
"app.api",
|
||||
@@ -9709,6 +9773,7 @@
|
||||
"app.application.history",
|
||||
"app.application.historymutation",
|
||||
"app.application.image",
|
||||
"app.application.invocation",
|
||||
"app.application.maintenance",
|
||||
"app.application.mediaserver",
|
||||
"app.application.messaging",
|
||||
@@ -9929,6 +9994,7 @@
|
||||
"app.db.adapters.history",
|
||||
"app.db.adapters.history.download",
|
||||
"app.db.adapters.history.transfer",
|
||||
"app.db.adapters.invocation",
|
||||
"app.db.adapters.mediaserver",
|
||||
"app.db.adapters.outbox",
|
||||
"app.db.adapters.pluginidentity",
|
||||
@@ -9954,6 +10020,7 @@
|
||||
"app.db.models._constraints",
|
||||
"app.db.models._identity",
|
||||
"app.db.models.agentchat",
|
||||
"app.db.models.agentinvocation",
|
||||
"app.db.models.agenttask",
|
||||
"app.db.models.agenttaskrun",
|
||||
"app.db.models.downloadfailure",
|
||||
@@ -9982,6 +10049,7 @@
|
||||
"app.db.models.workflow",
|
||||
"app.db.oper",
|
||||
"app.db.oper.agentchat",
|
||||
"app.db.oper.agentinvocation",
|
||||
"app.db.oper.agenttask",
|
||||
"app.db.oper.downloadfailure",
|
||||
"app.db.oper.downloadhistory",
|
||||
|
||||
@@ -175,10 +175,6 @@
|
||||
"app/agent/middleware/patching.py": {
|
||||
"misc": 1
|
||||
},
|
||||
"app/agent/middleware/policy.py": {
|
||||
"misc": 2,
|
||||
"union-attr": 1
|
||||
},
|
||||
"app/agent/middleware/selection.py": {
|
||||
"misc": 2,
|
||||
"no-any-return": 1,
|
||||
@@ -217,9 +213,6 @@
|
||||
"union-attr": 3,
|
||||
"var-annotated": 1
|
||||
},
|
||||
"app/agent/policy/orchestrator.py": {
|
||||
"no-any-return": 1
|
||||
},
|
||||
"app/agent/policy/sanitizer.py": {
|
||||
"var-annotated": 1
|
||||
},
|
||||
|
||||
@@ -608,9 +608,6 @@
|
||||
"tests/test_agent_tokens_events.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_agent_tool_result_policy.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_agent_tool_timeouts.py": {
|
||||
"I001": 1
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"repeat": 3,
|
||||
"targets": {
|
||||
"app.startup.lifecycle": {
|
||||
"loaded_app_module_count": 542,
|
||||
"loaded_app_module_count": 546,
|
||||
"max_ms": 1293.338,
|
||||
"median_ms": 1156.239,
|
||||
"min_ms": 1102.806,
|
||||
@@ -17,7 +17,7 @@
|
||||
]
|
||||
},
|
||||
"app.factory": {
|
||||
"loaded_app_module_count": 554,
|
||||
"loaded_app_module_count": 558,
|
||||
"max_ms": 1127.911,
|
||||
"median_ms": 1122.382,
|
||||
"min_ms": 1119.221,
|
||||
@@ -28,7 +28,7 @@
|
||||
]
|
||||
},
|
||||
"app.main": {
|
||||
"loaded_app_module_count": 556,
|
||||
"loaded_app_module_count": 560,
|
||||
"max_ms": 1188.652,
|
||||
"median_ms": 1183.509,
|
||||
"min_ms": 1174.522,
|
||||
|
||||
@@ -8,6 +8,8 @@ from app.agent.contracts import ReplyMode
|
||||
from app.agent.manager import AgentManager
|
||||
from app.agent.memory import memory_manager
|
||||
from app.agent.middleware.activity import QUERY_ACTIVITY_LOG_TOOL_NAME
|
||||
from app.agent.middleware.invocation import GET_TOOL_EXECUTION_NAME, InvocationMiddleware
|
||||
from app.agent.middleware.output import READ_TOOL_RESULT_NAME, ToolOutputMiddleware
|
||||
from app.agent.middleware.plan import PLAN_TOOL_NAME, PlanMiddleware
|
||||
from app.agent.middleware.selection import TOOL_DISCOVERY_NAME, ToolSelectorMiddleware
|
||||
from app.agent.middleware.skills import SKILL_TOOL_NAME
|
||||
@@ -97,15 +99,19 @@ def _capture_tool_selector(captured, **kwargs):
|
||||
|
||||
|
||||
def _assert_internal_tool_registration(created, captured):
|
||||
"""核实计划与发现工具同时注册到严格目录和强制保留集合。"""
|
||||
"""核实内部计划、续读和发现工具的目录身份、常驻筛选及外层顺序。"""
|
||||
middlewares = created["middleware"]
|
||||
plan = next(item for item in middlewares if isinstance(item, PlanMiddleware))
|
||||
output = next(item for item in middlewares if isinstance(item, ToolOutputMiddleware))
|
||||
selector = captured["selector"]
|
||||
policy = middlewares[0]
|
||||
assert policy.name == "AgentPolicyMiddleware"
|
||||
assert middlewares[1] is output
|
||||
assert not any(isinstance(item, InvocationMiddleware) for item in middlewares)
|
||||
assert policy.catalog.resolve_unique(GET_TOOL_EXECUTION_NAME) is None
|
||||
assert captured["enable_discovery"] is True
|
||||
assert {PLAN_TOOL_NAME, TOOL_DISCOVERY_NAME} <= set(selector.always_include)
|
||||
for tool in [*plan.tools, *selector.tools]:
|
||||
assert {PLAN_TOOL_NAME, READ_TOOL_RESULT_NAME, TOOL_DISCOVERY_NAME} <= set(selector.always_include)
|
||||
for tool in [*plan.tools, *output.tools, *selector.tools]:
|
||||
assert policy.catalog.resolve_unique(tool.name).tool is tool
|
||||
assert tool in selector.selection_tools
|
||||
assert middlewares.index(plan) < middlewares.index(selector)
|
||||
@@ -453,6 +459,7 @@ class TestAgentBackgroundOutput:
|
||||
|
||||
assert [getattr(item, "name", item) for item in created["middleware"]] == [
|
||||
"AgentPolicyMiddleware",
|
||||
"ToolOutputMiddleware",
|
||||
"skills",
|
||||
"jobs",
|
||||
"runtime",
|
||||
@@ -567,6 +574,7 @@ class TestAgentBackgroundOutput:
|
||||
|
||||
assert [getattr(item, "name", item) for item in created["middleware"]] == [
|
||||
"AgentPolicyMiddleware",
|
||||
"ToolOutputMiddleware",
|
||||
"skills",
|
||||
"jobs",
|
||||
"runtime",
|
||||
@@ -771,6 +779,7 @@ class TestAgentBackgroundOutput:
|
||||
|
||||
assert [getattr(item, "name", item) for item in created["middleware"]] == [
|
||||
"AgentPolicyMiddleware",
|
||||
"ToolOutputMiddleware",
|
||||
"skills",
|
||||
"jobs",
|
||||
"runtime",
|
||||
|
||||
@@ -10,12 +10,18 @@ from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from app.agent.contracts import ReplyMode
|
||||
from app.agent.mcp import AgentMcpToolSpec
|
||||
from app.agent.middleware.invocation import GET_TOOL_EXECUTION_NAME
|
||||
from app.agent.middleware.output import READ_TOOL_RESULT_NAME
|
||||
from app.agent.middleware.plan import PLAN_TOOL_NAME
|
||||
from app.agent.middleware.selection import TOOL_DISCOVERY_NAME
|
||||
from app.agent.orchestrator import MoviePilotAgent, _CompiledAgentBundle
|
||||
from app.agent.tools.catalog import (
|
||||
ToolCatalogSnapshot,
|
||||
ToolIdentityAmbiguousError,
|
||||
)
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.impl.mcp import create_external_mcp_tools
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
from app.runtime.config import settings
|
||||
from app.schemas.agent import AgentMcpServerConfig
|
||||
|
||||
@@ -26,6 +32,27 @@ def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_invocations", [False, True])
|
||||
def test_internal_graph_tools_do_not_enter_external_mcp_catalog(with_invocations):
|
||||
"""内部计划、结果续读和回执查询只属于会话图,不进入 HTTP/MCP 工具目录。"""
|
||||
data = SimpleNamespace(invocations=object()) if with_invocations else None
|
||||
with (
|
||||
patch("app.agent.tools.factory._get_plugin_agent_tools", return_value=[]),
|
||||
patch("app.agent.tools.factory.AgentCapabilityManager.supports_audio_output", return_value=False),
|
||||
):
|
||||
tools = MoviePilotToolFactory.create_tools(
|
||||
session_id="mcp-isolation", user_id="owner", data=data,
|
||||
)
|
||||
manager = MoviePilotToolsManager(session_id="mcp-isolation", user_id="owner", is_admin=True, data=data)
|
||||
manager.tools = tools
|
||||
published_names = {definition.name for definition in manager.list_tools()}
|
||||
assert "moviepilot_api" in published_names
|
||||
assert "agent_task" in published_names
|
||||
assert {PLAN_TOOL_NAME, READ_TOOL_RESULT_NAME, GET_TOOL_EXECUTION_NAME, TOOL_DISCOVERY_NAME}.isdisjoint(published_names)
|
||||
for name in (PLAN_TOOL_NAME, READ_TOOL_RESULT_NAME, GET_TOOL_EXECUTION_NAME, TOOL_DISCOVERY_NAME):
|
||||
assert manager.get_strict_tool(name) is None
|
||||
|
||||
|
||||
class _FakeGraphState:
|
||||
"""提供 LangGraph get_state 测试替身。"""
|
||||
|
||||
|
||||
487
tests/test_agent_invocation_middleware.py
Normal file
487
tests/test_agent_invocation_middleware.py
Normal file
@@ -0,0 +1,487 @@
|
||||
"""真实 Agent 图与 SQLite 回执联合验证写工具防重和未知结果核验。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.tools import StructuredTool
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from pydantic import Field
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.agent.mcp import AgentMcpToolSpec
|
||||
from app.agent.middleware.invocation import GET_TOOL_EXECUTION_NAME, InvocationMiddleware
|
||||
from app.agent.middleware.policy import AgentPolicyMiddleware
|
||||
from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
|
||||
from app.agent.tools.base import ToolExecutionTimeoutError
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.agent.tools.impl.mcp import McpExternalTool, create_external_mcp_tools
|
||||
from app.application.invocation import InvocationIdentity
|
||||
from app.db.adapters.invocation import TransactionalInvocationRepository
|
||||
from app.db.models.agentinvocation import AgentInvocation
|
||||
from app.schemas.agent import AgentMcpServerConfig
|
||||
|
||||
WRITE_ARGUMENTS = {"operation_id": "download.add", "body": {
|
||||
"torrent_in": {"title": "Test Movie", "enclosure": "https://example.invalid/test.torrent"},
|
||||
}}
|
||||
|
||||
|
||||
class _ScriptModel(FakeMessagesListChatModel):
|
||||
"""按固定响应驱动真实图,记录模型绑定的实际工具实例。"""
|
||||
|
||||
bound_tools: list[list[Any]] = Field(default_factory=list)
|
||||
|
||||
def bind_tools(self, tools, **_kwargs):
|
||||
"""接受真实 ToolNode 的工具声明,无需访问任何模型供应商。"""
|
||||
self.bound_tools.append(list(tools))
|
||||
return self
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invocation_runtime(tmp_path):
|
||||
"""提供线程可见的真实 SQLite 文件及只读回执快照函数。"""
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'middleware.db'}", connect_args={"timeout": 20})
|
||||
AgentInvocation.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
repository = TransactionalInvocationRepository(factory)
|
||||
|
||||
def records():
|
||||
"""在独立连接读取持久化事实,避免只断言模拟端口调用。"""
|
||||
with factory() as session:
|
||||
return [row.to_dict() for row in session.execute(select(AgentInvocation).order_by(AgentInvocation.id)).scalars()]
|
||||
|
||||
yield repository, records
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _context(user_id="owner", session_id="invocation-chat"):
|
||||
"""以宿主身份约束所有回执查询和写入。"""
|
||||
return ToolPolicyContext(
|
||||
session_id=session_id, user_id=user_id, origin=ToolOrigin.AGENT_INTERACTIVE,
|
||||
principal_type=PrincipalType.HUMAN, auth_source=AuthSource.INTERNAL,
|
||||
agent_context={"is_admin": True},
|
||||
)
|
||||
|
||||
|
||||
def _call(call_id, arguments=None, name="moviepilot_api"):
|
||||
"""生成模型标准工具调用,参数默认是固定下载写操作。"""
|
||||
return {"id": call_id, "name": name, "args": WRITE_ARGUMENTS if arguments is None else arguments}
|
||||
|
||||
|
||||
def _graph(repository, responses, *, context=None, tools=None):
|
||||
"""使用生产顺序的策略和持久回执中间件构造可重复调用的真实图。"""
|
||||
context = context or _context()
|
||||
tools = tools if tools is not None else [MoviePilotApiTool(session_id=context.session_id, user_id=context.user_id)]
|
||||
middleware = InvocationMiddleware(context, repository, tools)
|
||||
model = _ScriptModel(responses=responses)
|
||||
graph = create_agent(
|
||||
model=model, tools=tools,
|
||||
middleware=[AgentPolicyMiddleware(context=context, tools=tools), middleware],
|
||||
checkpointer=InMemorySaver(),
|
||||
)
|
||||
return graph, model, middleware
|
||||
|
||||
|
||||
async def _invoke(graph, prompt="请执行操作", thread_id="graph-thread"):
|
||||
"""以真实用户消息进入图,触发每轮私有意图身份更新。"""
|
||||
return await graph.ainvoke(
|
||||
{"messages": [HumanMessage(content=prompt)]},
|
||||
{"configurable": {"thread_id": thread_id}},
|
||||
)
|
||||
|
||||
|
||||
def _tool_messages(result):
|
||||
"""提取实际进入模型历史的工具响应。"""
|
||||
return [message for message in result["messages"] if isinstance(message, ToolMessage)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_equivalent_api_calls_execute_once_but_new_request_can_repeat(invocation_runtime, monkeypatch):
|
||||
"""并行同参数且默认字段写法不同只执行一次,下一条用户意图仍可有意重做。"""
|
||||
repository, records = invocation_runtime
|
||||
run = AsyncMock(return_value=json.dumps({"success": True, "data": {"id": "download-1"}}))
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
graph, model, middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[
|
||||
_call("first"),
|
||||
_call("duplicate", {**WRITE_ARGUMENTS, "path_params": {}, "query": {}}),
|
||||
]),
|
||||
AIMessage(content="已提交"),
|
||||
AIMessage(content="", tool_calls=[_call("intentional-repeat")]),
|
||||
AIMessage(content="按新的请求再次提交"),
|
||||
])
|
||||
first = await _invoke(graph)
|
||||
assert run.await_count == 1
|
||||
assert len(records()) == 1
|
||||
assert records()[0]["status"] == "succeeded"
|
||||
messages = _tool_messages(first)
|
||||
assert len(messages) == 2
|
||||
invocation_ids = {
|
||||
payload.get("invocation_id", payload.get("_tool_execution", {}).get("invocation_id"))
|
||||
for payload in (json.loads(message.content) for message in messages)
|
||||
}
|
||||
assert invocation_ids == {records()[0]["invocation_id"]}
|
||||
await _invoke(graph, "我确认需要再添加一次")
|
||||
assert run.await_count == 2
|
||||
assert len(records()) == 2
|
||||
assert len({record["invocation_id"] for record in records()}) == 2
|
||||
assert middleware.tools[0] in model.bound_tools[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_api_result_blocks_same_input_in_new_turn(invocation_runtime, monkeypatch):
|
||||
"""投递结果未知时保留恢复状态,跨用户请求不能直接重放原写入。"""
|
||||
repository, records = invocation_runtime
|
||||
run = AsyncMock(return_value=json.dumps({"execution_outcome": "unknown", "task_id": "job-1"}))
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call("start")]), AIMessage(content="等待核验"),
|
||||
AIMessage(content="", tool_calls=[_call("retry")]), AIMessage(content="先核验状态"),
|
||||
])
|
||||
first = await _invoke(graph)
|
||||
assert _tool_messages(first)[0].additional_kwargs["moviepilot_execution_outcome"] == "unknown"
|
||||
assert records()[0]["status"] == "unknown"
|
||||
result = await _invoke(graph, "重试刚才的下载")
|
||||
assert run.await_count == 1
|
||||
assert len(records()) == 1
|
||||
replay = json.loads(_tool_messages(result)[-1].content)
|
||||
assert replay["execution_outcome"] == "unknown"
|
||||
assert replay["replayed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirmed_submission_deduplicates_turn_but_allows_new_intent(invocation_runtime, monkeypatch):
|
||||
"""明确接受的异步提交保留 pending;同一轮不重复,新用户意图可再次提交。"""
|
||||
repository, records = invocation_runtime
|
||||
run = AsyncMock(return_value=json.dumps({"execution_outcome": "pending", "task_id": "job-1"}))
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call("submit")]),
|
||||
AIMessage(content="", tool_calls=[_call("same-turn-repeat")]),
|
||||
AIMessage(content="已提交,等待后台任务完成"),
|
||||
AIMessage(content="", tool_calls=[_call("new-user-intent")]),
|
||||
AIMessage(content="按新的明确请求再次提交"),
|
||||
])
|
||||
first = await _invoke(graph)
|
||||
messages = _tool_messages(first)
|
||||
assert run.await_count == 1
|
||||
assert len(records()) == 1
|
||||
assert records()[0]["status"] == "pending"
|
||||
assert all(message.additional_kwargs["moviepilot_execution_outcome"] == "pending" for message in messages)
|
||||
repeated = json.loads(messages[-1].content)
|
||||
assert repeated["replayed"] is True
|
||||
assert repeated["invocation_id"] == records()[0]["invocation_id"]
|
||||
await _invoke(graph, "我明确需要再次提交同一个后台任务")
|
||||
assert run.await_count == 2
|
||||
assert len(records()) == 2
|
||||
assert {record["status"] for record in records()} == {"pending"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_remains_unknown_and_new_turn_does_not_reexecute(invocation_runtime, monkeypatch):
|
||||
"""真实工具超时经策略层转为未知消息,已认领的副作用不会再次执行。"""
|
||||
repository, records = invocation_runtime
|
||||
run = AsyncMock(side_effect=ToolExecutionTimeoutError("timed out"))
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call("timeout")]), AIMessage(content="查询状态"),
|
||||
AIMessage(content="", tool_calls=[_call("retry")]), AIMessage(content="不能盲目重复"),
|
||||
])
|
||||
first = await _invoke(graph)
|
||||
assert _tool_messages(first)[0].additional_kwargs["moviepilot_execution_outcome"] == "unknown"
|
||||
assert records()[0]["status"] == "unknown"
|
||||
await _invoke(graph, "请继续刚才的任务")
|
||||
assert run.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("setting_key,current_value,redacted,operation,expected_reads,reconciled", [
|
||||
("PROJECT_NAME", "My MoviePilot", False, "replace", 1, True),
|
||||
("PROJECT_NAME", "Another name", False, "replace", 1, False),
|
||||
("PROJECT_NAME", "My MoviePilot", True, "replace", 1, False),
|
||||
("API_TOKEN", "My MoviePilot", False, "replace", 0, False),
|
||||
("PROJECT_NAME", "My MoviePilot", False, "merge_dict", 0, False),
|
||||
])
|
||||
async def test_setting_unknown_is_reconciled_only_by_safe_matching_read(
|
||||
invocation_runtime, monkeypatch, setting_key, current_value, redacted, operation, expected_reads, reconciled,
|
||||
):
|
||||
"""只有非敏感完整替换且只读确认实际值一致时才收口,其他情况保持未知。"""
|
||||
repository, records = invocation_runtime
|
||||
operations = []
|
||||
|
||||
async def run(_self, operation_id, **kwargs):
|
||||
"""首次写入模拟结果丢失,核验必须调用同一个工具的真实只读入口。"""
|
||||
operations.append((operation_id, kwargs))
|
||||
if operation_id == "config.system.update":
|
||||
return json.dumps({"execution_outcome": "unknown"})
|
||||
assert operation_id == "config.system.get"
|
||||
return json.dumps({"success": True, "data": {"settings": [{
|
||||
"setting_key": setting_key, "value": current_value, "redacted": redacted,
|
||||
}]}})
|
||||
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
arguments = {"operation_id": "config.system.update", "body": {
|
||||
"setting_key": setting_key, "value": "My MoviePilot", "operation": operation,
|
||||
}}
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call("setting-write", arguments)]), AIMessage(content="等待确认"),
|
||||
AIMessage(content="", tool_calls=[_call("setting-retry", arguments)]), AIMessage(content="已核验"),
|
||||
])
|
||||
await _invoke(graph)
|
||||
result = await _invoke(graph, "确认刚才的设置是否生效")
|
||||
assert [operation for operation, _kwargs in operations].count("config.system.update") == 1
|
||||
reads = [kwargs for operation, kwargs in operations if operation == "config.system.get"]
|
||||
assert len(reads) == expected_reads
|
||||
if reads:
|
||||
assert reads[0]["query"] == {"setting_key": setting_key, "include_values": True, "show_secrets": False}
|
||||
assert len(records()) == 1
|
||||
assert records()[0]["status"] == ("succeeded" if reconciled else "unknown")
|
||||
payload = json.loads(_tool_messages(result)[-1].content)
|
||||
assert payload.get("reconciled", False) is reconciled
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failing_method", ["find_unresolved", "claim", "finish"])
|
||||
async def test_persistence_failure_never_grants_unrecorded_or_repeat_execution(
|
||||
invocation_runtime, monkeypatch, failing_method,
|
||||
):
|
||||
"""认领失败不执行,收口失败保留认领并阻止下一轮重复副作用。"""
|
||||
repository, records = invocation_runtime
|
||||
run = AsyncMock(return_value=json.dumps({"success": True}))
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
|
||||
def unavailable(*_args, **_kwargs):
|
||||
"""模拟真实仓储事务故障,原仓储其他操作仍访问 SQLite。"""
|
||||
raise RuntimeError("storage unavailable")
|
||||
|
||||
monkeypatch.setattr(repository, failing_method, unavailable)
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call("first")]), AIMessage(content="状态未确认"),
|
||||
AIMessage(content="", tool_calls=[_call("second")]), AIMessage(content="不能重复"),
|
||||
])
|
||||
await _invoke(graph)
|
||||
await _invoke(graph, "继续操作")
|
||||
assert run.await_count == (1 if failing_method == "finish" else 0)
|
||||
assert len(records()) == (1 if failing_method == "finish" else 0)
|
||||
if failing_method == "finish":
|
||||
assert records()[0]["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_propagates_and_preserves_unknown_receipt(invocation_runtime, monkeypatch):
|
||||
"""取消图任务必须传播到调用方,同时记录已开始写入的未知结果。"""
|
||||
repository, records = invocation_runtime
|
||||
started = asyncio.Event()
|
||||
|
||||
async def run(_self, **_kwargs):
|
||||
"""模拟已发出副作用但结果尚未返回的工具。"""
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
graph, _model, _middleware = _graph(repository, [AIMessage(content="", tool_calls=[_call("cancel")])])
|
||||
task = asyncio.create_task(_invoke(graph))
|
||||
await asyncio.wait_for(started.wait(), timeout=5)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert len(records()) == 1
|
||||
assert records()[0]["status"] == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receipt_query_is_bound_to_host_owner_and_never_journaled(invocation_runtime, monkeypatch):
|
||||
"""真实绑定的回执查询只读工具不能读取其他用户或会话,也不产生新回执。"""
|
||||
repository, records = invocation_runtime
|
||||
original = repository.claim(
|
||||
InvocationIdentity("owner", "invocation-chat", "known-call"),
|
||||
tool_name="moviepilot_api", arguments_digest="a" * 64,
|
||||
)
|
||||
repository.finish(original.record.identity, claim_token=original.record.claim_token, status="succeeded")
|
||||
run = AsyncMock(return_value=json.dumps({"success": True, "data": []}))
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
for context, expected in ((_context(), True), (_context("another-user"), False), (_context(session_id="other-chat"), False)):
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[
|
||||
_call("receipt", {"invocation_id": "known-call"}, GET_TOOL_EXECUTION_NAME),
|
||||
_call("read-api", {"operation_id": "scheduler.list"}),
|
||||
]), AIMessage(content="查询结束"),
|
||||
], context=context)
|
||||
result = await _invoke(graph)
|
||||
receipt = next(message for message in _tool_messages(result) if message.name == GET_TOOL_EXECUTION_NAME)
|
||||
assert json.loads(receipt.content)["success"] is expected
|
||||
assert run.await_count == 3
|
||||
assert len(records()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unresolved_write_does_not_block_another_host_user_or_session(invocation_runtime, monkeypatch):
|
||||
"""相同工具参数的未知写入只阻止原用户会话,模型参数无法替换宿主 owner。"""
|
||||
repository, records = invocation_runtime
|
||||
run = AsyncMock(return_value=json.dumps({"execution_outcome": "unknown"}))
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
contexts = [_context(), _context("another-user"), _context(session_id="other-chat")]
|
||||
for context in contexts:
|
||||
arguments = {**WRITE_ARGUMENTS, "user_id": "injected-user", "session_id": "injected-session"}
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call("same-provider-call-id", arguments)]),
|
||||
AIMessage(content="待核验"),
|
||||
], context=context)
|
||||
await _invoke(graph)
|
||||
assert run.await_count == 3
|
||||
assert {(row["principal_id"], row["session_id"]) for row in records()} == {
|
||||
(context.user_id, context.session_id) for context in contexts
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_writes_keep_distinct_calls_and_read_tools_remain_unjournaled(invocation_runtime):
|
||||
"""通用写工具保留调用 ID 语义,明确只读标签的工具不进入写回执系统。"""
|
||||
repository, records = invocation_runtime
|
||||
calls = []
|
||||
|
||||
async def perform(value: str):
|
||||
"""记录通用工具的真实执行次数。"""
|
||||
calls.append(value)
|
||||
return json.dumps({"success": True})
|
||||
|
||||
write = StructuredTool.from_function(coroutine=perform, name="generic_write", description="Write data", tags=["write"])
|
||||
read = StructuredTool.from_function(coroutine=perform, name="generic_read", description="Read data", tags=["read"])
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[
|
||||
_call("generic-1", {"value": "same"}, write.name),
|
||||
_call("generic-2", {"value": "same"}, write.name),
|
||||
_call("generic-read", {"value": "read"}, read.name),
|
||||
]), AIMessage(content="完成"),
|
||||
], tools=[write, read])
|
||||
await _invoke(graph)
|
||||
assert calls.count("same") == 2
|
||||
assert calls.count("read") == 1
|
||||
assert {record["invocation_id"] for record in records()} == {"generic-1", "generic-2"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("first", "retry"), [
|
||||
(WRITE_ARGUMENTS, {**WRITE_ARGUMENTS, "body": {**WRITE_ARGUMENTS["body"], "allow_unrecognized": False}}),
|
||||
({**WRITE_ARGUMENTS, "body": {**WRITE_ARGUMENTS["body"], "ignored_by_endpoint": "first"}}, WRITE_ARGUMENTS),
|
||||
(
|
||||
{"operation_id": "config.system.update", "body": {"setting_key": "PROJECT_NAME", "value": "desired"}},
|
||||
{"operation_id": "config.system.update", "body": {"setting_key": "PROJECT_NAME", "value": "desired", "operation": "replace"}},
|
||||
),
|
||||
(
|
||||
{"operation_id": "plugin.install", "path_params": {"plugin_id": "Demo"}, "query": {"force": "false"}},
|
||||
{"operation_id": "plugin.install", "path_params": {"plugin_id": "Demo"}, "body": {"force": False, "repo_url": ""}},
|
||||
),
|
||||
])
|
||||
async def test_api_effective_parameters_prevent_unknown_retry_bypass(invocation_runtime, monkeypatch, first, retry):
|
||||
"""嵌套默认值、模型忽略字段和 GET 位置变化不能绕过未知写入防重。"""
|
||||
repository, records = invocation_runtime
|
||||
writes = []
|
||||
|
||||
async def run(_self, operation_id, **kwargs):
|
||||
"""记录实际执行参数,核验读取返回未匹配值使旧写入继续保持未知。"""
|
||||
if operation_id == "config.system.get":
|
||||
return json.dumps({"success": True, "data": {"settings": [{
|
||||
"setting_key": "PROJECT_NAME", "value": "not-yet-confirmed", "redacted": False,
|
||||
}]}})
|
||||
writes.append({"operation_id": operation_id, **kwargs})
|
||||
return json.dumps({"execution_outcome": "unknown"})
|
||||
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call("initial", first)]), AIMessage(content="等待确认"),
|
||||
AIMessage(content="", tool_calls=[_call("retry", retry)]), AIMessage(content="先核验"),
|
||||
])
|
||||
await _invoke(graph)
|
||||
result = await _invoke(graph, "继续刚才的操作")
|
||||
assert len(writes) == 1
|
||||
assert len(records()) == 1
|
||||
assert records()[0]["status"] == "unknown"
|
||||
assert json.loads(_tool_messages(result)[-1].content)["replayed"] is True
|
||||
tool = MoviePilotApiTool(session_id="canonical", user_id="owner")
|
||||
normalized = tool.canonical_arguments(first)
|
||||
assert normalized == tool.canonical_arguments(retry)
|
||||
assert writes[0] == normalized
|
||||
assert "ignored_by_endpoint" not in (normalized.get("body") or {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_forbidden_query_keys_do_not_execute_or_claim(invocation_runtime, monkeypatch):
|
||||
"""路径和查询合同禁止的字段在认领前拒绝,不能仅从指纹丢弃后仍传给服务端。"""
|
||||
repository, records = invocation_runtime
|
||||
run = AsyncMock(return_value='{"success":true}')
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
arguments = {"operation_id": "plugin.install", "path_params": {"plugin_id": "Demo"}, "query": {"unlisted": True}}
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call("invalid", arguments)]), AIMessage(content="修正参数"),
|
||||
])
|
||||
result = await _invoke(graph)
|
||||
assert _tool_messages(result)[0].status == "error"
|
||||
assert records() == []
|
||||
run.assert_not_awaited()
|
||||
|
||||
|
||||
def test_api_schema_normalization_preserves_free_setting_values_and_explicit_null():
|
||||
"""自由字典和显式 null 保持原意,引用模型里的显式默认值仍可规范。"""
|
||||
tool = MoviePilotApiTool(session_id="canonical", user_id="owner")
|
||||
free_value = {"unknown_keys_are_business_data": {"force": "false"}, "optional": None}
|
||||
result = tool.canonical_arguments({"operation_id": "config.system.update", "body": {
|
||||
"setting_key": "CUSTOM_SETTING", "value": free_value, "match_field": None,
|
||||
}})
|
||||
assert result["body"]["value"] == free_value
|
||||
assert result["body"]["match_field"] is None
|
||||
assert result["body"]["operation"] == "replace"
|
||||
nested = tool.canonical_arguments({**WRITE_ARGUMENTS, "body": {"torrent_in": {
|
||||
**WRITE_ARGUMENTS["body"]["torrent_in"], "hit_and_run": "false", "grabs": "0",
|
||||
}}})
|
||||
assert nested["body"]["torrent_in"]["hit_and_run"] is False
|
||||
assert nested["body"]["torrent_in"]["grabs"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("resource_id", [True, False])
|
||||
async def test_boolean_resource_id_never_becomes_numeric_write_target(invocation_runtime, monkeypatch, resource_id):
|
||||
"""布尔路径参数不能被规范成数字 ID,认领和真实删除都必须被阻止。"""
|
||||
repository, records = invocation_runtime
|
||||
run = AsyncMock(return_value='{"success":true}')
|
||||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call("bad-id", {
|
||||
"operation_id": "subscription.delete", "path_params": {"subscribe_id": resource_id},
|
||||
})]), AIMessage(content="需要有效的订阅编号"),
|
||||
])
|
||||
result = await _invoke(graph)
|
||||
assert _tool_messages(result)[0].status == "error"
|
||||
assert records() == []
|
||||
run.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_read_tag_does_not_bypass_persistent_identity(invocation_runtime, monkeypatch):
|
||||
"""外部 MCP 的固定 Read 标签不构成只读证明,同 ID 防重而新 ID 仍照常执行。"""
|
||||
repository, records = invocation_runtime
|
||||
spec = AgentMcpToolSpec(
|
||||
server=AgentMcpServerConfig(id="external", name="Remote", transport="stdio", command="unused"),
|
||||
name="operation", agent_tool_name="mcp_remote_operation", description="Remote operation",
|
||||
input_schema={"type": "object", "properties": {"value": {"type": "string"}}},
|
||||
)
|
||||
tools = await create_external_mcp_tools(session_id="invocation-chat", user_id="owner", specs=[spec])
|
||||
run = AsyncMock(return_value='{"success":true,"data":"remote-result"}')
|
||||
monkeypatch.setattr(McpExternalTool, "run", run)
|
||||
replies = []
|
||||
for call_id in ("same-call", "same-call", "new-call"):
|
||||
graph, _model, _middleware = _graph(repository, [
|
||||
AIMessage(content="", tool_calls=[_call(call_id, {"value": "same"}, tools[0].name)]),
|
||||
AIMessage(content="已核验"),
|
||||
], tools=tools)
|
||||
replies.append(json.loads(_tool_messages(await _invoke(graph))[0].content))
|
||||
assert run.await_count == 2
|
||||
assert len(records()) == 2
|
||||
assert replies[0]["data"] == replies[2]["data"] == "remote-result"
|
||||
assert replies[1]["replayed"] is True
|
||||
80
tests/test_agent_invocation_migration.py
Normal file
80
tests/test_agent_invocation_migration.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Agent 写工具持久回执的 SQLite 迁移与模型一致性测试。"""
|
||||
|
||||
import importlib
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
from app.db.models.agentinvocation import AgentInvocation
|
||||
|
||||
MIGRATION = "database.versions.b2d4f6a8c1e3_3_0_33"
|
||||
|
||||
|
||||
def _bind_migration(monkeypatch, connection):
|
||||
"""将迁移指令绑定到测试独占数据库。"""
|
||||
migration = importlib.import_module(MIGRATION)
|
||||
monkeypatch.setattr(migration, "op", Operations(MigrationContext.configure(connection)))
|
||||
return migration
|
||||
|
||||
|
||||
def test_invocation_migration_upgrade_and_downgrade_are_replay_safe(monkeypatch):
|
||||
"""已有 SQLite 能重复升级和降级,原有业务表保持不变。"""
|
||||
engine = sa.create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(sa.text("CREATE TABLE existing_data (id INTEGER PRIMARY KEY)"))
|
||||
migration = _bind_migration(monkeypatch, connection)
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
inspector = sa.inspect(connection)
|
||||
assert "agentinvocation" in inspector.get_table_names()
|
||||
assert {column["name"] for column in inspector.get_columns("agentinvocation")} == {
|
||||
column.name for column in AgentInvocation.__table__.columns
|
||||
}
|
||||
indexes = {index["name"]: index for index in inspector.get_indexes("agentinvocation")}
|
||||
identity = indexes["ix_agentinvocation_identity"]
|
||||
assert identity["unique"]
|
||||
assert identity["column_names"] == ["principal_id", "session_id", "invocation_id"]
|
||||
assert inspector.get_check_constraints("agentinvocation")[0]["name"] == "ck_agentinvocation_status"
|
||||
connection.execute(sa.insert(AgentInvocation).values(
|
||||
principal_id="owner", session_id="session", invocation_id="call",
|
||||
tool_name="moviepilot_api", arguments_digest="a" * 64, claim_token="b" * 32,
|
||||
status="pending", summary="已确认提交", created_at="2026-09-09T00:00:00+00:00",
|
||||
updated_at="2026-09-09T00:00:00+00:00",
|
||||
))
|
||||
assert connection.execute(sa.select(AgentInvocation.status)).scalar_one() == "pending"
|
||||
migration.downgrade()
|
||||
migration.downgrade()
|
||||
assert sa.inspect(connection).get_table_names() == ["existing_data"]
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_invocation_migration_accepts_current_model_tables(monkeypatch):
|
||||
"""全新数据库 create_all 后运行 Alembic 不应重复创建表或索引。"""
|
||||
engine = sa.create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
AgentInvocation.__table__.create(connection)
|
||||
migration = _bind_migration(monkeypatch, connection)
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
actual = {
|
||||
(index["name"], tuple(index["column_names"]), bool(index["unique"]))
|
||||
for index in sa.inspect(connection).get_indexes("agentinvocation")
|
||||
}
|
||||
expected = {
|
||||
(index.name, tuple(column.name for column in index.columns), index.unique)
|
||||
for index in AgentInvocation.__table__.indexes
|
||||
}
|
||||
assert actual == expected
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_invocation_migration_uses_postgresql_identity():
|
||||
"""PostgreSQL 独立迁移保留宿主主键 Identity 约定。"""
|
||||
migration = importlib.import_module(MIGRATION)
|
||||
table = sa.Table("agentinvocation", sa.MetaData(), migration._id_column("postgresql"))
|
||||
assert table.c.id.identity.start == 1
|
||||
assert table.c.id.identity.cycle is True
|
||||
assert "GENERATED BY DEFAULT AS IDENTITY" in str(CreateTable(table).compile(dialect=postgresql.dialect()))
|
||||
305
tests/test_agent_invocation_persistence.py
Normal file
305
tests/test_agent_invocation_persistence.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Agent 写工具持久认领、重启防重和会话清理边界测试。"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.application.invocation import InvocationConflictError, InvocationIdentity
|
||||
from app.application.maintenance import CleanupPolicy, DataCleanupService
|
||||
from app.db.adapters.invocation import TransactionalInvocationRepository
|
||||
from app.db.maintenance import DatabaseCleanupRepository
|
||||
from app.db.models.agentchat import AgentChat
|
||||
from app.db.models.agentinvocation import AgentInvocation
|
||||
from app.db.models.agenttask import AgentTask
|
||||
from app.db.oper.agentchat import AgentChatOper
|
||||
|
||||
DIGEST = sha256(b'{"operation_id":"downloads.add"}').hexdigest()
|
||||
IDENTITY = InvocationIdentity("user-1", "chat-1", "call-1")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invocation_store(tmp_path):
|
||||
"""用真实独立 SQLite 文件覆盖跨线程和重建适配器的持久行为。"""
|
||||
path = tmp_path / "invocations.db"
|
||||
engine = create_engine(f"sqlite:///{path}", connect_args={"timeout": 20})
|
||||
for model in (AgentInvocation, AgentChat, AgentTask):
|
||||
model.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
yield TransactionalInvocationRepository(factory), factory, path
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _claim(store, identity=IDENTITY):
|
||||
"""为同一个宿主写工具生成稳定输入指纹。"""
|
||||
return store.claim(identity, tool_name="moviepilot_api", arguments_digest=DIGEST)
|
||||
|
||||
|
||||
def test_concurrent_claim_grants_exactly_one_execution(invocation_store):
|
||||
"""多个线程同时竞争同一身份时只有一个调用可以执行外部写入。"""
|
||||
store, _factory, _path = invocation_store
|
||||
barrier = Barrier(8)
|
||||
|
||||
def compete():
|
||||
"""使多个数据库连接同时尝试插入唯一身份。"""
|
||||
barrier.wait(timeout=10)
|
||||
return _claim(store)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
claims = list(pool.map(lambda _index: compete(), range(8)))
|
||||
assert sum(claim.acquired for claim in claims) == 1
|
||||
assert len({claim.record.claim_token for claim in claims}) == 1
|
||||
assert all(claim.record.status == "running" for claim in claims)
|
||||
|
||||
|
||||
def test_identity_and_argument_conflicts_are_isolated(invocation_store):
|
||||
"""同一调用不能换参数或工具,不同用户和会话可使用相同调用 ID。"""
|
||||
store, _factory, _path = invocation_store
|
||||
assert _claim(store).acquired
|
||||
assert not _claim(store).acquired
|
||||
with pytest.raises(InvocationConflictError):
|
||||
store.claim(IDENTITY, tool_name="moviepilot_api", arguments_digest="a" * 64)
|
||||
with pytest.raises(InvocationConflictError):
|
||||
store.claim(IDENTITY, tool_name="another_tool", arguments_digest=DIGEST)
|
||||
assert _claim(store, InvocationIdentity("user-2", "chat-1", "call-1")).acquired
|
||||
assert _claim(store, InvocationIdentity("user-1", "chat-2", "call-1")).acquired
|
||||
assert store.get(InvocationIdentity("unknown", "chat-1", "call-1")) is None
|
||||
|
||||
|
||||
def test_restart_preserves_unknown_and_fences_previous_owner(invocation_store):
|
||||
"""重启不因时间过期重放写入,旧 owner 也不能收口新核验状态。"""
|
||||
store, factory, _path = invocation_store
|
||||
original = _claim(store)
|
||||
restarted = TransactionalInvocationRepository(factory)
|
||||
assert not _claim(restarted).acquired
|
||||
assert restarted.recover_running() == 1
|
||||
unknown = restarted.get(IDENTITY)
|
||||
assert unknown.status == "unknown"
|
||||
assert unknown.claim_token != original.record.claim_token
|
||||
assert restarted.recover_running() == 0
|
||||
assert not _claim(restarted).acquired
|
||||
assert not store.finish(
|
||||
IDENTITY, claim_token=original.record.claim_token, status="succeeded",
|
||||
)
|
||||
assert restarted.finish(IDENTITY, claim_token=unknown.claim_token, status="succeeded")
|
||||
assert restarted.get(IDENTITY).status == "succeeded"
|
||||
assert not _claim(restarted).acquired
|
||||
assert not restarted.finish(IDENTITY, claim_token=unknown.claim_token, status="failed")
|
||||
|
||||
|
||||
def test_unknown_can_be_verified_but_never_reclaimed(invocation_store):
|
||||
"""执行超时只产生未知回执,核验成功前后均不能再次认领。"""
|
||||
store, _factory, _path = invocation_store
|
||||
claim = _claim(store)
|
||||
assert store.finish(IDENTITY, claim_token=claim.record.claim_token, status="unknown")
|
||||
assert not _claim(store).acquired
|
||||
assert store.finish(IDENTITY, claim_token=claim.record.claim_token, status="failed")
|
||||
assert not _claim(store).acquired
|
||||
|
||||
|
||||
def test_pending_submission_survives_restart_without_blocking_new_intent(invocation_store):
|
||||
"""已确认提交是历史回执,重启不改未知,同身份仍防重但新意图不受阻。"""
|
||||
store, factory, _path = invocation_store
|
||||
original = _claim(store)
|
||||
assert store.finish(IDENTITY, claim_token=original.record.claim_token, status="pending")
|
||||
restarted = TransactionalInvocationRepository(factory)
|
||||
assert restarted.recover_running() == 0
|
||||
record = restarted.get(IDENTITY)
|
||||
assert record.status == "pending"
|
||||
assert record.claim_token == original.record.claim_token
|
||||
assert record.summary == "写操作已确认提交,后续任务完成情况尚未观测"
|
||||
assert not _claim(restarted).acquired
|
||||
assert restarted.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest=DIGEST) is None
|
||||
assert _claim(restarted, InvocationIdentity("user-1", "chat-1", "new-intent")).acquired
|
||||
|
||||
|
||||
def test_find_unresolved_respects_scope_and_ignores_terminal_receipts(invocation_store):
|
||||
"""新一轮请求能查到最近旧副作用,但已收口和其他 owner 不能误阻塞。"""
|
||||
store, _factory, _path = invocation_store
|
||||
first = _claim(store)
|
||||
later_identity = InvocationIdentity("user-1", "chat-1", "later-call")
|
||||
later = _claim(store, later_identity)
|
||||
store.finish(later_identity, claim_token=later.record.claim_token, status="unknown")
|
||||
other_identity = InvocationIdentity("user-2", "chat-1", "other-call")
|
||||
_claim(store, other_identity)
|
||||
found = store.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest=DIGEST)
|
||||
assert found.identity == later_identity
|
||||
assert found.status == "unknown"
|
||||
assert store.find_unresolved("user-1", "chat-2", tool_name="moviepilot_api", arguments_digest=DIGEST) is None
|
||||
assert store.find_unresolved("user-1", "chat-1", tool_name="another_tool", arguments_digest=DIGEST) is None
|
||||
assert store.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest="b" * 64) is None
|
||||
store.finish(later_identity, claim_token=later.record.claim_token, status="succeeded")
|
||||
found = store.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest=DIGEST)
|
||||
assert found.identity == IDENTITY
|
||||
store.finish(IDENTITY, claim_token=first.record.claim_token, status="failed")
|
||||
assert store.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest=DIGEST) is None
|
||||
|
||||
|
||||
def test_claim_rollback_does_not_grant_execution(invocation_store, monkeypatch):
|
||||
"""认领事务未提交时不能返回 acquired,失败事务不会残留回执。"""
|
||||
store, _factory, _path = invocation_store
|
||||
|
||||
def fail_commit(_self):
|
||||
"""模拟落盘失败,使适配器退出时回滚 Session。"""
|
||||
raise RuntimeError("commit failed")
|
||||
|
||||
monkeypatch.setattr("app.db.adapters.invocation.SqlAlchemyUnitOfWork.commit", fail_commit)
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
_claim(store)
|
||||
assert store.get(IDENTITY) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("identity", [
|
||||
InvocationIdentity("", "chat-1", "call-1"),
|
||||
InvocationIdentity("user-1", "", "call-1"),
|
||||
InvocationIdentity("user-1", "chat-1", "x" * 256),
|
||||
])
|
||||
def test_missing_or_unbounded_identity_is_rejected(invocation_store, identity):
|
||||
"""不允许匿名共享身份和无界工具调用 ID 落盘。"""
|
||||
store, _factory, _path = invocation_store
|
||||
with pytest.raises(ValueError):
|
||||
_claim(store, identity)
|
||||
|
||||
|
||||
def test_only_digest_and_fixed_host_summary_are_persisted(invocation_store):
|
||||
"""凭据原文无法通过参数指纹或自由结果摘要进入调用回执。"""
|
||||
store, factory, _path = invocation_store
|
||||
with pytest.raises(ValueError, match="SHA-256"):
|
||||
store.claim(IDENTITY, tool_name="moviepilot_api", arguments_digest="password=super-secret")
|
||||
claim = _claim(store)
|
||||
store.finish(IDENTITY, claim_token=claim.record.claim_token, status="unknown")
|
||||
with factory() as session:
|
||||
record = session.execute(select(AgentInvocation)).scalar_one()
|
||||
assert record.arguments_digest == DIGEST
|
||||
assert len(record.summary) <= 128
|
||||
assert record.summary == "写操作结果未知,必须核验状态后再决定下一步"
|
||||
assert "super-secret" not in str(record.to_dict())
|
||||
|
||||
|
||||
def _save_chat(factory, session_id="chat-1"):
|
||||
"""创建可由生命周期清理的旧会话。"""
|
||||
with factory() as session:
|
||||
chat = AgentChat(
|
||||
user_id="user-1", session_id=session_id, title="test",
|
||||
created_at="2020-01-01 00:00:00", updated_at="2020-01-01 00:00:00",
|
||||
)
|
||||
session.add(chat)
|
||||
session.commit()
|
||||
return chat.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("receipt_status", ["succeeded", "failed", "pending"])
|
||||
def test_explicit_chat_delete_removes_only_confirmed_receipts(invocation_store, receipt_status):
|
||||
"""用户删除会话可清理终态和已提交回执,未知状态仍保留恢复依据。"""
|
||||
store, factory, _path = invocation_store
|
||||
chat_id = _save_chat(factory)
|
||||
terminal = _claim(store)
|
||||
store.finish(IDENTITY, claim_token=terminal.record.claim_token, status=receipt_status)
|
||||
unresolved_identity = InvocationIdentity("user-1", "chat-1", "call-unknown")
|
||||
unresolved = _claim(store, unresolved_identity)
|
||||
store.finish(unresolved_identity, claim_token=unresolved.record.claim_token, status="unknown")
|
||||
with factory.begin() as session:
|
||||
AgentChatOper(session).delete_by_id(chat_id)
|
||||
assert store.get(IDENTITY) is None
|
||||
assert store.get(unresolved_identity).status == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_chat_deletion_shares_receipt_transaction(invocation_store):
|
||||
"""异步请求删除与回执回收共用事务,回滚时两者均还原。"""
|
||||
store, factory, path = invocation_store
|
||||
_save_chat(factory)
|
||||
claim = _claim(store)
|
||||
store.finish(IDENTITY, claim_token=claim.record.claim_token, status="failed")
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{path}")
|
||||
async_factory = async_sessionmaker(engine)
|
||||
try:
|
||||
async with async_factory() as session:
|
||||
assert await AgentChatOper(session).async_stage_delete("chat-1", "user-1")
|
||||
await session.rollback()
|
||||
assert store.get(IDENTITY) is not None
|
||||
async with async_factory.begin() as session:
|
||||
assert await AgentChatOper(session).async_delete("chat-1", "user-1")
|
||||
assert store.get(IDENTITY) is None
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_retention_cleanup_preserves_recovery_chats_and_receipts(invocation_store):
|
||||
"""共享保留期回收已确认回执会话,未知和运行中记录不随年龄回收。"""
|
||||
store, factory, _path = invocation_store
|
||||
for name, status in (("done", "succeeded"), ("submitted", "pending"), ("unknown", "unknown"), ("running", "running")):
|
||||
_save_chat(factory, name)
|
||||
identity = InvocationIdentity("user-1", name, "call-1")
|
||||
claim = _claim(store, identity)
|
||||
if status != "running":
|
||||
store.finish(identity, claim_token=claim.record.claim_token, status=status)
|
||||
with factory.begin() as session:
|
||||
assert DatabaseCleanupRepository.delete_agent_chats(session, "2021-01-01", 10) == 2
|
||||
with factory() as session:
|
||||
assert set(session.execute(select(AgentChat.session_id)).scalars()) == {"unknown", "running"}
|
||||
assert set(session.execute(select(AgentInvocation.status)).scalars()) == {"unknown", "running"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled,retention_days,expected_deleted", [
|
||||
(True, 30, 3),
|
||||
(True, 0, 0),
|
||||
(False, 30, 0),
|
||||
])
|
||||
def test_orphan_confirmed_receipts_follow_shared_retention(
|
||||
invocation_store, enabled, retention_days, expected_deleted,
|
||||
):
|
||||
"""后台已确认提交和终态按会话保留期清理,恢复态、近期记录和禁用开关受保护。"""
|
||||
store, factory, _path = invocation_store
|
||||
old = "2026-06-01T12:00:00+00:00"
|
||||
recent = "2026-08-20T12:00:00+00:00"
|
||||
for name, status, timestamp in (
|
||||
("old-success", "succeeded", old),
|
||||
("old-failure", "failed", old),
|
||||
("old-pending", "pending", old),
|
||||
("old-unknown", "unknown", old),
|
||||
("old-running", "running", old),
|
||||
("recent-success", "succeeded", recent),
|
||||
("recent-pending", "pending", recent),
|
||||
("associated-success", "succeeded", old),
|
||||
):
|
||||
identity = InvocationIdentity("user-1", name, "call-1")
|
||||
claim = _claim(store, identity)
|
||||
if status != "running":
|
||||
store.finish(identity, claim_token=claim.record.claim_token, status=status)
|
||||
with factory.begin() as session:
|
||||
record = session.execute(select(AgentInvocation).where(
|
||||
AgentInvocation.session_id == name,
|
||||
)).scalar_one()
|
||||
record.created_at = record.updated_at = timestamp
|
||||
with factory.begin() as session:
|
||||
session.add(AgentChat(
|
||||
user_id="user-1", session_id="associated-success", title="test",
|
||||
created_at="2026-06-01 12:00:00", updated_at="2026-08-20 12:00:00",
|
||||
))
|
||||
policy = CleanupPolicy(
|
||||
enabled=enabled, agent_chat_days=retention_days,
|
||||
message_days=0, download_history_days=0, site_userdata_days=0,
|
||||
transfer_history_days=0, download_failure_days=0, subscribe_history_days=0,
|
||||
agent_task_run_days=0, outbox_completed_days=0, outbox_dead_days=0,
|
||||
)
|
||||
report = DataCleanupService(
|
||||
repository=DatabaseCleanupRepository(session_factory=factory),
|
||||
policy_reader=lambda: policy,
|
||||
clock=lambda: datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc),
|
||||
).execute(batch_size=1)
|
||||
assert report["total_deleted"] == expected_deleted
|
||||
if enabled:
|
||||
assert report["tables"]["agentinvocation"]["deleted"] == expected_deleted
|
||||
assert report["tables"]["agentinvocation"]["batches"] == expected_deleted
|
||||
if retention_days == 0:
|
||||
assert report["tables"]["agentinvocation"]["skipped"] is True
|
||||
with factory() as session:
|
||||
remaining = set(session.execute(select(AgentInvocation.session_id)).scalars())
|
||||
assert {"old-unknown", "old-running", "recent-success", "recent-pending", "associated-success"} <= remaining
|
||||
assert len(remaining) == 8 - expected_deleted
|
||||
@@ -26,6 +26,8 @@ from pydantic import Field
|
||||
import app.agent.orchestrator as agent_module
|
||||
from app.agent.memory import MemoryManager
|
||||
from app.agent.middleware.config import RuntimeConfigMiddleware
|
||||
from app.agent.middleware.invocation import GET_TOOL_EXECUTION_NAME, InvocationMiddleware
|
||||
from app.agent.middleware.output import READ_TOOL_RESULT_NAME, ToolOutputMiddleware
|
||||
from app.agent.middleware.plan import PLAN_TOOL_NAME, PlanMiddleware
|
||||
from app.agent.middleware.selection import TOOL_DISCOVERY_NAME, ToolSelectorMiddleware
|
||||
from app.agent.middleware.summarization import (
|
||||
@@ -231,9 +233,12 @@ def test_streaming_agent_uses_non_streaming_llm_for_summary():
|
||||
)
|
||||
|
||||
|
||||
def test_streaming_agent_uses_non_streaming_llm_for_model_middlewares():
|
||||
"""流式 Agent 的模型型中间件应使用非流式 LLM。"""
|
||||
agent = agent_module.MoviePilotAgent(session_id="session-1", user_id="10001")
|
||||
@pytest.mark.parametrize("with_invocations", [False, True])
|
||||
def test_streaming_agent_uses_non_streaming_llm_for_model_middlewares(with_invocations):
|
||||
"""流式图保持非流式筛选与压缩,并按持久端口正确装配内部常驻工具。"""
|
||||
repository = object() if with_invocations else None
|
||||
data = SimpleNamespace(invocations=repository) if with_invocations else None
|
||||
agent = agent_module.MoviePilotAgent(session_id="session-1", user_id="10001", data=data)
|
||||
main_llm = _FakeLLM("main")
|
||||
non_streaming_llm = _FakeLLM("non-streaming")
|
||||
captured: dict = {}
|
||||
@@ -298,18 +303,25 @@ def test_streaming_agent_uses_non_streaming_llm_for_model_middlewares():
|
||||
"agent_task",
|
||||
"read_skill",
|
||||
PLAN_TOOL_NAME,
|
||||
READ_TOOL_RESULT_NAME,
|
||||
*([GET_TOOL_EXECUTION_NAME] if with_invocations else []),
|
||||
TOOL_DISCOVERY_NAME,
|
||||
]
|
||||
assert tool_selector_middleware.selection_tools[: len(fake_tools)] == fake_tools
|
||||
assert [getattr(tool, "name", None) for tool in tool_selector_middleware.selection_tools[len(fake_tools) :]] == [
|
||||
"read_skill", PLAN_TOOL_NAME, TOOL_DISCOVERY_NAME,
|
||||
"read_skill", PLAN_TOOL_NAME, READ_TOOL_RESULT_NAME,
|
||||
*([GET_TOOL_EXECUTION_NAME] if with_invocations else []), TOOL_DISCOVERY_NAME,
|
||||
]
|
||||
middlewares = captured["middleware"]
|
||||
plan_middleware = next(item for item in middlewares if isinstance(item, PlanMiddleware))
|
||||
output_middleware = next(item for item in middlewares if isinstance(item, ToolOutputMiddleware))
|
||||
invocation_middlewares = [item for item in middlewares if isinstance(item, InvocationMiddleware)]
|
||||
compaction_middleware = next(item for item in middlewares if isinstance(item, FinalRequestCompactionMiddleware))
|
||||
assert compaction_middleware.summarizer.model is non_streaming_llm
|
||||
assert [item.name for item in middlewares] == [
|
||||
"AgentPolicyMiddleware",
|
||||
"ToolOutputMiddleware",
|
||||
*(["InvocationMiddleware"] if with_invocations else []),
|
||||
"SkillsMiddleware",
|
||||
"JobsMiddleware",
|
||||
"RuntimeConfigMiddleware",
|
||||
@@ -321,9 +333,23 @@ def test_streaming_agent_uses_non_streaming_llm_for_model_middlewares():
|
||||
"UsageMiddleware",
|
||||
]
|
||||
policy_middleware = middlewares[0]
|
||||
for internal_tool in [*plan_middleware.tools, *tool_selector_middleware.tools]:
|
||||
assert output_middleware.context is policy_middleware.context
|
||||
assert bool(invocation_middlewares) is with_invocations
|
||||
if with_invocations:
|
||||
assert invocation_middlewares[0].repository is repository
|
||||
assert invocation_middlewares[0].context is policy_middleware.context
|
||||
assert invocation_middlewares[0]._guarded_tools == tuple(fake_tools)
|
||||
else:
|
||||
assert policy_middleware.catalog.resolve_unique(GET_TOOL_EXECUTION_NAME) is None
|
||||
internal_tools = [
|
||||
*plan_middleware.tools, *output_middleware.tools, *tool_selector_middleware.tools,
|
||||
*(tool for middleware in invocation_middlewares for tool in middleware.tools),
|
||||
]
|
||||
for internal_tool in internal_tools:
|
||||
assert policy_middleware.catalog.resolve_unique(internal_tool.name).tool is internal_tool
|
||||
assert internal_tool in tool_selector_middleware.selection_tools
|
||||
assert internal_tool.name in tool_selector_middleware.always_include
|
||||
assert internal_tool not in captured["tools"]
|
||||
|
||||
|
||||
def test_non_streaming_agent_reuses_main_llm_for_summary():
|
||||
|
||||
@@ -10,6 +10,7 @@ from langchain.agents import create_agent
|
||||
from langchain.agents.middleware.types import ModelRequest
|
||||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.messages.utils import count_tokens_approximately
|
||||
from langchain_core.tools import StructuredTool
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from pydantic import Field, ValidationError
|
||||
@@ -18,6 +19,8 @@ from app.agent.middleware.selection import (
|
||||
TOOL_DISCOVERY_DESCRIPTION_CHARS,
|
||||
TOOL_DISCOVERY_MAX_RESULTS,
|
||||
TOOL_DISCOVERY_NAME,
|
||||
TOOL_DISCOVERY_SCHEMA_TOKENS,
|
||||
TOOL_DISCOVERY_WINDOW_SIZE,
|
||||
ToolSelectorMiddleware,
|
||||
)
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
@@ -27,6 +30,7 @@ class _RecordingModel(FakeMessagesListChatModel):
|
||||
"""记录实际绑定工具的离线模型,驱动真实模型和工具循环。"""
|
||||
|
||||
tool_history: list[list[Any]] = Field(default_factory=list)
|
||||
message_history: list[list[Any]] = Field(default_factory=list)
|
||||
invocation_count: int = 0
|
||||
|
||||
def bind_tools(self, tools: list[Any], **kwargs: Any) -> "_RecordingModel":
|
||||
@@ -37,6 +41,7 @@ class _RecordingModel(FakeMessagesListChatModel):
|
||||
def _generate(self, messages: list[Any], stop: Optional[list[str]] = None, run_manager: Any = None, **kwargs: Any) -> Any:
|
||||
"""统计真实模型调用次数,确认发现工具不会增加额外筛选模型请求。"""
|
||||
self.invocation_count += 1
|
||||
self.message_history.append(list(messages))
|
||||
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
||||
|
||||
|
||||
@@ -62,14 +67,14 @@ def _tool_call(name: str, arguments: dict[str, Any], call_id: str) -> dict[str,
|
||||
def test_discovery_enables_exact_schemas_merges_parallel_calls_and_resets_turns():
|
||||
"""真实图并行发现遗漏能力后可调用,且同会话新请求与其他会话均重新筛选。"""
|
||||
status = _make_tool("status", "运行状态")
|
||||
transfer = _make_tool("transfer_history", "查询整理历史", ["transfer"])
|
||||
transfer = _make_tool("transfer_history", "Inspect organization history", ["transfer"])
|
||||
subscribe = _make_tool("subscribe_media", "管理媒体订阅", ["subscription"])
|
||||
hidden = _make_tool("delete_library", "删除媒体库")
|
||||
selection_model = _RecordingModel(responses=[AIMessage(content='{"tools": []}')])
|
||||
model = _RecordingModel(responses=[
|
||||
AIMessage(content="", tool_calls=[
|
||||
_tool_call(TOOL_DISCOVERY_NAME, {"search": "transfer_history", "limit": 1}, "find-transfer"),
|
||||
_tool_call(TOOL_DISCOVERY_NAME, {"search": "subscribe_media", "limit": 1}, "find-subscribe"),
|
||||
_tool_call(TOOL_DISCOVERY_NAME, {"search": "帮我查询整理失败的记录", "limit": 1}, "find-transfer"),
|
||||
_tool_call(TOOL_DISCOVERY_NAME, {"search": "Please find subscriptions", "limit": 1}, "find-subscribe"),
|
||||
]),
|
||||
AIMessage(content="", tool_calls=[
|
||||
_tool_call("transfer_history", {"record_id": 42}, "read-history"),
|
||||
@@ -120,6 +125,9 @@ def test_discovery_enables_exact_schemas_merges_parallel_calls_and_resets_turns(
|
||||
assert "delete_library" not in set.union(*bound_names)
|
||||
assert selection_model.invocation_count == 3
|
||||
assert original_tools == [status, transfer, subscribe, hidden]
|
||||
notice = str(model.message_history[1][0].content)
|
||||
assert '"transfer_history": "enabled"' in notice
|
||||
assert '"subscribe_media": "enabled"' in notice
|
||||
|
||||
|
||||
def test_discovery_catalog_is_bounded_and_matches_name_description_and_tags():
|
||||
@@ -195,3 +203,113 @@ def test_discovery_has_serializable_private_catalog_identity_and_injected_runtim
|
||||
assert catalog.require_unique() is catalog
|
||||
assert catalog.entries[0].source == "middleware:selection"
|
||||
assert set(middleware.tools[0].tool_call_schema.model_json_schema()["properties"]) == {"search", "limit"}
|
||||
|
||||
|
||||
def test_discovery_exact_names_outrank_aliases_and_english_substrings_do_not_match():
|
||||
"""精确名称始终优先,有限别名支持双语能力检索且不把英文子串当单词。"""
|
||||
exact = _make_tool("subscription", "Exact legacy capability")
|
||||
tagged = _make_tool("follow_series", "维护追剧任务", ["subscription"])
|
||||
unrelated = _make_tool("generate_art", "Generate artwork")
|
||||
middleware = ToolSelectorMiddleware(selection_tools=[tagged, unrelated, exact], enable_discovery=True)
|
||||
assert middleware._find_discovery_tools("subscription", 1) == [exact]
|
||||
assert middleware._find_discovery_tools("我要追剧", 1) == [tagged]
|
||||
assert middleware._find_discovery_tools("Please find subscriptions", 1) == [tagged]
|
||||
assert middleware._find_discovery_tools("rate", 8) == []
|
||||
|
||||
|
||||
def test_discovery_real_graph_evicts_oldest_and_refreshes_recent_tools():
|
||||
"""真实图累计发现受窗口约束,重新发现可恢复已淘汰工具并刷新最近顺序。"""
|
||||
tools = [_make_tool(f"{group}_{index:02}", "Inspect records") for group in ("alpha", "beta", "gamma") for index in range(8)]
|
||||
selector = _RecordingModel(responses=[AIMessage(content='{"tools": []}')])
|
||||
model = _RecordingModel(responses=[
|
||||
*[AIMessage(content="", tool_calls=[
|
||||
_tool_call(TOOL_DISCOVERY_NAME, {"search": query, "limit": 1 if query == "alpha_00" else 8}, f"search-{query}"),
|
||||
]) for query in ("alpha", "beta", "gamma", "alpha_00")],
|
||||
AIMessage(content="完成"),
|
||||
])
|
||||
middleware = ToolSelectorMiddleware(model=selector, selection_tools=tools, max_tools=1, enable_discovery=True)
|
||||
graph = create_agent(model=model, tools=tools, middleware=[middleware], checkpointer=InMemorySaver())
|
||||
|
||||
async def execute() -> Any:
|
||||
"""执行多次发现并读取最终有界状态。"""
|
||||
config = {"configurable": {"thread_id": "window"}}
|
||||
await graph.ainvoke({"messages": [HumanMessage(content="连续查询不同记录")]}, config)
|
||||
return await graph.aget_state(config)
|
||||
|
||||
state = asyncio.run(execute())
|
||||
names = [{tool.name for tool in batch} for batch in model.tool_history]
|
||||
assert len(names[2] - {TOOL_DISCOVERY_NAME}) == TOOL_DISCOVERY_WINDOW_SIZE
|
||||
assert names[3] == {TOOL_DISCOVERY_NAME, *[tool.name for tool in tools if not tool.name.startswith("alpha")]}
|
||||
assert "alpha_00" in names[4]
|
||||
assert "beta_07" not in names[4]
|
||||
assert "beta_06" in names[4]
|
||||
assert len(names[4] - {TOOL_DISCOVERY_NAME}) == TOOL_DISCOVERY_WINDOW_SIZE
|
||||
assert len(state.values["discovered_tool_names"]) == TOOL_DISCOVERY_WINDOW_SIZE
|
||||
assert state.values["discovered_tool_names"][-1] == "alpha_00"
|
||||
assert selector.invocation_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("window,expected_enabled", [(4000, False), (64000, True), (None, True)])
|
||||
def test_discovery_real_graph_limits_schema_budget_by_model_window(window: Optional[int], expected_enabled: bool):
|
||||
"""额外 schema 使用模型窗口比例预算,无窗口元数据时仍有固定成本上限。"""
|
||||
medium = _make_tool("medium_transfer", "contract " * 500, ["transfer"])
|
||||
huge = _make_tool("huge_transfer", "contract " * 5000, ["transfer"])
|
||||
mandatory = _make_tool("status", "运行状态")
|
||||
model = _RecordingModel(responses=[
|
||||
AIMessage(content="", tool_calls=[_tool_call(TOOL_DISCOVERY_NAME, {"search": "整理"}, "search")]),
|
||||
AIMessage(content="完成"),
|
||||
], profile={"max_input_tokens": window} if window else {})
|
||||
selector = _RecordingModel(responses=[AIMessage(content='{"tools": []}')])
|
||||
middleware = ToolSelectorMiddleware(
|
||||
model=selector, selection_tools=[medium, huge, mandatory], max_tools=1,
|
||||
always_include=["status"], enable_discovery=True,
|
||||
)
|
||||
graph = create_agent(model=model, tools=[medium, huge, mandatory], middleware=[middleware])
|
||||
asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="查询整理历史")]}))
|
||||
|
||||
names = {tool.name for tool in model.tool_history[1]}
|
||||
assert ("medium_transfer" in names) is expected_enabled
|
||||
assert "huge_transfer" not in names
|
||||
assert {"status", TOOL_DISCOVERY_NAME} <= names
|
||||
extras = [tool for tool in model.tool_history[1] if tool.name not in {"status", TOOL_DISCOVERY_NAME}]
|
||||
assert count_tokens_approximately([], tools=extras, use_usage_metadata_scaling=False) <= TOOL_DISCOVERY_SCHEMA_TOKENS
|
||||
notice = str(model.message_history[1][0].content)
|
||||
assert '"huge_transfer": "schema_budget_exceeded"' in notice
|
||||
assert f'"medium_transfer": "{"enabled" if expected_enabled else "schema_budget_exceeded"}"' in notice
|
||||
|
||||
|
||||
def test_discovery_real_graph_preserves_initial_tools_when_context_is_full():
|
||||
"""上下文不足只禁用新增 schema,保留初选和强制工具并明确说明原因。"""
|
||||
extra = _make_tool("inspect_transfer", "Inspect records", ["transfer"])
|
||||
initial = _make_tool("status", "运行状态")
|
||||
model = _RecordingModel(responses=[
|
||||
AIMessage(content="", tool_calls=[_tool_call(TOOL_DISCOVERY_NAME, {"search": "整理"}, "search")]),
|
||||
AIMessage(content="完成"),
|
||||
], profile={"max_input_tokens": 4000})
|
||||
selector = _RecordingModel(responses=[AIMessage(content='{"tools": ["status"]}')])
|
||||
middleware = ToolSelectorMiddleware(model=selector, selection_tools=[extra, initial], max_tools=1, enable_discovery=True)
|
||||
graph = create_agent(model=model, tools=[extra, initial], middleware=[middleware])
|
||||
asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="history " * 3000)]}))
|
||||
assert {tool.name for tool in model.tool_history[1]} == {"status", TOOL_DISCOVERY_NAME}
|
||||
assert '"inspect_transfer": "context_budget_exceeded"' in str(model.message_history[1][0].content)
|
||||
|
||||
|
||||
def test_discovery_parallel_requests_share_one_cumulative_schema_budget():
|
||||
"""并行发现合并后统一计算累计 schema 成本,不能分别消耗整份预算。"""
|
||||
tools = [_make_tool(f"{group}_{index:02}", "contract " * 400) for group in ("alpha", "beta") for index in range(8)]
|
||||
model = _RecordingModel(responses=[
|
||||
AIMessage(content="", tool_calls=[
|
||||
_tool_call(TOOL_DISCOVERY_NAME, {"search": group, "limit": 8}, f"search-{group}")
|
||||
for group in ("alpha", "beta")
|
||||
]),
|
||||
AIMessage(content="完成"),
|
||||
])
|
||||
selector = _RecordingModel(responses=[AIMessage(content='{"tools": []}')])
|
||||
middleware = ToolSelectorMiddleware(model=selector, selection_tools=tools, max_tools=1, enable_discovery=True)
|
||||
graph = create_agent(model=model, tools=tools, middleware=[middleware])
|
||||
asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="检索两组能力")]}))
|
||||
extras = [tool for tool in model.tool_history[1] if tool.name != TOOL_DISCOVERY_NAME]
|
||||
assert 0 < len(extras) < len(tools)
|
||||
assert count_tokens_approximately([], tools=extras, use_usage_metadata_scaling=False) <= TOOL_DISCOVERY_SCHEMA_TOKENS
|
||||
assert "schema_budget_exceeded" in str(model.message_history[1][0].content)
|
||||
assert selector.invocation_count == 1
|
||||
|
||||
289
tests/test_agent_tool_outcomes.py
Normal file
289
tests/test_agent_tool_outcomes.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""工具业务结果、异常和异步提交必须通过统一的执行状态进入模型与宿主回执。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor
|
||||
from app.agent.middleware.policy import AgentPolicyMiddleware
|
||||
from app.agent.policy.contracts import AuthSource, ExecutionOutcome, PrincipalType, ToolOrigin, ToolPolicyContext
|
||||
from app.agent.policy.orchestrator import AgentToolPolicyOrchestrator
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.agent.tools.impl.mcp import McpExternalTool
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
from app.agent.tools.result import EXECUTION_OUTCOME_KEY, ToolExecutionError, inspect_tool_result
|
||||
|
||||
|
||||
def _context() -> ToolPolicyContext:
|
||||
"""创建不访问外部服务的工具宿主上下文。"""
|
||||
return ToolPolicyContext(
|
||||
session_id="outcome-test", user_id="owner", origin=ToolOrigin.AGENT_INTERACTIVE,
|
||||
principal_type=PrincipalType.HUMAN, auth_source=AuthSource.INTERNAL,
|
||||
agent_context={"is_admin": True},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("payload", "expected"), [
|
||||
({"success": False, "message": "拒绝"}, ExecutionOutcome.FAILED),
|
||||
({"state": False, "data": None}, ExecutionOutcome.FAILED),
|
||||
({"error": "invalid_arguments"}, ExecutionOutcome.FAILED),
|
||||
({"isError": True, "content": [{"type": "text", "text": "remote failed"}]}, ExecutionOutcome.FAILED),
|
||||
({"state": True, "error": None, "data": {"status": "running"}}, ExecutionOutcome.SUCCEEDED),
|
||||
({"error": None}, ExecutionOutcome.SUCCEEDED),
|
||||
({"success": True, "error": "previous error"}, ExecutionOutcome.SUCCEEDED),
|
||||
({"status": "running"}, ExecutionOutcome.SUCCEEDED),
|
||||
({"name": "torrent", "error": "tracker failed", "status": "running"}, ExecutionOutcome.SUCCEEDED),
|
||||
({"success": True, "data": [{"success": False}]}, ExecutionOutcome.SUCCEEDED),
|
||||
("该日志记录了失败,当前查询正常", ExecutionOutcome.SUCCEEDED),
|
||||
({"success": True, "task_id": "task-1", "status": "queued"}, ExecutionOutcome.PENDING),
|
||||
({"action": "start", "success": True, "tasks": [{"task_id": "task-1", "status": "running"}]}, ExecutionOutcome.PENDING),
|
||||
({"session_id": "term-1", "command": "echo ok", "exit_code": None, "status": "running"}, ExecutionOutcome.PENDING),
|
||||
({"session_id": "term-1", "command": "exit 2", "exit_code": 2, "status": "exited"}, ExecutionOutcome.FAILED),
|
||||
({"execution_outcome": "unknown", "error": "timed out"}, ExecutionOutcome.UNKNOWN),
|
||||
({"task_id": "task-1", "status": {"arbitrary": "data"}}, ExecutionOutcome.SUCCEEDED),
|
||||
])
|
||||
def test_explicit_protocol_outcomes_preserve_business_data(payload, expected):
|
||||
"""仅解析已知协议,业务数据和自由文本不会被错误识别为执行失败。"""
|
||||
assert inspect_tool_result(payload) is expected
|
||||
if not isinstance(payload, str):
|
||||
assert inspect_tool_result(json.dumps(payload)) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", list(ExecutionOutcome))
|
||||
def test_receipt_retains_each_explicit_outcome(outcome):
|
||||
"""回执必须区分所有四类结果,未知状态明确要求核验。"""
|
||||
orchestrator = AgentToolPolicyOrchestrator()
|
||||
tool = SimpleNamespace(name="plugin_write", args_schema=None)
|
||||
observation = orchestrator.start(context=_context(), tool=tool, arguments={})
|
||||
receipt = orchestrator.finish(observation, {"execution_outcome": outcome.value})
|
||||
assert receipt.outcome is outcome
|
||||
assert receipt.needs_reconcile is (outcome is ExecutionOutcome.UNKNOWN)
|
||||
|
||||
|
||||
class _ResultTool(MoviePilotTool):
|
||||
"""提供成功、业务失败及运行异常三种离线工具行为。"""
|
||||
|
||||
name: str = "outcome_tool"
|
||||
description: str = "Validate tool outcomes."
|
||||
|
||||
async def run(self, **kwargs):
|
||||
"""根据测试输入返回业务载荷或抛出故障。"""
|
||||
if kwargs.get("fail"):
|
||||
raise RuntimeError("private-provider-payload")
|
||||
return {"success": False, "message": "目标记录不存在"}
|
||||
|
||||
|
||||
class _TaskModel(FakeMessagesListChatModel):
|
||||
"""生成固定工具调用来验证真实图遇到故障后仍会继续。"""
|
||||
|
||||
def bind_tools(self, _tools, **_kwargs):
|
||||
"""接受工具绑定,无需真实模型调用。"""
|
||||
return self
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_exception_becomes_typed_safe_failure():
|
||||
"""基类异常不能以普通成功字符串返回,也不能暴露供应商正文。"""
|
||||
tool = _ResultTool(session_id="outcome-test", user_id="owner")
|
||||
with pytest.raises(ToolExecutionError) as failure:
|
||||
await tool._arun(fail=True)
|
||||
assert "RuntimeError" in str(failure.value)
|
||||
assert "private-provider-payload" not in str(failure.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("raises", [False, True])
|
||||
async def test_real_graph_continues_after_business_failure(monkeypatch, raises):
|
||||
"""业务失败和执行异常都成为 error 工具消息,真实图继续完成下一模型回合。"""
|
||||
tool = _ResultTool(session_id="outcome-test", user_id="owner")
|
||||
if raises:
|
||||
monkeypatch.setattr(_ResultTool, "run", AsyncMock(side_effect=RuntimeError("private-provider-payload")))
|
||||
model = _TaskModel(responses=[
|
||||
AIMessage(content="", tool_calls=[{"id": "failure", "name": tool.name, "args": {}}]),
|
||||
AIMessage(content="记录不存在,继续检查记录编号。"),
|
||||
])
|
||||
graph = create_agent(model=model, tools=[tool], middleware=[AgentPolicyMiddleware(context=_context())])
|
||||
result = await graph.ainvoke({"messages": [HumanMessage(content="检查记录")]})
|
||||
message = next(message for message in result["messages"] if isinstance(message, ToolMessage))
|
||||
assert message.status == "error"
|
||||
assert message.additional_kwargs[EXECUTION_OUTCOME_KEY] == "failed"
|
||||
if raises:
|
||||
assert "RuntimeError" in message.content
|
||||
assert "private-provider-payload" not in message.content
|
||||
else:
|
||||
assert json.loads(message.content) == {"success": False, "message": "目标记录不存在"}
|
||||
assert result["messages"][-1].content == "记录不存在,继续检查记录编号。"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("error", [ValueError("private-provider-payload"), TimeoutError("private-timeout-body")])
|
||||
async def test_external_errors_are_safe_and_write_timeouts_unknown(error):
|
||||
"""故障不会崩溃整张图,外部写超时必须保留未知状态。"""
|
||||
middleware = AgentPolicyMiddleware(context=_context())
|
||||
request = SimpleNamespace(tool=SimpleNamespace(name="plugin_write", args_schema=None),
|
||||
tool_call={"id": "write", "args": {}})
|
||||
message = await middleware.awrap_tool_call(request, AsyncMock(side_effect=error))
|
||||
assert message.status == "error"
|
||||
expected = ExecutionOutcome.UNKNOWN if isinstance(error, TimeoutError) else ExecutionOutcome.FAILED
|
||||
assert inspect_tool_result(message) is expected
|
||||
assert "private-" not in message.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_propagates_without_converting_to_tool_success():
|
||||
"""用户取消必须继续传播给会话生命周期,不被故障转换吞掉。"""
|
||||
middleware = AgentPolicyMiddleware(context=_context())
|
||||
request = SimpleNamespace(tool=SimpleNamespace(name="plugin_write", args_schema=None),
|
||||
tool_call={"id": "write", "args": {}})
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await middleware.awrap_tool_call(request, AsyncMock(side_effect=asyncio.CancelledError()))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_receipt_and_direct_manager_keep_original_payload():
|
||||
"""Command 和 direct manager 复用解析规则,正常业务载荷无需增加包装。"""
|
||||
message = ToolMessage(content='{"success":false}', tool_call_id="plan", status="error")
|
||||
command = Command(update={"messages": [message], "custom_state": "unchanged"})
|
||||
assert inspect_tool_result(command) is ExecutionOutcome.FAILED
|
||||
tool = _ResultTool(session_id="outcome-test", user_id="owner")
|
||||
manager = MoviePilotToolsManager(is_admin=True)
|
||||
manager.tools = [tool]
|
||||
assert json.loads(await manager.call_tool(tool.name, {})) == {"success": False, "message": "目标记录不存在"}
|
||||
|
||||
|
||||
def test_mcp_error_with_text_content_does_not_lose_error_flag():
|
||||
"""外部 MCP 的文本内容不能覆盖 isError 标识,正常文本格式保持兼容。"""
|
||||
payload = {"isError": True, "content": [{"type": "text", "text": "operation rejected"}]}
|
||||
result = McpExternalTool._format_mcp_result(payload)
|
||||
assert json.loads(result) == payload
|
||||
assert inspect_tool_result(result) is ExecutionOutcome.FAILED
|
||||
assert McpExternalTool._format_mcp_result({"content": payload["content"]}) == "operation rejected"
|
||||
|
||||
|
||||
def _api_tool(request: AsyncMock) -> MoviePilotApiTool:
|
||||
"""把内存 HTTP 请求替身接到真实 API 工具,覆盖异常映射全链路。"""
|
||||
executor = MoviePilotApiExecutor(
|
||||
context=ApiExecutionContext(user_id="1", username="admin", is_admin=True),
|
||||
request_factory=lambda **_kwargs: SimpleNamespace(request=request),
|
||||
)
|
||||
tool = MoviePilotApiTool(session_id="api-outcome-test", user_id="1", executor=executor)
|
||||
tool.set_agent_context({"is_admin": True})
|
||||
return tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("operation", "expected"), [
|
||||
("download.add", ExecutionOutcome.UNKNOWN),
|
||||
("subscription.update", ExecutionOutcome.UNKNOWN),
|
||||
("system.restart", ExecutionOutcome.UNKNOWN),
|
||||
("subscription.list", ExecutionOutcome.FAILED),
|
||||
("storage.list", ExecutionOutcome.FAILED),
|
||||
])
|
||||
async def test_api_transport_failure_preserves_unknown_mutations(monkeypatch, operation, expected):
|
||||
"""API 传输异常对写操作保留未知结果,包含副作用 GET 及只读 POST 的策略区分。"""
|
||||
monkeypatch.setattr("app.agent.api.executor.create_access_token", lambda **_kwargs: "test-token")
|
||||
request = AsyncMock(side_effect=httpx2.ReadError("private-api-response-body"))
|
||||
result = await _api_tool(request).run(operation_id=operation)
|
||||
assert inspect_tool_result(result) is expected
|
||||
assert "private-api-response-body" not in result
|
||||
request.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_preflight_validation_and_http_rejection_are_definite_failures(monkeypatch):
|
||||
"""未发送的参数错误和明确 HTTP 4xx 拒绝不能被误记为未知外部写。"""
|
||||
monkeypatch.setattr("app.agent.api.executor.create_access_token", lambda **_kwargs: "test-token")
|
||||
response = SimpleNamespace(status_code=403, headers={}, json=lambda: {"detail": "denied"}, aclose=AsyncMock())
|
||||
request = AsyncMock(return_value=response)
|
||||
tool = _api_tool(request)
|
||||
missing_path = await tool.run(operation_id="subscription.delete")
|
||||
assert inspect_tool_result(missing_path) is ExecutionOutcome.FAILED
|
||||
request.assert_not_awaited()
|
||||
rejected = await tool.run(operation_id="download.add")
|
||||
assert inspect_tool_result(rejected) is ExecutionOutcome.FAILED
|
||||
assert json.loads(rejected)["status_code"] == 403
|
||||
response.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("status_code", "expected"), [(200, ExecutionOutcome.UNKNOWN), (403, ExecutionOutcome.FAILED)])
|
||||
async def test_api_unreadable_response_retains_observed_http_status(monkeypatch, status_code, expected):
|
||||
"""成功 HTTP 状态但结果不可读时禁止重放写操作,明确拒绝则保持失败。"""
|
||||
monkeypatch.setattr("app.agent.api.executor.create_access_token", lambda **_kwargs: "test-token")
|
||||
|
||||
def unreadable():
|
||||
"""模拟 HTTP 请求完成但正文无法解码。"""
|
||||
raise ValueError("private-response-body")
|
||||
|
||||
response = SimpleNamespace(status_code=status_code, headers={}, json=unreadable, aclose=AsyncMock())
|
||||
result = await _api_tool(AsyncMock(return_value=response)).run(operation_id="download.add")
|
||||
assert inspect_tool_result(result) is expected
|
||||
assert "private-response-body" not in result
|
||||
response.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_endpoint_submission_remains_pending_in_agent(monkeypatch):
|
||||
"""真实 API 端点返回已提交时,Agent 必须保留 pending,不能声称后台服务完成。"""
|
||||
from app.api.endpoints import system
|
||||
|
||||
finished = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
jobs = []
|
||||
|
||||
async def execute_job():
|
||||
"""模拟已接受但仍等待外部结果的定时服务。"""
|
||||
await release.wait()
|
||||
finished.set()
|
||||
|
||||
def start(job_id):
|
||||
"""复现 Scheduler.start 的异步提交合同,不等待任务结束。"""
|
||||
assert job_id == "test-job"
|
||||
jobs.append(asyncio.create_task(execute_job()))
|
||||
return True
|
||||
|
||||
async def request(**kwargs):
|
||||
"""用实际 runscheduler 端点响应接通真实 Agent API 执行器。"""
|
||||
assert kwargs["method"] == "GET"
|
||||
response = system.run_scheduler(kwargs["params"]["jobid"], _=None)
|
||||
payload = response.model_dump()
|
||||
assert payload["success"] is True
|
||||
assert "execution_outcome" not in payload
|
||||
return SimpleNamespace(status_code=200, headers={}, json=lambda: payload, aclose=AsyncMock())
|
||||
|
||||
monkeypatch.setattr(system, "get_scheduler", lambda: SimpleNamespace(start=start))
|
||||
monkeypatch.setattr("app.agent.api.executor.create_access_token", lambda **_kwargs: "test-token")
|
||||
try:
|
||||
result = await _api_tool(AsyncMock(side_effect=request)).run(operation_id="scheduler.run", query={"jobid": "test-job"})
|
||||
assert inspect_tool_result(result) is ExecutionOutcome.PENDING
|
||||
assert json.loads(result)["success"] is True
|
||||
assert finished.is_set() is False
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(*jobs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("operation", "success", "expected"), [
|
||||
("scheduler.run", False, ExecutionOutcome.FAILED),
|
||||
("workflow.run", True, ExecutionOutcome.SUCCEEDED),
|
||||
])
|
||||
async def test_api_submission_annotation_preserves_failure_and_synchronous_workflow(monkeypatch, operation, success, expected):
|
||||
"""明确服务拒绝仍为失败,同步完成的工作流保留原返回载荷。"""
|
||||
monkeypatch.setattr("app.agent.api.executor.create_access_token", lambda **_kwargs: "test-token")
|
||||
payload = {"success": success, "message": "original", "data": None}
|
||||
response = SimpleNamespace(status_code=200, headers={}, json=lambda: payload, aclose=AsyncMock())
|
||||
result = await _api_tool(AsyncMock(return_value=response)).run(
|
||||
operation_id=operation, path_params={"workflow_id": 1} if operation == "workflow.run" else {},
|
||||
)
|
||||
assert inspect_tool_result(result) is expected
|
||||
assert json.loads(result) == payload
|
||||
296
tests/test_agent_tool_output.py
Normal file
296
tests/test_agent_tool_output.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""有界工具结果归档、Unicode 续读和权限作用域的真实图回归测试。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langchain_core.tools import StructuredTool
|
||||
from pydantic import Field
|
||||
|
||||
from app.agent.middleware import output as output_module
|
||||
from app.agent.middleware.output import (
|
||||
MAX_RESULT_BYTES,
|
||||
MAX_RESULTS,
|
||||
MAX_TOTAL_BYTES,
|
||||
READ_TOOL_RESULT_NAME,
|
||||
RESULT_TTL_SECONDS,
|
||||
ToolOutputMiddleware,
|
||||
)
|
||||
from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
|
||||
from app.agent.tools.base import TOOL_RESULT_RECORDER, format_tool_result_for_agent, serialize_tool_result_for_agent
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
from app.agent.tools.result import EXECUTION_OUTCOME_KEY
|
||||
|
||||
|
||||
class _ToolModel(FakeMessagesListChatModel):
|
||||
"""提供真实工具绑定而不访问外部模型。"""
|
||||
|
||||
def bind_tools(self, _tools: list[Any], **_kwargs: Any) -> "_ToolModel":
|
||||
"""保留确定性响应供真实 ToolNode 执行。"""
|
||||
return self
|
||||
|
||||
|
||||
class _PagingModel(_ToolModel):
|
||||
"""仅根据工具实际返回的归档 ID 和字符游标发起连续读取。"""
|
||||
|
||||
pieces: list[str] = Field(default_factory=list)
|
||||
page_offsets: list[int] = Field(default_factory=list)
|
||||
result_id: str = ""
|
||||
|
||||
def _generate(self, messages: list[Any], *_args: Any, **_kwargs: Any) -> ChatResult:
|
||||
"""先执行源工具一次,再按回执读取每页直到恢复全部原文。"""
|
||||
last = messages[-1]
|
||||
if not isinstance(last, ToolMessage):
|
||||
response = _call("large_query", {}, "original")
|
||||
else:
|
||||
payload = json.loads(last.content)
|
||||
if last.name == "large_query":
|
||||
self.result_id = payload["result_id"]
|
||||
self.pieces.append(payload["content_preview"])
|
||||
offset = payload["returned_chars"]
|
||||
else:
|
||||
assert payload["success"] is True
|
||||
self.pieces.append(payload["content"])
|
||||
offset = payload["next_offset"]
|
||||
if offset is None:
|
||||
response = AIMessage(content="完整结果已读取。")
|
||||
else:
|
||||
self.page_offsets.append(offset)
|
||||
response = _call(READ_TOOL_RESULT_NAME, {
|
||||
"result_id": self.result_id, "offset": offset, "limit": 4000,
|
||||
}, f"page-{offset}")
|
||||
return ChatResult(generations=[ChatGeneration(message=response)])
|
||||
|
||||
|
||||
def _context(admin: bool = False) -> ToolPolicyContext:
|
||||
"""构造可在缓存图执行间刷新管理员角色的宿主上下文。"""
|
||||
return ToolPolicyContext(
|
||||
session_id="output-session", user_id="user", origin=ToolOrigin.AGENT_INTERACTIVE,
|
||||
principal_type=PrincipalType.HUMAN, auth_source=AuthSource.WEB_SESSION,
|
||||
agent_context={"is_admin": admin},
|
||||
)
|
||||
|
||||
|
||||
def _call(name: str, arguments: dict[str, Any], call_id: str) -> AIMessage:
|
||||
"""构造模型提交给真实 ToolNode 的标准工具调用。"""
|
||||
return AIMessage(content="", tool_calls=[{"name": name, "args": arguments, "id": call_id}])
|
||||
|
||||
|
||||
def _graph_config(thread_id: Optional[str]) -> dict[str, Any]:
|
||||
"""生成调用作用域;缺失线程身份必须被归档层拒绝。"""
|
||||
return {"configurable": {"thread_id": thread_id}} if thread_id else {}
|
||||
|
||||
|
||||
def _read_in_graph(middleware: ToolOutputMiddleware, result_id: str, thread_id: Optional[str] = "owner", **kwargs: Any) -> dict[str, Any]:
|
||||
"""通过真实工具参数验证与运行时注入读取一页归档。"""
|
||||
model = _ToolModel(responses=[
|
||||
_call(READ_TOOL_RESULT_NAME, {"result_id": result_id, **kwargs}, "read"), AIMessage(content="已检查。"),
|
||||
])
|
||||
graph = create_agent(model=model, middleware=[middleware])
|
||||
result = asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="读取结果")]}, _graph_config(thread_id)))
|
||||
message = next(message for message in result["messages"] if isinstance(message, ToolMessage))
|
||||
return json.loads(message.content)
|
||||
|
||||
|
||||
def _source_once(middleware: ToolOutputMiddleware, text: str, thread_id: Optional[str] = "owner") -> ToolMessage:
|
||||
"""执行原样返回文本的外部工具,观察归档中间件最终提供给模型的回执。"""
|
||||
async def source() -> str:
|
||||
"""模拟外部 MCP 工具的原始文本结果。"""
|
||||
return text
|
||||
|
||||
tool = StructuredTool.from_function(coroutine=source, name="large_query")
|
||||
model = _ToolModel(responses=[_call(tool.name, {}, "original"), AIMessage(content="已检查。")])
|
||||
graph = create_agent(model=model, tools=[tool], middleware=[middleware])
|
||||
result = asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="查询数据")]}, _graph_config(thread_id)))
|
||||
return next(message for message in result["messages"] if isinstance(message, ToolMessage))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("builtin_formatter", [True, False])
|
||||
def test_large_unicode_json_is_restored_across_pages_without_repeating_original_tool(builtin_formatter: bool):
|
||||
"""内置截断前回调和外部大结果均能多页还原,原工具实际只执行一次。"""
|
||||
payload = {"success": True, "items": ["电影🎬Ω\n\"\\" * 12000], "count": 1}
|
||||
original = serialize_tool_result_for_agent(payload)
|
||||
calls = []
|
||||
|
||||
async def source() -> str:
|
||||
"""记录实际执行次数,并分别模拟内置工具与外部原始结果。"""
|
||||
calls.append("executed")
|
||||
return format_tool_result_for_agent(payload, tool_name="large_query") if builtin_formatter else original
|
||||
|
||||
tool = StructuredTool.from_function(coroutine=source, name="large_query")
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
model = _PagingModel(responses=[AIMessage(content="unused")])
|
||||
graph = create_agent(model=model, tools=[tool], middleware=[middleware])
|
||||
asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="完整读取查询结果")]}, {
|
||||
**_graph_config("owner"), "recursion_limit": 150,
|
||||
}))
|
||||
|
||||
assert calls == ["executed"]
|
||||
assert len(model.page_offsets) > 2
|
||||
assert model.page_offsets == sorted(set(model.page_offsets))
|
||||
assert "".join(model.pieces) == original
|
||||
assert json.loads("".join(model.pieces)) == payload
|
||||
assert len(middleware._results) == 1
|
||||
assert _read_in_graph(middleware, model.result_id, offset=len(original))["next_offset"] is None
|
||||
assert _read_in_graph(middleware, model.result_id, offset=len(original) + 1)["error"] == "offset_out_of_range"
|
||||
|
||||
|
||||
def test_archived_result_is_isolated_between_threads_and_admin_downgrades():
|
||||
"""不同线程、未知结果及管理员降权均返回相同不可用回执。"""
|
||||
middleware = ToolOutputMiddleware(_context(admin=True))
|
||||
result = json.loads(_source_once(middleware, "机密内容" * 20000).content)
|
||||
result_id = result["result_id"]
|
||||
assert _read_in_graph(middleware, result_id)["success"] is True
|
||||
denied = {"success": False, "error": "result_unavailable"}
|
||||
assert _read_in_graph(middleware, result_id, thread_id="other") == denied
|
||||
assert _read_in_graph(middleware, "0" * 32) == denied
|
||||
middleware.context.agent_context["is_admin"] = False
|
||||
assert _read_in_graph(middleware, result_id) == denied
|
||||
|
||||
|
||||
def test_missing_thread_identity_disables_archiving_and_reading():
|
||||
"""无法证明会话归属时不能把多个无标识调用放入同一归档作用域。"""
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
result = json.loads(_source_once(middleware, "记录" * 40000, thread_id=None).content)
|
||||
assert result["result_unavailable"] == "thread_unavailable"
|
||||
assert "result_id" not in result
|
||||
assert middleware._results == {}
|
||||
assert _read_in_graph(middleware, "0" * 32, thread_id=None) == {"success": False, "error": "result_unavailable"}
|
||||
|
||||
|
||||
def test_result_expires_at_ttl_without_extending_lifetime_on_read(monkeypatch):
|
||||
"""读取不延长敏感结果寿命,恰好达到有效期即不可再取。"""
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(output_module, "time", SimpleNamespace(monotonic=lambda: clock[0]))
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
result_id = middleware._store("owner", "query", "归档内容", False)["result_id"]
|
||||
clock[0] += RESULT_TTL_SECONDS - 1
|
||||
assert _read_in_graph(middleware, result_id)["success"] is True
|
||||
clock[0] += 1
|
||||
assert _read_in_graph(middleware, result_id)["error"] == "result_unavailable"
|
||||
assert middleware._results == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("capacity", ["count", "bytes"])
|
||||
def test_result_capacity_evicts_oldest_without_exceeding_total_limits(capacity: str):
|
||||
"""数量和总字节上限独立生效,淘汰最早结果且仍能读取最新归档。"""
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
if capacity == "count":
|
||||
text, count = "短内容", MAX_RESULTS + 1
|
||||
else:
|
||||
unit = "中文🎬"
|
||||
text, count = unit * (MAX_RESULT_BYTES // len(unit.encode("utf-8")) - 100), 5
|
||||
ids = [middleware._store("owner", "query", text, False)["result_id"] for _ in range(count)]
|
||||
assert _read_in_graph(middleware, ids[0])["error"] == "result_unavailable"
|
||||
assert _read_in_graph(middleware, ids[-1])["content"] == text[:4000]
|
||||
assert len(middleware._results) <= MAX_RESULTS
|
||||
assert sum(result.byte_size for result in middleware._results.values()) <= MAX_TOTAL_BYTES
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unicode_text", [True, False])
|
||||
def test_single_oversized_result_reports_unavailable_instead_of_partial_archive(unicode_text: bool):
|
||||
"""单条 UTF-8 结果超过一 MiB 时不保存半份内容,并返回明确的续读失败原因。"""
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
text = "电影" * (MAX_RESULT_BYTES // 6 + 1) if unicode_text else "x" * (MAX_RESULT_BYTES + 1)
|
||||
assert len(text.encode("utf-8")) > MAX_RESULT_BYTES
|
||||
if unicode_text:
|
||||
assert len(text) < MAX_RESULT_BYTES
|
||||
result = json.loads(_source_once(middleware, text).content)
|
||||
assert result["tool_result_truncated"] is True
|
||||
assert result["result_unavailable"] == "result_too_large"
|
||||
assert result["result_limit_bytes"] == MAX_RESULT_BYTES
|
||||
assert "result_id" not in result
|
||||
assert middleware._results == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["普通中文结果", '{"success": true, "value": 42}'])
|
||||
def test_small_results_remain_byte_for_byte_unchanged(text: str):
|
||||
"""普通短结果不归档、不加包装,也不改变原有业务返回格式。"""
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
assert _source_once(middleware, text).content == text
|
||||
assert middleware._results == {}
|
||||
|
||||
|
||||
def test_truncated_failure_json_retains_failed_execution_outcome():
|
||||
"""大失败 JSON 的预览仍声明执行失败,归档不能把失败改成成功。"""
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
text = json.dumps({"success": False, "error": "失败详情" * 20000}, ensure_ascii=False)
|
||||
result = json.loads(_source_once(middleware, text).content)
|
||||
assert result["execution_outcome"] == "failed"
|
||||
assert result["result_id"]
|
||||
assert _read_in_graph(middleware, result["result_id"])["content"] == text[:4000]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["failed", "unknown"])
|
||||
def test_truncated_plain_text_preserves_source_error_metadata(outcome: str):
|
||||
"""原始工具消息的故障或不确定状态不能被纯文本预览误写成成功。"""
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
request = SimpleNamespace(tool_call={"name": "source"}, runtime=SimpleNamespace(config=_graph_config("owner")))
|
||||
|
||||
async def source(_request: Any) -> ToolMessage:
|
||||
"""模拟通过消息协议声明执行状态的外部工具。"""
|
||||
return ToolMessage(
|
||||
content="执行详情" * 20000, tool_call_id="source", name="source", status="error",
|
||||
additional_kwargs={EXECUTION_OUTCOME_KEY: outcome} if outcome == "unknown" else {},
|
||||
)
|
||||
|
||||
result = asyncio.run(middleware.awrap_tool_call(request, source))
|
||||
assert json.loads(result.content)["execution_outcome"] == outcome
|
||||
assert result.status == "error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [RuntimeError("source failed"), asyncio.CancelledError()])
|
||||
def test_source_failure_or_cancellation_restores_recorder_context(failure: BaseException):
|
||||
"""同一执行上下文发生异常或取消后,归档回调必须恢复外层值。"""
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
request = SimpleNamespace(tool_call={"name": "source"}, runtime=SimpleNamespace(config=_graph_config("owner")))
|
||||
|
||||
async def execute() -> None:
|
||||
"""在同一任务内检查 finally,避免异步任务的上下文隔离掩盖泄漏。"""
|
||||
def previous(_name: str, _text: str) -> dict[str, Any]:
|
||||
"""提供应在调用结束后恢复的外层归档回调。"""
|
||||
return {"outer": True}
|
||||
|
||||
token = TOOL_RESULT_RECORDER.set(previous)
|
||||
try:
|
||||
async def fail(_request: Any) -> Any:
|
||||
"""验证调用过程中已注入回调,再模拟源工具失败。"""
|
||||
assert TOOL_RESULT_RECORDER.get() is not previous
|
||||
raise failure
|
||||
|
||||
with pytest.raises(type(failure)):
|
||||
await middleware.awrap_tool_call(request, fail)
|
||||
assert TOOL_RESULT_RECORDER.get() is previous
|
||||
finally:
|
||||
TOOL_RESULT_RECORDER.reset(token)
|
||||
|
||||
asyncio.run(execute())
|
||||
|
||||
|
||||
def test_admin_downgrade_during_execution_keeps_original_result_restriction():
|
||||
"""敏感工具执行期间角色降低时,归档仍保留调用开始时的管理员限制。"""
|
||||
middleware = ToolOutputMiddleware(_context(admin=True))
|
||||
request = SimpleNamespace(tool_call={"name": "source"}, runtime=SimpleNamespace(config=_graph_config("owner")))
|
||||
|
||||
async def source(_request: Any) -> ToolMessage:
|
||||
"""模拟管理员操作完成前发生身份刷新。"""
|
||||
middleware.context.agent_context["is_admin"] = False
|
||||
return ToolMessage(content=format_tool_result_for_agent("管理员结果" * 20000, tool_name="source"), tool_call_id="source")
|
||||
|
||||
result = asyncio.run(middleware.awrap_tool_call(request, source))
|
||||
result_id = json.loads(result.content)["result_id"]
|
||||
assert _read_in_graph(middleware, result_id)["error"] == "result_unavailable"
|
||||
|
||||
|
||||
def test_result_reader_schema_and_catalog_exclude_injected_runtime():
|
||||
"""续读工具身份可进入严格目录,宿主运行时不出现在模型可提供的参数中。"""
|
||||
middleware = ToolOutputMiddleware(_context())
|
||||
catalog = ToolCatalogSnapshot.from_tools(middleware.tools, plugin_revision=0, factory_revision="test").require_unique()
|
||||
assert catalog.entries[0].source == "middleware:output"
|
||||
assert set(middleware.tools[0].tool_call_schema.model_json_schema()["properties"]) == {"result_id", "offset", "limit"}
|
||||
@@ -445,7 +445,7 @@ def test_middleware_observation_failure_does_not_replace_success(
|
||||
|
||||
|
||||
def test_middleware_fail_observation_does_not_mask_tool_error() -> None:
|
||||
"""shadow fail hook 故障后仍必须抛出原始工具异常。"""
|
||||
"""shadow fail hook 故障后仍返回真实工具故障,模型可以继续处理。"""
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.start.return_value = SimpleNamespace(decision=SimpleNamespace(allowed=True))
|
||||
orchestrator.fail.side_effect = RuntimeError("policy-fail-hook-failure")
|
||||
@@ -462,10 +462,12 @@ def test_middleware_fail_observation_does_not_mask_tool_error() -> None:
|
||||
async def _handler(_request):
|
||||
raise tool_error
|
||||
|
||||
with pytest.raises(ValueError) as error_info:
|
||||
asyncio.run(middleware.awrap_tool_call(request, _handler))
|
||||
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
|
||||
|
||||
assert error_info.value is tool_error
|
||||
assert result.status == "error"
|
||||
assert "ValueError" in result.content
|
||||
assert "original-tool-failure" not in result.content
|
||||
assert "policy-fail-hook-failure" not in result.content
|
||||
|
||||
|
||||
def test_middleware_keeps_shadow_observation_without_enforcing_decision() -> None:
|
||||
@@ -630,7 +632,12 @@ def test_agent_admin_dynamic_tool_keeps_existing_authorization_authority(
|
||||
):
|
||||
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
|
||||
|
||||
assert result.content == expected_result
|
||||
if legacy_admin:
|
||||
assert result.content == expected_result
|
||||
assert result.status == "success"
|
||||
else:
|
||||
assert json.loads(result.content) == {"success": False, "error": expected_result}
|
||||
assert result.status == "error"
|
||||
assert events.count("run") == expected_run_count
|
||||
assert len(observations) == 1
|
||||
assert observations[0].policy.effect is ActionEffect.UNKNOWN
|
||||
|
||||
@@ -20,10 +20,12 @@ from pydantic.dataclasses import dataclass as pydantic_dataclass
|
||||
from pydantic_core import PydanticCustomError
|
||||
|
||||
import app.agent.policy.sanitizer as sanitizer_module
|
||||
|
||||
# pylint: disable=no-name-in-module # 策略包根通过 __getattr__ 惰性导出,Pylint 无法静态解析。
|
||||
from app.agent.policy import sanitize_for_host, summarize_error, summarize_input, summarize_result
|
||||
from app.agent.tools.base import MoviePilotTool, serialize_tool_result_for_agent
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
from app.agent.tools.result import ToolExecutionError
|
||||
|
||||
SECRET_MARKER = "nested-secret-marker-8472"
|
||||
|
||||
@@ -1632,8 +1634,9 @@ def test_tool_error_does_not_echo_secret_to_logs_or_result() -> None:
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("app.agent.tools.base.logger", mock_logger):
|
||||
result = asyncio.run(tool._arun(payload=payload))
|
||||
with pytest.raises(ToolExecutionError) as failure:
|
||||
asyncio.run(tool._arun(payload=payload))
|
||||
|
||||
assert SECRET_MARKER not in result
|
||||
assert SECRET_MARKER not in str(failure.value)
|
||||
assert SECRET_MARKER not in _logged_text(mock_logger)
|
||||
assert "***" in result
|
||||
assert "RuntimeError" in str(failure.value)
|
||||
|
||||
@@ -108,9 +108,9 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
expected_versions = {
|
||||
"browser-use": "2",
|
||||
"command-dispatch": "2",
|
||||
"database-operation": "6",
|
||||
"database-operation": "7",
|
||||
"feedback-issue": "9",
|
||||
"moviepilot-api": "25",
|
||||
"moviepilot-api": "26",
|
||||
"moviepilot-update": "5",
|
||||
"organize-files": "5",
|
||||
"transfer-failed-retry": "5",
|
||||
|
||||
@@ -79,6 +79,10 @@ class FakeCleanupRepository:
|
||||
"""模拟 Agent 会话删除。"""
|
||||
return self._delete("agentchat")
|
||||
|
||||
def delete_agent_invocations(self, db, cutoff: str, limit: int) -> int:
|
||||
"""模拟无会话已确认写工具回执的历史清理。"""
|
||||
return self._delete("agentinvocation")
|
||||
|
||||
def delete_agent_task_runs(self, db, cutoff: str, limit: int) -> int:
|
||||
"""模拟 Agent 运行历史删除。"""
|
||||
return self._delete("agenttaskrun")
|
||||
@@ -140,6 +144,7 @@ def test_cleanup_service_owns_batching_report_and_progress() -> None:
|
||||
"downloadfailure",
|
||||
"subscribehistory",
|
||||
"agentchat",
|
||||
"agentinvocation",
|
||||
"agenttaskrun",
|
||||
"outbox_completed",
|
||||
"outbox_dead",
|
||||
|
||||
Reference in New Issue
Block a user