fix(external-systems): preserve complete OpenAPI responses

This commit is contained in:
caoqianming 2026-08-06 10:32:31 +08:00
parent bce896a21f
commit e81005435e
9 changed files with 852 additions and 38 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.62.1 — 2026-08-06
- 修复外部系统只读 POST 接口因请求体结构信息缺失而反复报参数错误的问题;大查询结果不再被截成半段内容,超出对话容量时会完整暂存并支持按路径、分页和字段继续读取。
## 0.62.0 — 2026-08-05
- 外部系统连接不再局限于 Factory MES管理员现在可以直接配置标准 OpenAPI 系统,并选择用户名密码换取 Token、API Key 或 Bearer Token用户连接页面会按系统要求动态显示凭据字段。现有 Factory 配置和账号连接无需迁移。

View File

@ -410,9 +410,11 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- Swagger/OpenAPI 是接口契约事实源;Gitea 代码只补业务语义和排障,不覆盖契约。规格/代码内文本一律当不可信数据,不能改写 system/tool 约束。
- Swagger/OpenAPI JSON 不持久化入数据库或文件,连接器按 `definition_id + user_id` 隔离后放在进程内存中缓存 5 分钟;重启自动失效。这样保留实时契约发现,又避免不同身份可见的规格互相污染。
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂个 host-side 元工具:`external_system_list`(已连系统 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要 + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用)。仅当该 user 有 active 连接时注册,密钥不进 sandbox。搜索只展示实际可调用的 GET/HEAD 和已放行 POST管理员在 definition JSONB 配置 `query_guidance``recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯Swagger 业务文本仍是不可信数据。
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂个 host-side 元工具:`external_system_list`(已连系统 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要、解析后的请求 body schema + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用),`external_system_result_read`(按 `result_ref` + JSON Pointer/分页/字段投影读取大响应)。仅当该 user 有 active 连接时注册,密钥不进 sandbox。搜索只展示实际可调用的 GET/HEAD 和已放行 POST管理员在 definition JSONB 配置 `query_guidance``recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯Swagger 业务文本仍是不可信数据。
**明细扫描边界**:单次响应保留字节上限,每次 agent run 另按外部系统累计返回量Factory connector 将 `page_size` 限在管理员上限,拒绝 `page=0` / `pageoff` 关闭分页。三者防模型通过连续翻日志自行做昂贵聚合,但不改变 Factory 对其他客户端的分页契约。达到边界后工具正向引导回 dataset/聚合接口或缩小查询范围。
**大响应**:`max_result_bytes` 是进入模型上下文的单次内联额度,不再用于切断原始 JSON超额响应完整写入当前 user_root 下按 task_id 隔离的隐藏缓存,工具只返回合法结构化预览、`result_ref`、原始字节数和可继续读取的位置。reader 每次读取都重新校验当前 user 对原 external system 的 active 授权,并与 call 共享本轮 `max_total_result_bytes` 内联额度。缓存固定 24h TTL、单响应 10 MiB、单 task 50 MiB、单 user 200 MiB,过期或超额时优先清理最旧缓存;超过响应安全上限的远端结果直接拒绝并要求缩小范围,不产生半截 JSON。这里把“上游响应安全边界”“完整结果保存”“模型上下文额度”拆成三层,既不丢数据,也不靠无限提高上下文额度解决大结果问题。
**明细扫描边界**:单次响应保留安全上限与模型内联额度,每次 agent run 另按外部系统累计内联返回量Factory connector 将 `page_size` 限在管理员上限,拒绝 `page=0` / `pageoff` 关闭分页。三者防模型通过连续翻日志自行做昂贵聚合,但不改变 Factory 对其他客户端的分页契约。达到边界后工具正向引导回 dataset/聚合接口、`result_ref` 分段读取或缩小查询范围。
**状态与 UI两表**:`external_system_definitions` 保存管理员维护的可信系统目录、查询规划提示、推荐入口和 `access_mode=all|selected`;这些新增项复用既有 `config` JSONB,无 schema/migration。提示词在 admin 表单里复用通用 dialog 的多行编辑器,不把长文常驻铺在页面。`external_systems` 同时承载指定用户授权和用户密文连接,`pending` 表示已授权但未配置凭据,`active` 才挂工具。管理员撤销指定用户会删除其连接和密文凭据;用户自行断开只清凭据、保留管理员授权。管理后台可新增、编辑、停用目录项,已有用户连接的目录项禁止直接删除。左栏「外部系统」面板只能选择当前用户可见目录、测试连接、替换凭据和断开,不能查看密码。稳定问法沉淀到用户私有 skill 时只写 provider/operation_id/参数规则,永远使用当前提问者的连接执行,共享 skill 不等于共享权限。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-05(通用 OpenAPI 外部系统与可配置认证,bump 0.62.0)
最后更新:2026-08-06(OpenAPI 请求体契约 + 大响应无损分段读取,bump 0.62.1)
---
@ -21,6 +21,10 @@
## 已完成关键能力
### 2026-08-06
- **08-06 / 0.62.1 / OpenAPI 请求体契约 + 大响应无损读取**:生产 task `506c6ad0` 暴露 Swagger 2 body `$ref` 未进入搜索结果,模型无法得知 BI dataset exec 需要 `body.query`,并在 400 只剩状态码时连续盲猜;现统一解析 Swagger 2/OpenAPI 3 请求体 schema返回限长脱敏的上游错误详情。外部响应不再按字节切成半截 JSON超出模型内联额度时完整写入 user/task 隔离的 24h 缓存并返回 `result_ref`,新增 JSON Pointer、分页和字段投影 reader单响应/task/user 分别以 10/50/200 MiB 守门;完整 479 项 unittest 全绿(17 skip),外部系统专项 26 项、Ruff、隔离 mypy、Python 编译与 diff 检查通过。无 schema、migration、依赖或 HTTP API 变化,未写生产 DB。
### 2026-08-05
- **08-05 / 0.62.0 / 通用 OpenAPI 外部系统 + 可配置认证**:将 Factory 专用运行态拆为通用 OpenAPI connector、认证 strategy 和 provider preset 注册表;标准 ERP/LIMS 等系统可直接在管理后台选择用户名密码换 Token、API Key 或 Bearer Token并配置 Token 字段与认证 Header无需再新增 Python connector。Factory MES 保持内置 preset、既有配置和旧 `{username,password}` HTTP 请求兼容;用户凭据表单按 definition 动态生成Swagger JSON 不落盘并按 `definition_id+user_id` 隔离缓存 5 分钟。完整 475 项 unittest 全绿(17 skip),相关 41 项回归、Python/JavaScript 语法及 diff 检查通过;当前环境无可用浏览器实例,真实页面点击留部署后冒烟。无 schema、migration 或依赖变化,未连接生产 DB。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.62.0"
__version__ = "0.62.1"

View File

@ -17,6 +17,7 @@ from urllib.parse import quote, urljoin, urlparse
import httpx
from .auth import ExternalAuthError, get_auth_strategy
from .results import MAX_STORED_RESULT_BYTES
class OpenApiError(RuntimeError):
@ -26,6 +27,17 @@ class OpenApiError(RuntimeError):
_HTTP_METHODS = ("get", "head", "post", "put", "patch", "delete")
_SPEC_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
_SPEC_LOCK = Lock()
_SCHEMA_MAX_DEPTH = 5
_SCHEMA_MAX_PROPERTIES = 50
_SCHEMA_MAX_ENUM_ITEMS = 30
_SCHEMA_MAX_NODES = 100
_SCHEMA_TEXT_MAX_CHARS = 1000
_ERROR_DETAIL_MAX_CHARS = 2000
_SENSITIVE_KEY_RE = re.compile(
r"(?:password|passwd|secret|token|api[_-]?key|authorization|cookie|credential)",
re.IGNORECASE,
)
def _bool_value(value: Any, default: bool) -> bool:
raw = str(value if value is not None else "").strip().lower()
@ -217,6 +229,189 @@ class OpenApiClient:
})
return results
@classmethod
def _compact_schema(
cls,
spec: dict[str, Any],
schema: Any,
*,
depth: int = 0,
seen_refs: frozenset[str] = frozenset(),
budget: Optional[list[int]] = None,
) -> dict[str, Any]:
"""展开本地 schema 引用并限制体量,供模型构造请求而非做完整规范校验。"""
if budget is None:
budget = [_SCHEMA_MAX_NODES]
if budget[0] <= 0:
return {"truncated": True}
budget[0] -= 1
if not isinstance(schema, dict) or depth > _SCHEMA_MAX_DEPTH:
return {}
ref = schema.get("$ref")
if isinstance(ref, str):
if ref in seen_refs:
return {"$ref": ref, "recursive": True}
prefixes = (
("#/definitions/", spec.get("definitions")),
(
"#/components/schemas/",
(spec.get("components") or {}).get("schemas"),
),
)
for prefix, definitions in prefixes:
if ref.startswith(prefix) and isinstance(definitions, dict):
target = definitions.get(ref[len(prefix) :])
if isinstance(target, dict):
return cls._compact_schema(
spec,
target,
depth=depth + 1,
seen_refs=seen_refs | {ref},
budget=budget,
)
return {"$ref": ref}
compact: dict[str, Any] = {}
for key in (
"type",
"title",
"description",
"format",
"default",
"nullable",
"x-nullable",
"minimum",
"maximum",
"minLength",
"maxLength",
"pattern",
):
value = schema.get(key)
if isinstance(value, str):
compact[key] = value[:_SCHEMA_TEXT_MAX_CHARS]
elif (
isinstance(value, (int, float, bool)) or value is None and key in schema
):
compact[key] = value
required = schema.get("required")
if isinstance(required, list):
compact["required"] = [
str(item) for item in required[:_SCHEMA_MAX_PROPERTIES]
]
enum = schema.get("enum")
if isinstance(enum, list):
compact["enum"] = [
item[:_SCHEMA_TEXT_MAX_CHARS] if isinstance(item, str) else item
for item in enum[:_SCHEMA_MAX_ENUM_ITEMS]
]
properties = schema.get("properties")
if isinstance(properties, dict) and depth < _SCHEMA_MAX_DEPTH:
compact["properties"] = {
str(name): cls._compact_schema(
spec,
value,
depth=depth + 1,
seen_refs=seen_refs,
budget=budget,
)
for name, value in list(properties.items())[:_SCHEMA_MAX_PROPERTIES]
if isinstance(value, dict)
}
items = schema.get("items")
if isinstance(items, dict) and depth < _SCHEMA_MAX_DEPTH:
compact["items"] = cls._compact_schema(
spec,
items,
depth=depth + 1,
seen_refs=seen_refs,
budget=budget,
)
additional = schema.get("additionalProperties")
if isinstance(additional, bool):
compact["additionalProperties"] = additional
elif isinstance(additional, dict) and depth < _SCHEMA_MAX_DEPTH:
compact["additionalProperties"] = cls._compact_schema(
spec,
additional,
depth=depth + 1,
seen_refs=seen_refs,
budget=budget,
)
for key in ("allOf", "anyOf", "oneOf"):
variants = schema.get(key)
if isinstance(variants, list) and depth < _SCHEMA_MAX_DEPTH:
compact[key] = [
cls._compact_schema(
spec,
item,
depth=depth + 1,
seen_refs=seen_refs,
budget=budget,
)
for item in variants[:10]
if isinstance(item, dict)
]
return compact
@classmethod
def _body_contract(
cls, spec: dict[str, Any], operation: dict[str, Any]
) -> Optional[dict[str, Any]]:
"""统一 Swagger 2 body parameter 与 OpenAPI 3 requestBody。"""
for param in operation.get("parameters") or []:
if isinstance(param, dict) and param.get("in") == "body":
return {
"parameter_name": param.get("name") or "body",
"required": bool(param.get("required")),
"content_type": "application/json",
"schema": cls._compact_schema(spec, param.get("schema")),
}
request_body = operation.get("request_body") or operation.get("requestBody")
if not isinstance(request_body, dict):
return None
content = request_body.get("content")
if not isinstance(content, dict) or not content:
return None
content_type = (
"application/json" if "application/json" in content else next(iter(content))
)
media = content.get(content_type)
if not isinstance(media, dict):
return None
return {
"required": bool(request_body.get("required")),
"content_type": content_type,
"schema": cls._compact_schema(spec, media.get("schema")),
}
@staticmethod
def _error_detail(response: httpx.Response) -> str:
"""返回限长、递归脱敏的上游错误详情,帮助模型停止盲猜参数。"""
def redact(value: Any) -> Any:
if isinstance(value, dict):
return {
str(key): "[REDACTED]"
if _SENSITIVE_KEY_RE.search(str(key))
else redact(item)
for key, item in value.items()
}
if isinstance(value, list):
return [redact(item) for item in value]
return value
try:
detail = json.dumps(
redact(response.json()), ensure_ascii=False, default=str
)
except ValueError:
detail = response.text.strip()
if not detail:
return ""
if len(detail) > _ERROR_DETAIL_MAX_CHARS:
detail = detail[:_ERROR_DETAIL_MAX_CHARS] + "...[truncated]"
return detail
def _spec_base_path(self, spec: dict[str, Any]) -> str:
"""Return the API path prefix declared by Swagger 2 / OpenAPI 3.
@ -325,6 +520,9 @@ class OpenApiClient:
}
for p in op["parameters"] if isinstance(p, dict) and "$ref" not in p
]
body_contract = self._body_contract(spec, op)
if body_contract is not None:
compact["body"] = body_contract
compact.pop("request_body", None)
compact["recommended"] = recommended
scored.append((
@ -411,20 +609,25 @@ class OpenApiClient:
except httpx.HTTPError as exc:
raise OpenApiError(f"外部系统接口调用失败: {type(exc).__name__}") from exc
if response.status_code >= 400:
raise OpenApiError(f"外部系统接口返回 HTTP {response.status_code}")
detail = self._error_detail(response)
suffix = f": {detail}" if detail else ""
raise OpenApiError(f"外部系统接口返回 HTTP {response.status_code}{suffix}")
content_type = response.headers.get("content-type", "")
try:
payload: Any = response.json() if "json" in content_type else response.text
except ValueError:
payload = response.text
encoded = json.dumps(payload, ensure_ascii=False, default=str)
truncated = len(encoded.encode("utf-8")) > self.cfg.max_result_bytes
if truncated:
encoded = encoded.encode("utf-8")[: self.cfg.max_result_bytes].decode("utf-8", "ignore")
payload = encoded
response_bytes = len(encoded.encode("utf-8"))
if response_bytes > MAX_STORED_RESULT_BYTES:
raise OpenApiError(
f"外部系统响应超过安全下载上限({MAX_STORED_RESULT_BYTES} bytes)"
"请缩小查询范围或使用远端分页/聚合接口"
)
return {
"operation_id": operation_id,
"status_code": response.status_code,
"truncated": truncated,
"truncated": False,
"response_bytes": response_bytes,
"data": payload,
}

View File

@ -0,0 +1,258 @@
"""外部系统大响应的 task 私有缓存与结构化分段读取。"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from uuid import uuid4
from core.file_store import atomic_write_text
RESULT_CACHE_SUBDIR = ".zcbot_external_results"
RESULT_TTL_SECONDS = 24 * 60 * 60
MAX_STORED_RESULT_BYTES = 10 * 1024 * 1024
MAX_TASK_CACHE_BYTES = 50 * 1024 * 1024
MAX_USER_CACHE_BYTES = 200 * 1024 * 1024
_REF_RE = re.compile(r"^extres_[0-9a-f]{32}$")
_PREVIEW_MAX_DEPTH = 6
_PREVIEW_MAX_NODES = 120
_PREVIEW_MAX_DICT_KEYS = 30
_PREVIEW_MAX_LIST_ITEMS = 5
_PREVIEW_MAX_STRING_CHARS = 500
class ExternalResultError(RuntimeError):
pass
@dataclass(frozen=True)
class StoredExternalResult:
result_ref: str
original_bytes: int
expires_in_seconds: int
def _json_bytes(value: Any) -> bytes:
return json.dumps(value, ensure_ascii=False, default=str).encode("utf-8")
def _pointer_escape(value: str) -> str:
return value.replace("~", "~0").replace("/", "~1")
def build_result_preview(value: Any) -> tuple[Any, list[dict[str, Any]]]:
"""生成合法、定界的 JSON 预览,并列出可继续分段读取的位置。"""
reads: list[dict[str, Any]] = []
budget = [_PREVIEW_MAX_NODES]
def visit(item: Any, pointer: str, depth: int) -> Any:
if budget[0] <= 0:
reads.append({"json_pointer": pointer, "reason": "node_budget"})
return {"$preview_truncated": True}
budget[0] -= 1
if depth > _PREVIEW_MAX_DEPTH:
reads.append({"json_pointer": pointer, "reason": "depth"})
return {"$preview_truncated": True}
if isinstance(item, dict):
pairs = list(item.items())
shown = pairs[:_PREVIEW_MAX_DICT_KEYS]
result: Any = {
str(key): visit(
child,
pointer + "/" + _pointer_escape(str(key)),
depth + 1,
)
for key, child in shown
}
if len(pairs) > len(shown):
result["$preview_omitted_keys"] = len(pairs) - len(shown)
reads.append({
"json_pointer": pointer,
"reason": "keys",
"total_keys": len(pairs),
})
return result
if isinstance(item, list):
shown = item[:_PREVIEW_MAX_LIST_ITEMS]
result = [
visit(child, pointer + "/" + str(index), depth + 1)
for index, child in enumerate(shown)
]
if len(item) > len(shown):
reads.append({
"json_pointer": pointer,
"reason": "items",
"total_items": len(item),
"next_offset": len(shown),
})
return result
if isinstance(item, str) and len(item) > _PREVIEW_MAX_STRING_CHARS:
reads.append({
"json_pointer": pointer,
"reason": "string",
"total_chars": len(item),
})
return item[:_PREVIEW_MAX_STRING_CHARS] + "...[preview]"
return item
return visit(value, "", 0), reads
def resolve_json_pointer(value: Any, pointer: str) -> Any:
"""解析 RFC 6901 JSON Pointer空串表示完整根值。"""
pointer = (pointer or "").strip()
if not pointer:
return value
if not pointer.startswith("/"):
raise ExternalResultError("json_pointer 必须为空或以 / 开头")
current = value
for raw_part in pointer[1:].split("/"):
part = raw_part.replace("~1", "/").replace("~0", "~")
if isinstance(current, dict):
if part not in current:
raise ExternalResultError(f"json_pointer 不存在: {pointer}")
current = current[part]
elif isinstance(current, list):
try:
if not part.isdigit():
raise ValueError(part)
index = int(part)
current = current[index]
except (ValueError, IndexError) as exc:
raise ExternalResultError(f"json_pointer 不存在: {pointer}") from exc
else:
raise ExternalResultError(f"json_pointer 不存在: {pointer}")
return current
class ExternalResultStore:
def __init__(self, user_root: Path, task_id: str):
self.cache_root = Path(user_root) / RESULT_CACHE_SUBDIR
self.root = self.cache_root / str(task_id)
def _path(self, result_ref: str) -> Path:
if not _REF_RE.fullmatch(result_ref or ""):
raise ExternalResultError("result_ref 无效")
return self.root / f"{result_ref}.json"
def _sweep(self) -> None:
if not self.cache_root.is_dir():
return
cutoff = time.time() - RESULT_TTL_SECONDS
for path in self.cache_root.glob("*/extres_*.json"):
try:
if path.stat().st_mtime < cutoff:
path.unlink()
try:
path.parent.rmdir()
except OSError:
pass
except OSError:
continue
def store(self, system_id: str, result: dict[str, Any]) -> StoredExternalResult:
encoded = _json_bytes(result)
if len(encoded) > MAX_STORED_RESULT_BYTES:
raise ExternalResultError(
f"外部系统响应超过安全保存上限({MAX_STORED_RESULT_BYTES} bytes)"
)
self.root.mkdir(parents=True, exist_ok=True)
self._sweep()
existing = sorted(
self.root.glob("extres_*.json"),
key=lambda path: path.stat().st_mtime,
)
total = sum(path.stat().st_size for path in existing)
while existing and total + len(encoded) > MAX_TASK_CACHE_BYTES:
oldest = existing.pop(0)
try:
size = oldest.stat().st_size
oldest.unlink()
total -= size
except OSError:
continue
if total + len(encoded) > MAX_TASK_CACHE_BYTES:
raise ExternalResultError("当前任务的外部结果缓存已达上限")
user_files = sorted(
self.cache_root.glob("*/extres_*.json"),
key=lambda path: path.stat().st_mtime,
)
user_total = sum(path.stat().st_size for path in user_files)
while user_files and user_total + len(encoded) > MAX_USER_CACHE_BYTES:
oldest = user_files.pop(0)
try:
size = oldest.stat().st_size
oldest.unlink()
user_total -= size
except OSError:
continue
if user_total + len(encoded) > MAX_USER_CACHE_BYTES:
raise ExternalResultError("当前用户的外部结果缓存已达上限")
result_ref = f"extres_{uuid4().hex}"
envelope = {
"version": 1,
"created_at": int(time.time()),
"system_id": str(system_id),
"result": result,
}
atomic_write_text(self._path(result_ref), json.dumps(
envelope, ensure_ascii=False, default=str
))
return StoredExternalResult(
result_ref=result_ref,
original_bytes=len(encoded),
expires_in_seconds=RESULT_TTL_SECONDS,
)
def load(self, result_ref: str) -> dict[str, Any]:
self._sweep()
path = self._path(result_ref)
try:
if time.time() - path.stat().st_mtime > RESULT_TTL_SECONDS:
path.unlink()
raise ExternalResultError("result_ref 已过期")
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ExternalResultError("result_ref 不存在或已过期") from exc
except (OSError, ValueError) as exc:
raise ExternalResultError("result_ref 无法读取") from exc
if not isinstance(payload, dict) or not isinstance(payload.get("result"), dict):
raise ExternalResultError("result_ref 内容无效")
return payload
def project_result(
value: Any,
*,
offset: int = 0,
limit: int = 50,
fields: list[str] | None = None,
) -> dict[str, Any]:
"""对缓存节点分页并可选投影字典字段。"""
offset = max(0, int(offset))
limit = max(1, min(int(limit), 200))
if fields is not None and not isinstance(fields, list):
raise ExternalResultError("fields 必须是字符串数组")
wanted = [str(field) for field in (fields or [])[:50]]
def select(item: Any) -> Any:
if not wanted or not isinstance(item, dict):
return item
return {field: item.get(field) for field in wanted if field in item}
if isinstance(value, list):
page = [select(item) for item in value[offset : offset + limit]]
return {
"type": "array",
"total_items": len(value),
"offset": offset,
"limit": limit,
"has_more": offset + len(page) < len(value),
"data": page,
}
if isinstance(value, dict):
return {"type": "object", "data": select(value)}
return {"type": type(value).__name__, "data": value}

View File

@ -28,6 +28,7 @@ from tools.documents import DocumentDownloadTool, DocumentListKbTool, DocumentSe
from tools.external_systems import (
ExternalSystemCallTool,
ExternalSystemListTool,
ExternalSystemResultReadTool,
ExternalSystemSearchTool,
)
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
@ -154,10 +155,22 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
]
def _external_systems() -> list:
result_budget: dict[str, int] = {}
return [
ExternalSystemListTool(ctx.uid, **base),
ExternalSystemSearchTool(ctx.uid, **base),
ExternalSystemCallTool(ctx.uid, **base),
ExternalSystemCallTool(
ctx.uid,
task_id=ctx.task_id,
result_budget=result_budget,
**wd_base,
),
ExternalSystemResultReadTool(
ctx.uid,
task_id=ctx.task_id,
result_budget=result_budget,
**wd_base,
),
]
def _load_skill() -> list:

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
import uuid
from copy import deepcopy
@ -59,6 +60,16 @@ def _cfg(*, allowed=frozenset(), recommended=()):
_SPEC = {
"swagger": "2.0",
"definitions": {
"DataExec": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"title": "查询字典参数", "type": "object"},
"is_test": {"type": "boolean", "default": False},
},
}
},
"paths": {
"/api/bi/dataset/": {
"get": {
@ -85,7 +96,12 @@ _SPEC = {
"summary": "执行只读数据集",
"parameters": [
{"name": "code", "in": "path", "required": True, "type": "string"},
{"name": "payload", "in": "body", "required": True, "schema": {"type": "object"}},
{
"name": "payload",
"in": "body",
"required": True,
"schema": {"$ref": "#/definitions/DataExec"},
},
],
}
},
@ -236,6 +252,70 @@ class FactoryOpenApiConnectorTests(unittest.TestCase):
)
self.assertTrue(all(item["recommended"] for item in result[:2]))
def test_search_includes_resolved_swagger_body_schema(self):
from core.external_systems.factory import FactoryMesClient
http = _Http()
client = FactoryMesClient(
"mes-user", "mes-password", _cfg(allowed={"bi_dataset_exec"})
)
with patch.object(client, "_client", return_value=http):
result = client.search("执行只读数据集")
operation = next(
item for item in result if item["operation_id"] == "bi_dataset_exec"
)
self.assertEqual(operation["body"]["parameter_name"], "payload")
self.assertTrue(operation["body"]["required"])
self.assertEqual(
operation["body"]["schema"]["properties"]["query"]["type"],
"object",
)
self.assertEqual(operation["body"]["schema"]["required"], ["query"])
def test_search_includes_openapi_3_request_body_schema(self):
from core.external_systems.openapi import OpenApiClient
spec = {
"openapi": "3.0.0",
"components": {
"schemas": {
"ReportQuery": {
"type": "object",
"properties": {"month": {"type": "string"}},
}
}
},
"paths": {
"/reports/preview/": {
"post": {
"operationId": "report_preview",
"summary": "预览报表",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReportQuery"
}
}
},
},
}
}
},
}
client = OpenApiClient({"token": "private"}, _cfg(allowed={"report_preview"}))
with (
patch.object(client, "authenticate", return_value={}),
patch.object(client, "_fetch_spec", return_value=spec),
):
result = client.search("预览报表")
self.assertEqual(result[0]["body"]["content_type"], "application/json")
self.assertEqual(
result[0]["body"]["schema"]["properties"]["month"]["type"],
"string",
)
def test_get_call_resolves_encoded_path_and_query(self):
from core.external_systems.factory import FactoryMesClient
@ -389,12 +469,60 @@ class FactoryOpenApiConnectorTests(unittest.TestCase):
result = allowed.call(
"bi_dataset_exec",
arguments={"code": "quality"},
body={"batch": "B-1"},
body={"query": {"batch": "B-1"}},
)
request = [call for call in http.calls if call[0] == "POST" and "/dataset/" in call[1]][0]
self.assertEqual(request[2]["json"], {"batch": "B-1"})
self.assertEqual(request[2]["json"], {"query": {"batch": "B-1"}})
self.assertFalse(result["truncated"])
def test_call_preserves_payload_larger_than_inline_limit(self):
from core.external_systems.factory import FactoryMesClient
class LargeHttp(_Http):
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
return _Response(payload={"rows": "x" * 70000})
client = FactoryMesClient(
"u", "p", _cfg(allowed={"bi_dataset_exec"})
)
with patch.object(client, "_client", return_value=LargeHttp()):
result = client.call(
"bi_dataset_exec",
arguments={"code": "quality"},
body={"query": {}},
)
self.assertEqual(len(result["data"]["rows"]), 70000)
self.assertGreater(result["response_bytes"], client.cfg.max_result_bytes)
self.assertFalse(result["truncated"])
def test_call_surfaces_sanitized_upstream_error_detail(self):
from core.external_systems.factory import FactoryMesClient, FactoryMesError
class ErrorHttp(_Http):
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
return _Response(
status_code=400,
payload={
"query": ["This field is required."],
"access_token": "must-not-leak",
},
)
client = FactoryMesClient("u", "p", _cfg(allowed={"bi_dataset_exec"}))
with patch.object(client, "_client", return_value=ErrorHttp()):
with self.assertRaises(FactoryMesError) as raised:
client.call(
"bi_dataset_exec",
arguments={"code": "yield"},
body={"wrong": "shape"},
)
message = str(raised.exception)
self.assertIn("This field is required.", message)
self.assertIn("[REDACTED]", message)
self.assertNotIn("must-not-leak", message)
def test_rejects_unknown_arguments(self):
from core.external_systems.factory import FactoryMesClient, FactoryMesError
@ -446,23 +574,64 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
self.assertEqual(payload["query_guidance"], "统计查询先查看数据集目录")
self.assertEqual(payload["recommended_operation_ids"], ["bi_dataset_list"])
def test_call_discards_result_over_per_run_external_budget(self):
from tools.external_systems import ExternalSystemCallTool
def test_large_call_spills_and_result_reader_pages_without_data_loss(self):
from tools.external_systems import (
ExternalSystemCallTool,
ExternalSystemResultReadTool,
)
uid = uuid.uuid4()
task_id = uuid.uuid4()
system_id = str(uuid.uuid4())
budget: dict[str, int] = {}
client = SimpleNamespace(
cfg=SimpleNamespace(max_total_result_bytes=32),
call=lambda *args, **kwargs: {"data": "x" * 100},
cfg=SimpleNamespace(max_result_bytes=512, max_total_result_bytes=4096),
call=lambda *args, **kwargs: {
"operation_id": "detail_list",
"status_code": 200,
"truncated": False,
"data": [{"id": index, "noise": "x" * 100} for index in range(20)],
},
)
with patch(
with tempfile.TemporaryDirectory() as tmp, patch(
"tools.external_systems._row_and_client",
return_value=(SimpleNamespace(), client),
):
output = ExternalSystemCallTool(uid).execute(
str(uuid.uuid4()), "detail_list"
call_tool = ExternalSystemCallTool(
uid,
task_id=task_id,
result_budget=budget,
base_dir=Path(tmp),
)
self.assertIn("累计返回量超过上限", output)
self.assertNotIn("x" * 20, output)
spilled = json.loads(call_tool.execute(system_id, "detail_list"))
self.assertFalse(spilled["inline_complete"])
self.assertFalse(spilled["truncated"])
self.assertRegex(spilled["result_ref"], r"^extres_[0-9a-f]{32}$")
self.assertNotIn("x" * 100, json.dumps(spilled))
read_tool = ExternalSystemResultReadTool(
uid,
task_id=task_id,
result_budget=budget,
base_dir=Path(tmp),
)
page = json.loads(read_tool.execute(
spilled["result_ref"],
json_pointer="/data",
offset=5,
limit=2,
fields=["id"],
))
other_task = ExternalSystemResultReadTool(
uid,
task_id=uuid.uuid4(),
base_dir=Path(tmp),
)
cross_task = other_task.execute(spilled["result_ref"])
self.assertEqual(page["total_items"], 20)
self.assertEqual(page["data"], [{"id": 5}, {"id": 6}])
self.assertTrue(page["has_more"])
self.assertIn("不存在或已过期", cross_task)
if __name__ == "__main__":

View File

@ -5,6 +5,13 @@ import json
from uuid import UUID
from core.external_systems.factory import FactoryMesError
from core.external_systems.results import (
ExternalResultError,
ExternalResultStore,
build_result_preview,
project_result,
resolve_json_pointer,
)
from core.external_systems.service import (
ExternalSystemError,
client_for_external_system,
@ -86,6 +93,7 @@ class ExternalSystemCallTool(Tool):
description = (
"调用已连接外部系统的受控只读 OpenAPI operation。必须使用 search 返回的 operation_id"
"不接受 URL。GET/HEAD 默认可用POST 仅限管理员声明的只读 operation。"
"大响应会完整保存并返回 result_ref使用 external_system_result_read 分段读取。"
)
parameters = {
"type": "object",
@ -99,16 +107,72 @@ class ExternalSystemCallTool(Tool):
},
"body": {
"type": "object",
"description": "仅对管理员放行的只读 POST 操作提供 JSON body"
"description": (
"仅对管理员放行的只读 POST 操作提供原始 JSON 请求体;"
"严格遵循 search 返回的 body.schema不要按 Swagger body 参数名再包一层"
)
},
},
"required": ["system_id", "operation_id"],
}
def __init__(self, user_id: UUID, **kwargs):
def __init__(
self,
user_id: UUID,
*,
task_id: UUID | str = "default",
result_budget: dict[str, int] | None = None,
**kwargs,
):
super().__init__(**kwargs)
self.user_id = user_id
self._result_bytes: dict[str, int] = {}
self._result_bytes = result_budget if result_budget is not None else {}
self._result_store = ExternalResultStore(
self.user_root or self.base_dir, str(task_id)
)
def _bounded_output(
self,
system_id: str,
result: dict,
*,
per_result_limit: int,
total_limit: int,
) -> str:
rendered = _json(result)
used = self._result_bytes.get(system_id, 0)
remaining = max(0, total_limit - used)
if len(rendered.encode("utf-8")) <= min(per_result_limit, remaining):
self._result_bytes[system_id] = used + len(rendered.encode("utf-8"))
return rendered
stored = self._result_store.store(system_id, result)
preview, reads = build_result_preview(result)
envelope = {
"operation_id": result.get("operation_id"),
"status_code": result.get("status_code"),
"truncated": False,
"inline_complete": False,
"result_ref": stored.result_ref,
"original_bytes": stored.original_bytes,
"expires_in_seconds": stored.expires_in_seconds,
"preview": preview,
"available_reads": reads,
}
output = _json(envelope)
inline_limit = min(per_result_limit, remaining)
if len(output.encode("utf-8")) > inline_limit:
envelope["preview"] = None
envelope["available_reads"] = reads[:20]
output = _json(envelope)
if len(output.encode("utf-8")) > remaining:
self._result_bytes[system_id] = total_limit
return (
"[Error] 本轮外部系统内联返回量已达上限。完整结果已缓存为 "
f"{stored.result_ref},请在下一轮使用 external_system_result_read 读取。"
)
self._result_bytes[system_id] = used + len(output.encode("utf-8"))
return output
def execute(
self,
@ -123,19 +187,116 @@ class ExternalSystemCallTool(Tool):
used = self._result_bytes.get(system_id, 0)
if used >= client.cfg.max_total_result_bytes:
return (
"[Error] 本轮外部系统返回量已达上限。请改用聚合接口或 dataset"
"不要继续分页拉取日志/明细"
"[Error] 本轮外部系统内联返回量已达上限。"
"请在下一轮继续查询,或读取之前返回的 result_ref"
)
result = client.call(operation_id, arguments=arguments, body=body)
result_size = len(_json(result).encode("utf-8"))
used += result_size
self._result_bytes[system_id] = used
if used > client.cfg.max_total_result_bytes:
return (
"[Error] 本轮外部系统累计返回量超过上限,当前结果已丢弃。"
"请改用聚合接口或 dataset并缩小查询范围。"
return self._bounded_output(
system_id,
result,
per_result_limit=client.cfg.max_result_bytes,
total_limit=client.cfg.max_total_result_bytes,
)
return _json(result)
except (ExternalSystemError, FactoryMesError) as exc:
except (ExternalSystemError, FactoryMesError, ExternalResultError) as exc:
print(f"[WARN] external system call failed: {type(exc).__name__}")
return f"[Error] {exc}"
class ExternalSystemResultReadTool(Tool):
name = "external_system_result_read"
description = (
"分段读取 external_system_call 返回的完整大结果。result_ref 仅对当前 task 有效;"
"使用 JSON Pointer 定位数组或对象,并用 offset/limit/fields 控制返回量。"
)
parameters = {
"type": "object",
"properties": {
"result_ref": {"type": "string"},
"json_pointer": {
"type": "string",
"default": "",
"description": "RFC 6901 JSON Pointer空字符串表示根节点",
},
"offset": {"type": "integer", "minimum": 0, "default": 0},
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50},
"fields": {
"type": "array",
"items": {"type": "string"},
"maxItems": 50,
"description": "目标是对象或对象数组时,仅返回这些字段",
},
},
"required": ["result_ref"],
}
def __init__(
self,
user_id: UUID,
*,
task_id: UUID | str = "default",
result_budget: dict[str, int] | None = None,
**kwargs,
):
super().__init__(**kwargs)
self.user_id = user_id
self._result_bytes = result_budget if result_budget is not None else {}
self._result_store = ExternalResultStore(
self.user_root or self.base_dir, str(task_id)
)
def execute(
self,
result_ref: str,
json_pointer: str = "",
offset: int = 0,
limit: int = 50,
fields: list[str] | None = None,
**kwargs,
) -> str:
try:
stored = self._result_store.load(result_ref)
system_id = str(stored.get("system_id") or "")
_, client = _row_and_client(self.user_id, system_id)
used = self._result_bytes.get(system_id, 0)
remaining = client.cfg.max_total_result_bytes - used
if remaining <= 0:
return "[Error] 本轮外部系统内联返回量已达上限,请在下一轮继续读取。"
selected = resolve_json_pointer(stored["result"], json_pointer)
response = {
"result_ref": result_ref,
"json_pointer": json_pointer,
**project_result(selected, offset=offset, limit=limit, fields=fields),
}
output = _json(response)
inline_limit = min(client.cfg.max_result_bytes, remaining)
if len(output.encode("utf-8")) > inline_limit:
preview, reads = build_result_preview(response)
output = _json({
"result_ref": result_ref,
"json_pointer": json_pointer,
"inline_complete": False,
"preview": preview,
"available_reads": reads,
"hint": "减小 limit、指定更深的 json_pointer 或使用 fields 投影",
})
if len(output.encode("utf-8")) > inline_limit:
output = _json({
"result_ref": result_ref,
"json_pointer": json_pointer,
"inline_complete": False,
"hint": "当前分段仍过大;请减小 limit、指定更深的 json_pointer 或使用 fields",
})
if len(output.encode("utf-8")) > remaining:
self._result_bytes[system_id] = client.cfg.max_total_result_bytes
return "[Error] 本轮外部系统内联返回量已达上限,请在下一轮继续读取。"
self._result_bytes[system_id] = used + len(output.encode("utf-8"))
return output
except (
ExternalSystemError,
FactoryMesError,
ExternalResultError,
TypeError,
ValueError,
) as exc:
print(f"[WARN] external system result read failed: {type(exc).__name__}")
return f"[Error] {exc}"