Skip to content

feat(tools): add OpenAPI toolset - #343

Merged
raychen911 merged 1 commit into
mainfrom
feature/openapi-tools
Sep 23, 2026
Merged

raychen911 merged 1 commit into
mainfrom
feature/openapi-tools

Conversation

@raychen911

Copy link
Copy Markdown
Contributor

No description provided.

@weimch weimch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve

Comment thread CHANGELOG.md Outdated
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里没写对

@raychen911
raychen911 force-pushed the feature/openapi-tools branch from ed672df to c4bd41d Compare September 22, 2026 11:58
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

不通过

审查范围:b6fc4f3..ed672df(feat(tools): add OpenAPI toolset,18 个文件,+1896/-2),核心为新增的 trpc_agent_sdk/tools/_openapi_tools.py(559 行)、_openai_model.py 的 raw JSON Schema 分支、示例与文档。计划符合性:功能完整、测试覆盖面良好($ref 解析、四个参数位置、host allowlist、非法 spec 拒绝等)。主要风险:(1) SEVERE——路径模板校验只查 urlparse 的 scheme/netloc,/https://evil.example/x 可绕过校验并经 urljoin 覆盖 base 主机,allowed_hosts 默认 None 无兜底,构成 SSRF 向量且被新增测试固化为预期;(2) MODERATE——OpenAPI 工具仅填 parameters_json_schema,add_tools_to_prompt 模式下 prompt 注入的工具渲染丢失全部参数定义;(3) MODERATE——错误响应体未裁剪,模型上下文随错误路径与成功路径不对称而膨胀;(4) MODERATE——3xx 被当作成功处理;(5) MODERATE——operationId 与框架保留名 transfer_to_agent/set_model_response 冲突时会劫持或截断 agent 语义(Phase 3 sweep 补充确认);(6) LOW——CRLF header 值触发未捕获的 LocalProtocolError 使 agent 崩溃;(7) LOW——示例文档引用不存在的 .env.example。测试充分性:约 569 行新测试,覆盖充分,但对 urljoin 主机覆盖、3xx、CRLF、保留名冲突、add_tools_to_prompt 渲染均存在测试缺口。门禁结论:存在 SEVERE 缺陷,审查状态 FAILED。

发现的问题

严重

trpc_agent_sdk/tools/_openapi_tools.py:99-105

问题: _validate_path_template(第 99-105 行)仅检查 urlparse(path) 的 scheme/netloc/query/fragment,而 /https://evil.example/x 这类以单斜杠开头的路径也能解析出空值,校验被绕过;call() 第 354 行 path.lstrip("/") 后得到 https://evil.example/x,再经 urljoin(self._base_url + "/", ...) 会直接用该值覆盖 base 主机(实测 urljoin("https://api.example.com/v1/", "https://evil.example/x") 返回 https://evil.example/x),请求被发送到 spec 之外的任意主机。allowed_hosts 仅在用户配置时才生效(第 355-357 行),默认值为 None 即无任何主机限制,且新增测试 test_rejects_invalid_path_keys_before_any_request 明确断言 /https://evil.example/items 这类路径会被接受,等于把该绕过固化为预期行为。

触发条件: 用户未显式配置 allowed_hosts(默认 None),且 OpenAPI 文档 paths 键为 /https://<任意主机>/... 形式或包含可被解析为绝对 URL 的路径模板;LLM 要求调用该 operation 时即触发。

实际影响: 工具会将请求发送到 spec 声明主机之外的任意目标(内部地址、云元数据服务如 169.254.169.254 等),形成可利用的 SSRF 向量;_validate_path_template、_validate_base_url 与 _normalize_hostname 的防护在此路径下全部失效,且当 allowed_hosts 未配置时无任何兜底。

修正方向: 在拼接 URL 后对最终目标做强制校验:url = urljoin(...) 之后,解析 urlparse(url).hostname 并核对它属于文档 servers[].url 的主机(或归一化后与配置的 allowed_hosts 之一匹配),不匹配即抛 OpenAPIToolError;同时让 allowed_hosts 默认继承文档 servers 的主机列表,而不是 None,并把 /https://... 用例纳入拒绝路径的测试。

中等

trpc_agent_sdk/tools/_openapi_tools.py:299-304

问题: OpenAPITool._get_declaration(第 299-304 行)只设置 parameters_json_schema,从不设置 parameters;而 tool_prompt/_xml.py 第 49 行与 tool_prompt/_json.py 第 77-78 行只读取 func_decl.parameters,二者不互通。启用 add_tools_to_prompt=True(Hunyuan 渲染 prompt 注入模式需要)时,_openai_model.py 第 590-591 行生成的工具提示中每个 OpenAPI 工具只剩名称和描述,参数 schema 为空。

触发条件: 用户在 OpenAIModel 上设置 add_tools_to_prompt=True(如 Hunyuan 场景),并把 OpenAPIToolSet 挂到 agent 的 tools 上。

实际影响: 模型在 prompt 中看不到任何参数定义(必填项、嵌套对象、枚举全部丢失),无法生成符合 schema 的调用参数,工具调用会因缺少必填参数或类型错误而持续失败,OpenAPI 工具在该模式下实际不可用,且无任何报错提示。

修正方向: 扩展 tool_prompt 的序列化器,使其在 func_decl.parameters 为 None 时回退读取 func_decl.parameters_json_schema(或让 _get_declaration 同时填充 parameters,二选一并在两处保持一致);并在测试中补充 add_tools_to_prompt=True 渲染 OpenAPI 工具的用例。

中等

trpc_agent_sdk/tools/_openapi_tools.py:53-57

问题: OpenAPIHTTPError.__init__(第 53-57 行)把未经裁剪的完整响应体 content!r 拼进异常消息,而成功路径的 tool 响应会经过 _clip_tool_response_text 裁剪(_openai_model.py 第 719/731 行,tool_response_clip_chars 可配置);错误路径经 ToolsProcessor._create_error_event 原样转发,模型侧错误消息同样不经过裁剪。

触发条件: 被调用的 operation 返回 4xx/5xx,且响应体较大(如网关错误页、长堆栈或超大 JSON 错误体)。

实际影响: 完整错误体(数十 KB 甚至更大)被注入 tool error message 并进入下一轮 LLM 请求,直接膨胀模型上下文、增加 token 成本,长响应还可能挤占上下文窗口导致后续对话质量下降。

修正方向: 在 OpenAPIHTTPError 构造时对 content 做截断(例如保留前 N 字符并追加 ...[TRUNCATED] 标记),或复用 _clip_tool_response_text 的裁剪逻辑,使错误路径与成功路径的资源消耗一致。

中等

trpc_agent_sdk/tools/_openapi_tools.py:360-372

问题: 第 366-372 行仅用 response.is_error 判断失败,而 httpx 中 3xx 的 is_error 为 False;同时 OpenAPIToolSet.__init__(第 400 行)创建 httpx.AsyncClient 时未设置 follow_redirects(默认 False),301/302/307/308 会被当作成功响应返回。

触发条件: 服务器返回任何 3xx(重定向、未修改、缓存命中)且 Location 指向的端点未被跟随时,或返回 3xx 错误体时。

实际影响: 重定向响应体(如 HTML 跳转页)和状态码 302 被当成 operation 的成功结果交回 LLM,模型会得到与业务成功不一致的假数据(例如认为已创建资源但实际被重定向),错误诊断也被误导。

修正方向: 将失败判断改为 response.is_error or response.status_code >= 300(或 status_code 不在 2xx 即抛 OpenAPIHTTPError),并显式决定 follow_redirects 策略(跟随时需注意重定向目标是否越出 allowed_hosts 约束);补充 3xx 响应的测试用例。

中等

trpc_agent_sdk/tools/_openapi_tools.py:453-462

问题: _validate_operation_id(第 453-462 行)只按 ^[A-Za-z_][A-Za-z0-9_-]{0,63}$ 校验 operationId 的字面格式,不拒绝框架保留名;而 transfer_to_agent 与 set_model_response 在框架内按字符串特殊处理:_history_processor.py 第 289-291 行会把名为 transfer_to_agent 的 function_call/function_response 直接过滤出会话历史,_output_schema_processor.py 第 96-101 行与 _llm_agent.py 第 631-638 行会把任何名为 set_model_response 的 function_response 当作模型的最终 JSON 响应,并据此结束 agent 执行。

触发条件: OpenAPI 文档的某 operation 恰好以 transfer_to_agent 或 set_model_response 作为 operationId(格式校验允许),且被 LLM 调用。

实际影响: transfer_to_agent 命名的工具调用及结果会从历史中消失,破坏多轮对话上下文;set_model_response 命名的工具调用会让 agent 把 HTTP 响应体当作最终回答立即结束运行,对话流程被劫持,其余工具链执行被截断,用户得到错误且不完整的答案。

修正方向: 在 _validate_operation_id 中增加保留名黑名单(如 transfer_to_agent、set_model_response 等框架特殊处理的名字),检测到即抛 OpenAPISpecError;或在工具构建时对冲突名加前缀/重命名,并补充相应测试。

较低

trpc_agent_sdk/tools/_openapi_tools.py:361-362

问题: 第 334 行把 header 形参值直接 str(value) 写入 header,未做 CRLF 校验;第 361-362 行仅捕获 httpx.HTTPError,而 httpx 底层发生 CRLF 注入时会抛 httpcore.LocalProtocolError(不是 httpx.HTTPError 子类),异常会穿透 except 向上传播。

触发条件: OpenAPI spec 声明 header 参数,且 LLM 生成的参数值包含 \r\n(或经 prompt injection 诱导)。

实际影响: 工具调用不是以 tool error event 失败(会被 ToolsProcessor 捕获并喂回 LLM),而是让异常冒泡击穿 agent 执行流程,导致整个 agent 运行崩溃;正常情况下可能基于头部错误注入 HTTP 头。

修正方向: 在写入 header 前丢弃或替换值中的 \r/\n(或复用 httpx 的 header 校验逻辑),同时把异常捕获扩展为 (httpx.HTTPError, httpcore.LocalProtocolError),保证请求层错误统一降级为 OpenAPIToolError。

较低

docs/mkdocs/zh/openapi_tools.md:134-137

问题: 本文件(第 134 行)与英文版 docs/mkdocs/en/openapi_tools.md(第 137 行)都指导先执行 cp .env.example .env,但本次提交只包含 examples/openapi_tools/.env,没有 .env.example(git ls-files 确认仓库内不存在该文件)。

触发条件: 用户按文档快速上手时按文档中的安装流程执行命令,cp 报「文件不存在」,首次体验即中断。

实际影响: 文档开箱体验失败;用户不得不手动发现需要自行创建 .env,或误以为示例不可用,影响新特性推广。

修正方向: 新增 examples/openapi_tools/.env.example(字段与现有 .env 一致,值为空占位符),或把文档和示例 README 中的命令改为直接编辑 examples/openapi_tools/.env。

Comment on lines +99 to +105
def _validate_path_template(path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise OpenAPISpecError(f"OpenAPI path must be a path template beginning with '/': {path!r}")
parsed = urlparse(path)
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
raise OpenAPISpecError(f"Invalid OpenAPI path template: {path!r}")
return path

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _validate_path_template(第 99-105 行)仅检查 urlparse(path) 的 scheme/netloc/query/fragment,而 /https://evil.example/x 这类以单斜杠开头的路径也能解析出空值,校验被绕过;call() 第 354 行 path.lstrip("/") 后得到 https://evil.example/x,再经 urljoin(self._base_url + "/", ...) 会直接用该值覆盖 base 主机(实测 urljoin("https://api.example.com/v1/", "https://evil.example/x") 返回 https://evil.example/x),请求被发送到 spec 之外的任意主机。allowed_hosts 仅在用户配置时才生效(第 355-357 行),默认值为 None 即无任何主机限制,且新增测试 test_rejects_invalid_path_keys_before_any_request 明确断言 /https://evil.example/items 这类路径会被接受,等于把该绕过固化为预期行为。

触发条件: 用户未显式配置 allowed_hosts(默认 None),且 OpenAPI 文档 paths 键为 /https://<任意主机>/... 形式或包含可被解析为绝对 URL 的路径模板;LLM 要求调用该 operation 时即触发。

实际影响: 工具会将请求发送到 spec 声明主机之外的任意目标(内部地址、云元数据服务如 169.254.169.254 等),形成可利用的 SSRF 向量;_validate_path_template、_validate_base_url 与 _normalize_hostname 的防护在此路径下全部失效,且当 allowed_hosts 未配置时无任何兜底。

修正方向: 在拼接 URL 后对最终目标做强制校验:url = urljoin(...) 之后,解析 urlparse(url).hostname 并核对它属于文档 servers[].url 的主机(或归一化后与配置的 allowed_hosts 之一匹配),不匹配即抛 OpenAPIToolError;同时让 allowed_hosts 默认继承文档 servers 的主机列表,而不是 None,并把 /https://... 用例纳入拒绝路径的测试。

Comment on lines +299 to +304
def _get_declaration(self) -> FunctionDeclaration:
return FunctionDeclaration(
description=self.description,
name=self.name,
parameters_json_schema=self.input_schema,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: OpenAPITool._get_declaration(第 299-304 行)只设置 parameters_json_schema,从不设置 parameters;而 tool_prompt/_xml.py 第 49 行与 tool_prompt/_json.py 第 77-78 行只读取 func_decl.parameters,二者不互通。启用 add_tools_to_prompt=True(Hunyuan 渲染 prompt 注入模式需要)时,_openai_model.py 第 590-591 行生成的工具提示中每个 OpenAPI 工具只剩名称和描述,参数 schema 为空。

触发条件: 用户在 OpenAIModel 上设置 add_tools_to_prompt=True(如 Hunyuan 场景),并把 OpenAPIToolSet 挂到 agent 的 tools 上。

实际影响: 模型在 prompt 中看不到任何参数定义(必填项、嵌套对象、枚举全部丢失),无法生成符合 schema 的调用参数,工具调用会因缺少必填参数或类型错误而持续失败,OpenAPI 工具在该模式下实际不可用,且无任何报错提示。

修正方向: 扩展 tool_prompt 的序列化器,使其在 func_decl.parameters 为 None 时回退读取 func_decl.parameters_json_schema(或让 _get_declaration 同时填充 parameters,二选一并在两处保持一致);并在测试中补充 add_tools_to_prompt=True 渲染 OpenAPI 工具的用例。

Comment on lines +53 to +57
def __init__(self, *, operation_id: str, status_code: int, content: Any):
self.content = content
self.operation_id = operation_id
self.status_code = status_code
super().__init__(f"OpenAPI operation '{operation_id}' returned HTTP {status_code}: {content!r}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: OpenAPIHTTPError.__init__(第 53-57 行)把未经裁剪的完整响应体 content!r 拼进异常消息,而成功路径的 tool 响应会经过 _clip_tool_response_text 裁剪(_openai_model.py 第 719/731 行,tool_response_clip_chars 可配置);错误路径经 ToolsProcessor._create_error_event 原样转发,模型侧错误消息同样不经过裁剪。

触发条件: 被调用的 operation 返回 4xx/5xx,且响应体较大(如网关错误页、长堆栈或超大 JSON 错误体)。

实际影响: 完整错误体(数十 KB 甚至更大)被注入 tool error message 并进入下一轮 LLM 请求,直接膨胀模型上下文、增加 token 成本,长响应还可能挤占上下文窗口导致后续对话质量下降。

修正方向: 在 OpenAPIHTTPError 构造时对 content 做截断(例如保留前 N 字符并追加 ...[TRUNCATED] 标记),或复用 _clip_tool_response_text 的裁剪逻辑,使错误路径与成功路径的资源消耗一致。

Comment on lines +360 to +372
try:
response = await self._client.request(self._method, url, **request_kwargs)
except httpx.HTTPError as error:
raise OpenAPIToolError(f"OpenAPI operation '{self.name}' request failed: {error}") from error

content = _response_content(response)
if response.is_error:
raise OpenAPIHTTPError(operation_id=self.name, status_code=response.status_code, content=content)
return {
"content": content,
"content_type": response.headers.get("content-type", ""),
"status_code": response.status_code,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 第 366-372 行仅用 response.is_error 判断失败,而 httpx 中 3xx 的 is_error 为 False;同时 OpenAPIToolSet.__init__(第 400 行)创建 httpx.AsyncClient 时未设置 follow_redirects(默认 False),301/302/307/308 会被当作成功响应返回。

触发条件: 服务器返回任何 3xx(重定向、未修改、缓存命中)且 Location 指向的端点未被跟随时,或返回 3xx 错误体时。

实际影响: 重定向响应体(如 HTML 跳转页)和状态码 302 被当成 operation 的成功结果交回 LLM,模型会得到与业务成功不一致的假数据(例如认为已创建资源但实际被重定向),错误诊断也被误导。

修正方向: 将失败判断改为 response.is_error or response.status_code >= 300(或 status_code 不在 2xx 即抛 OpenAPIHTTPError),并显式决定 follow_redirects 策略(跟随时需注意重定向目标是否越出 allowed_hosts 约束);补充 3xx 响应的测试用例。

Comment on lines +453 to +462
@staticmethod
def _validate_operation_id(operation_id: Any, method: str, path: str, seen: set[str]) -> None:
location = f"{method.upper()} {path}"
if not isinstance(operation_id, str) or not operation_id:
raise OpenAPISpecError(f"OpenAPI operation {location} is missing operationId")
if not _OPERATION_ID_PATTERN.fullmatch(operation_id):
raise OpenAPISpecError(
f"Unsafe OpenAPI operationId {operation_id!r} at {location}; "
"use 1-64 letters, digits, underscores, or hyphens, starting with a letter or underscore")
if operation_id in seen:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _validate_operation_id(第 453-462 行)只按 ^[A-Za-z_][A-Za-z0-9_-]{0,63}$ 校验 operationId 的字面格式,不拒绝框架保留名;而 transfer_to_agent 与 set_model_response 在框架内按字符串特殊处理:_history_processor.py 第 289-291 行会把名为 transfer_to_agent 的 function_call/function_response 直接过滤出会话历史,_output_schema_processor.py 第 96-101 行与 _llm_agent.py 第 631-638 行会把任何名为 set_model_response 的 function_response 当作模型的最终 JSON 响应,并据此结束 agent 执行。

触发条件: OpenAPI 文档的某 operation 恰好以 transfer_to_agent 或 set_model_response 作为 operationId(格式校验允许),且被 LLM 调用。

实际影响: transfer_to_agent 命名的工具调用及结果会从历史中消失,破坏多轮对话上下文;set_model_response 命名的工具调用会让 agent 把 HTTP 响应体当作最终回答立即结束运行,对话流程被劫持,其余工具链执行被截断,用户得到错误且不完整的答案。

修正方向: 在 _validate_operation_id 中增加保留名黑名单(如 transfer_to_agent、set_model_response 等框架特殊处理的名字),检测到即抛 OpenAPISpecError;或在工具构建时对冲突名加前缀/重命名,并补充相应测试。

Comment on lines +361 to +362
response = await self._client.request(self._method, url, **request_kwargs)
except httpx.HTTPError as error:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 第 334 行把 header 形参值直接 str(value) 写入 header,未做 CRLF 校验;第 361-362 行仅捕获 httpx.HTTPError,而 httpx 底层发生 CRLF 注入时会抛 httpcore.LocalProtocolError(不是 httpx.HTTPError 子类),异常会穿透 except 向上传播。

触发条件: OpenAPI spec 声明 header 参数,且 LLM 生成的参数值包含 \r\n(或经 prompt injection 诱导)。

实际影响: 工具调用不是以 tool error event 失败(会被 ToolsProcessor 捕获并喂回 LLM),而是让异常冒泡击穿 agent 执行流程,导致整个 agent 运行崩溃;正常情况下可能基于头部错误注入 HTTP 头。

修正方向: 在写入 header 前丢弃或替换值中的 \r/\n(或复用 httpx 的 header 校验逻辑),同时把异常捕获扩展为 (httpx.HTTPError, httpcore.LocalProtocolError),保证请求层错误统一降级为 OpenAPIToolError。

Comment on lines +134 to +137
cp .env.example .env
python run_agent.py
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本文件(第 134 行)与英文版 docs/mkdocs/en/openapi_tools.md(第 137 行)都指导先执行 cp .env.example .env,但本次提交只包含 examples/openapi_tools/.env,没有 .env.example(git ls-files 确认仓库内不存在该文件)。

触发条件: 用户按文档快速上手时按文档中的安装流程执行命令,cp 报「文件不存在」,首次体验即中断。

实际影响: 文档开箱体验失败;用户不得不手动发现需要自行创建 .env,或误以为示例不可用,影响新特性推广。

修正方向: 新增 examples/openapi_tools/.env.example(字段与现有 .env 一致,值为空占位符),或把文档和示例 README 中的命令改为直接编辑 examples/openapi_tools/.env。

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

不通过

审查范围:base b6fc4f3..head c4bd41d,计划为「feat(tools): add OpenAPI toolset」。新增 trpc_agent_sdk/tools/_openapi_tools.py(OpenAPI 3.x → BaseToolSet 转换:局部 $ref 解析、参数收集、servers 选择、nullable 处理、base_url/allowed_hosts 校验、httpx 客户端与 close 生命周期、输入 schema 生成)、_openai_model.py 工具参数透传分支、569 行单测、a2a 之外的 OpenAPIToolSet 导出与示例/文档。

计划符合性:整体功能齐全(工具构建、参数/requestBody、$ref、servers 优先级、operationId 过滤、allowlist、错误类型划分、测试和文档齐备),但与周边组件的契约存在多处未对齐。

主要风险:(1)SEVERE——A2A 服务必调的 initialize() 卡片构建流程会对 agent.tools 中所有 BaseToolSet 调用 close(),而 OpenAPIToolSet 默认自建 httpx 客户端且 close() 后不重建,执行器复用同一工具集导致每次 OpenAPI 调用抛未捕获的 RuntimeError,A2A+OpenAPI 确定性不可用(经代码路径与 httpx 实测双重确认);(2)nullable:true 被改成 type:["X","null"] 数组并原样透传所有后端,Anthropic/Deepseek/Vertex(经 LiteLLM) 存在 400 拒绝风险(OpenAI 主链路接受该形式);(3)_get_declaration 只填 parameters_json_schema,add_tools_to_prompt(Hunyuan 强制)与 Claude 服务端路径读到空 schema;(4)urljoin 点段折叠使路径参数中的 .. 可逃逸操作声明范围;(5)SimpleCookie 转义损坏 cookie 值且超大 Cookie 头抛未捕获 ValueError。

测试充分性:14 个工具集单测覆盖构建/参数/过滤/错误/ref 基础场景,2 个模型层透传测试;但缺少 nullable、add_tools_to_prompt、cookie 已编码、点段逃逸、超大 header、A2A 生命周期复用等关键场景,未使用任何集成测试覆盖 a2a 与 OpenAPIToolSet 的组合,测试无法拦截上述缺陷。

门禁结论:REVIEW_CODE_STATE_FAILED。存在 SEVERE 级阻断问题(A2A 生命周期关闭共享客户端);其余 MODERATE/LOW 问题也应在本变更合入前处理。

发现的问题

严重

trpc_agent_sdk/tools/_openapi_tools.py:415-417

问题: OpenAPIToolSet.close() 在默认自建客户端(_owns_client=True)时直接 aclose() 内部共享的 httpx.AsyncClient,且 get_tools() 不做任何重建;而 A2A 服务端的 AgentCard 构建流程会对 agent.tools 中所有 BaseToolSet 调用 await toolset.close()(server/a2a/_agent_card_builder.py 与 server/a2a_v1/_agent_card_builder.py 的 _build_tool_skills 均如此),执行器随后复用同一个 agent/工具集实例发起请求。

触发条件: 默认构造 OpenAPIToolSet(不传 client=)挂载到 LlmAgent 后部署 A2A 服务:a2a_svc.initialize() 是文档要求的必调步骤,其内部构建 AgentCard 时即关闭该工具集的客户端;之后 _create_executor 用同一 agent 的同一个 OpenAPIToolSet 执行任何工具调用。

实际影响: 每次 OpenAPI 调用在 self._client.request() 处抛出未被 except httpx.HTTPError 捕获的原始 RuntimeError("Cannot send a request, as the client has been closed.")(沙箱实测 RuntimeError 不是 httpx.HTTPError 子类),工具调用永久性、确定性失败,A2A 与 OpenAPI 组合功能完全不可用;对比 MCPToolSet 会在每次 get_tools() 重建会话,该缺陷是本次变更引入的独有行为。

修正方向: 让 close() 只释放引用并在 call() 前检测 client.is_closed 时惰性重建客户端,或使 A2A 卡片构建只提取工具元信息、不关闭工具集资源,与 MCPToolSet 的会话级重建策略保持一致。

中等

trpc_agent_sdk/tools/_openapi_tools.py:233-236

问题: _normalize_schema 将 OpenAPI 的 nullable: true 直接改写为 type: [X, "null"] 数组(嵌套属性经递归同样展开),随后 _openai_model.py 本变更新增的分支把它经 parameters_json_schema 不加任何规范化地透传给所有后端。

触发条件: specs 中任意参数或 requestBody schema 声明 nullable: true,且模型后端为 Anthropic(_anthropic_model.py:346-348 将 parameters_json_schema 原样填入 input_schema)、Deepseek 或经 LiteLLM 路由到 Gemini/Vertex 的配置。

实际影响: OpenAI 主链路接受 ["string","null"](官方文档将其作为可选字段的标准写法),但 Anthropic 服务端对 input_schema 做严格校验、Deepseek 对 null/联合形式有已知 400 拒绝记录、Vertex 要求 nullable: true 而非独立 null 分支(litellm 曾有专门修复),这些后端会以 400 拒绝整个 tools 列表,工具注册失败或会话不可用。

修正方向: 按后端规范化 nullable:默认保留 nullable 语义(如转成 anyOf 或保留 nullable 键),在模型层转换;或对已知严格后端降级为不含 null 分支的 schema,避免把 OpenAPI 方言原样发给所有后端。

中等

trpc_agent_sdk/tools/_openapi_tools.py:299-304

问题: _get_declaration() 只设置 parameters_json_schema=self.input_schema,google Schema 类型的 parameters 字段保持 None;而 tool_prompt/_json.py:77-80、tool_prompt/_xml.py:117-118 以及 server/agents/claude/_claude_agent.py:566 都只读取 func_decl.parameters,为空时生成空 schema。

触发条件: (a) 配置 OpenAIModel(add_tools_to_prompt=True)(文档化用法,且 Hunyuan 适配器 requires_add_tools_to_prompt() 强制该模式,禁用会抛 ValueError),_openai_model.py:590-592 将工具渲染进 system prompt;(b) 通过 Claude 服务端路径加载含 OpenAPI 工具集的 agent。

实际影响: 模型在 prompt 中看到的工具参数为 {"type": "object", "properties": {}}(XML 路径则为空 <parameters> 块),按此 schema 生成并解析工具调用,无法产出符合真实参数结构的参数,OpenAPI 工具在这些路径下调用质量严重下降甚至不可用,且测试只断言了 parameters_json_schema,未覆盖该缺口。

修正方向: 在 _get_declaration() 中同时由 input_schema 构造 parameters=Schema(...),或让 tool_prompt 构建器与 _claude_agent.py 在 parameters 为空时回退读取 parameters_json_schema。

中等

trpc_agent_sdk/tools/_openapi_tools.py:354

问题: urljoin(self._base_url + "/", path.lstrip("/")) 会按相对路径规则解析点段:路径参数值经 quote(..., safe="") 后 .. 原样保留,urljoin 将其折叠,使最终请求路径脱离操作声明范围。

触发条件: 操作路径形如 /items/{id},参数值由模型(不受信输入)提供为 ..、../admin 等——沙箱实测 urljoin("https://api.example.com/v1/", "pets/../../admin") → https://api.example.com/admin;allowed_hosts 只校验主机不校验路径,主机未变即可通过,未配置 allowed_hosts(默认 None)时更无约束。

实际影响: 请求被静默发往同一主机上操作声明之外的端点(如管理接口),既是资源语义错误也构成范围内越权;且 .. 值不会被拒绝或告警,问题难以察觉。

修正方向: 在替换路径参数后、组装 URL 前拒绝含 ../. 段的路径,或改用不解析点段的 urllib.parse.urlunsplit 组装,并将最终 URL 的 path 与规范化的操作路径比对。

中等

trpc_agent_sdk/tools/_openapi_tools.py:342-343

问题: 用 SimpleCookie 序列化 cookie 参数会按 RFC 6265 转义特殊字符(沙箱实测 ; → \073、空格等均被转义),服务端通常不会解码回原值;且全部 cookie 拼进单个 Cookie 头,htxt 对超过约 4KB 的单头值直接抛原始 ValueError(非 httpx.HTTPError,不被 L360-363 的异常包装捕获)。

触发条件: 操作声明 cookie 参数且参数值含 ;、空格、非 ASCII 字符(模型常见的多 cookie 字符串如 session=abc; theme=dark 是最自然的触发输入);或 cookie 数量多/值大(沙箱实测 100 个 cookie 序列化 5588 字节即触发 httpx 报错)。

实际影响: 服务端收到被转义的损坏 cookie(会话/认证参数失效、身份校验失败);超大 Cookie 头则抛出未包装的原始 ValueError,工具调用以裸异常终止,与文档承诺的 OpenAPIToolError 错误契约不符。

修正方向: 不再使用 SimpleCookie,按参数直接拼装 Name=value 并对整个 cookie 值做 URL 编码;同时对 header 总大小设上限或把异常捕获扩展为 (httpx.HTTPError, ValueError, TypeError) 统一转成 OpenAPIToolError。

较低

trpc_agent_sdk/tools/_openapi_tools.py:360-363

问题: call() 只在 try 内执行 self._client.request() 且仅包装 except httpx.HTTPError,但 httpx 在构造 Request 时对 json= 参数执行 json_dumps,request_body 含 datetime 等不可序列化对象时抛出 TypeError(沙箱实测 issubclass(TypeError, httpx.HTTPError) == False),客户端已关闭时抛出 RuntimeError,都会原样漏出。

触发条件: 模型或调用方传入含 datetime/自定义对象等非 JSON 可序列化值的 request_body;或客户端被关闭后再调用(与 A2A 生命周期问题叠加时会放大其影响面)。

实际影响: 上层 runner/执行器收到未预期的异常类型,错误信息晦涩;直接使用 tool.call() 的用户代码捕获不到文档承诺的 OpenAPIToolError,错误契约被破坏。

修正方向: 将 try 范围前移到参数组装与序列化阶段,except (httpx.HTTPError, TypeError, ValueError, RuntimeError),对不可序列化数据给出明确提示后转为 OpenAPIToolError。

较低

trpc_agent_sdk/tools/_openapi_tools.py:175-181

问题: _validate_base_url 只拒绝凭据与 fragment,允许 URL 携带 query 字符串;而 call() 的 urljoin 拼接在 base 含 query 时会把 query 丢弃并替换路径最后一段(沙箱实测 urljoin("https://x/v1?env=prod/", "items") → https://x/items)。

触发条件: 用户 base_url 或 OpenAPI servers 条目 URL 包含查询串(如签名/环境参数 https://api.example.com/v1?env=prod)时,该工具集所有操作全部路由到错误地址。

实际影响: 请求静默发往错误的路径(丢失 /v1 前缀与整段 query),出现难以排查的 404/401 类故障。

修正方向: 在 _validate_base_url 中显式拒绝带 query 的 base URL 并给出清晰错误;或改用 urlunsplit 显式组装 path 与 query,不依赖 urljoin 的隐式解析。

较低

trpc_agent_sdk/tools/_openapi_tools.py:395-400

问题: 构造 OpenAPIToolSet 时若同时传入 client=,auth/timeout 会被静默忽略(它们只应用于自建客户端分支),没有任何错误或告警。

触发条件: 用户提供自建客户端并同时配置 auth=BasicAuth(...) 或 timeout=,期望其生效。

实际影响: 请求以未认证/默认超时发出,得到 401 或超时故障,但原因无法从代码路径察觉,排障成本高。

修正方向: 在 client 与 auth/timeout 同时传入时抛出 OpenAPISpecError 明确互斥,或在 call() 中把 auth 应用到请求级。

较低

trpc_agent_sdk/tools/_openapi_tools.py:250-259

问题: _response_content 对响应体没有任何大小上限,OpenAI 路径的 tool_response_clip_chars 裁剪默认配置为 0(关闭),Anthropic 路径完全没有裁剪机制。

触发条件: 对接的 OpenAPI 服务返回大体积响应(大列表、导出文件、日志聚合等接口),模型工具结果随之整体进入上下文。

实际影响: 单次工具调用即可向模型上下文注入数 MB 文本,快速耗尽上下文窗口导致请求失败或成本剧增,存在资源放大风险。

修正方向: 为 _response_content 提供可配置的 max_chars 上限(参照 webfetch 的 max_length 模式),超限时截断并标注截断标记。

较低

examples/openapi_tools/.env:1

问题: 文档 docs/mkdocs/zh/openapi_tools.md 与 docs/mkdocs/en/openapi_tools.md 均指引用户执行 cp .env.example .env,但本变更只提交了值为空的 .env 文件,仓库中没有 .env.example。

触发条件: 用户按文档操作时 cp .env.example .env 直接失败;按仓库自带 .env 直接运行示例时 TRPC_AGENT_API_KEY 等三个必填环境变量为空。

实际影响: 示例无法按文档引导运行,新用户上手即遇到失败,示例可复现性差。

修正方向: 提交 .env.example 模板(或改为「编辑 .env」的指引),并在示例启动前对缺失环境变量做显式校验与提示。

Comment on lines +415 to +417
async def close(self) -> None:
if self._owns_client:
await self._client.aclose()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: OpenAPIToolSet.close() 在默认自建客户端(_owns_client=True)时直接 aclose() 内部共享的 httpx.AsyncClient,且 get_tools() 不做任何重建;而 A2A 服务端的 AgentCard 构建流程会对 agent.tools 中所有 BaseToolSet 调用 await toolset.close()(server/a2a/_agent_card_builder.py 与 server/a2a_v1/_agent_card_builder.py 的 _build_tool_skills 均如此),执行器随后复用同一个 agent/工具集实例发起请求。

触发条件: 默认构造 OpenAPIToolSet(不传 client=)挂载到 LlmAgent 后部署 A2A 服务:a2a_svc.initialize() 是文档要求的必调步骤,其内部构建 AgentCard 时即关闭该工具集的客户端;之后 _create_executor 用同一 agent 的同一个 OpenAPIToolSet 执行任何工具调用。

实际影响: 每次 OpenAPI 调用在 self._client.request() 处抛出未被 except httpx.HTTPError 捕获的原始 RuntimeError("Cannot send a request, as the client has been closed.")(沙箱实测 RuntimeError 不是 httpx.HTTPError 子类),工具调用永久性、确定性失败,A2A 与 OpenAPI 组合功能完全不可用;对比 MCPToolSet 会在每次 get_tools() 重建会话,该缺陷是本次变更引入的独有行为。

修正方向: 让 close() 只释放引用并在 call() 前检测 client.is_closed 时惰性重建客户端,或使 A2A 卡片构建只提取工具元信息、不关闭工具集资源,与 MCPToolSet 的会话级重建策略保持一致。

Comment on lines +233 to +236
if nullable:
schema_type = normalized.get("type")
if isinstance(schema_type, str):
normalized["type"] = [schema_type, "null"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _normalize_schema 将 OpenAPI 的 nullable: true 直接改写为 type: [X, "null"] 数组(嵌套属性经递归同样展开),随后 _openai_model.py 本变更新增的分支把它经 parameters_json_schema 不加任何规范化地透传给所有后端。

触发条件: specs 中任意参数或 requestBody schema 声明 nullable: true,且模型后端为 Anthropic(_anthropic_model.py:346-348 将 parameters_json_schema 原样填入 input_schema)、Deepseek 或经 LiteLLM 路由到 Gemini/Vertex 的配置。

实际影响: OpenAI 主链路接受 ["string","null"](官方文档将其作为可选字段的标准写法),但 Anthropic 服务端对 input_schema 做严格校验、Deepseek 对 null/联合形式有已知 400 拒绝记录、Vertex 要求 nullable: true 而非独立 null 分支(litellm 曾有专门修复),这些后端会以 400 拒绝整个 tools 列表,工具注册失败或会话不可用。

修正方向: 按后端规范化 nullable:默认保留 nullable 语义(如转成 anyOf 或保留 nullable 键),在模型层转换;或对已知严格后端降级为不含 null 分支的 schema,避免把 OpenAPI 方言原样发给所有后端。

Comment on lines +299 to +304
def _get_declaration(self) -> FunctionDeclaration:
return FunctionDeclaration(
description=self.description,
name=self.name,
parameters_json_schema=self.input_schema,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _get_declaration() 只设置 parameters_json_schema=self.input_schema,google Schema 类型的 parameters 字段保持 None;而 tool_prompt/_json.py:77-80、tool_prompt/_xml.py:117-118 以及 server/agents/claude/_claude_agent.py:566 都只读取 func_decl.parameters,为空时生成空 schema。

触发条件: (a) 配置 OpenAIModel(add_tools_to_prompt=True)(文档化用法,且 Hunyuan 适配器 requires_add_tools_to_prompt() 强制该模式,禁用会抛 ValueError),_openai_model.py:590-592 将工具渲染进 system prompt;(b) 通过 Claude 服务端路径加载含 OpenAPI 工具集的 agent。

实际影响: 模型在 prompt 中看到的工具参数为 {"type": "object", "properties": {}}(XML 路径则为空 <parameters> 块),按此 schema 生成并解析工具调用,无法产出符合真实参数结构的参数,OpenAPI 工具在这些路径下调用质量严重下降甚至不可用,且测试只断言了 parameters_json_schema,未覆盖该缺口。

修正方向: 在 _get_declaration() 中同时由 input_schema 构造 parameters=Schema(...),或让 tool_prompt 构建器与 _claude_agent.py 在 parameters 为空时回退读取 parameters_json_schema。

elif self._request_body_required:
raise OpenAPIToolError(f"Missing required argument 'request_body' for operation '{self.name}'")

url = urljoin(self._base_url + "/", path.lstrip("/"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: urljoin(self._base_url + "/", path.lstrip("/")) 会按相对路径规则解析点段:路径参数值经 quote(..., safe="") 后 .. 原样保留,urljoin 将其折叠,使最终请求路径脱离操作声明范围。

触发条件: 操作路径形如 /items/{id},参数值由模型(不受信输入)提供为 ..、../admin 等——沙箱实测 urljoin("https://api.example.com/v1/", "pets/../../admin") → https://api.example.com/admin;allowed_hosts 只校验主机不校验路径,主机未变即可通过,未配置 allowed_hosts(默认 None)时更无约束。

实际影响: 请求被静默发往同一主机上操作声明之外的端点(如管理接口),既是资源语义错误也构成范围内越权;且 .. 值不会被拒绝或告警,问题难以察觉。

修正方向: 在替换路径参数后、组装 URL 前拒绝含 ../. 段的路径,或改用不解析点段的 urllib.parse.urlunsplit 组装,并将最终 URL 的 path 与规范化的操作路径比对。

Comment on lines +342 to +343
if cookies:
headers["Cookie"] = cookies.output(header="", sep=";").strip()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 用 SimpleCookie 序列化 cookie 参数会按 RFC 6265 转义特殊字符(沙箱实测 ; → \073、空格等均被转义),服务端通常不会解码回原值;且全部 cookie 拼进单个 Cookie 头,htxt 对超过约 4KB 的单头值直接抛原始 ValueError(非 httpx.HTTPError,不被 L360-363 的异常包装捕获)。

触发条件: 操作声明 cookie 参数且参数值含 ;、空格、非 ASCII 字符(模型常见的多 cookie 字符串如 session=abc; theme=dark 是最自然的触发输入);或 cookie 数量多/值大(沙箱实测 100 个 cookie 序列化 5588 字节即触发 httpx 报错)。

实际影响: 服务端收到被转义的损坏 cookie(会话/认证参数失效、身份校验失败);超大 Cookie 头则抛出未包装的原始 ValueError,工具调用以裸异常终止,与文档承诺的 OpenAPIToolError 错误契约不符。

修正方向: 不再使用 SimpleCookie,按参数直接拼装 Name=value 并对整个 cookie 值做 URL 编码;同时对 header 总大小设上限或把异常捕获扩展为 (httpx.HTTPError, ValueError, TypeError) 统一转成 OpenAPIToolError。

Comment on lines +360 to +363
try:
response = await self._client.request(self._method, url, **request_kwargs)
except httpx.HTTPError as error:
raise OpenAPIToolError(f"OpenAPI operation '{self.name}' request failed: {error}") from error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: call() 只在 try 内执行 self._client.request() 且仅包装 except httpx.HTTPError,但 httpx 在构造 Request 时对 json= 参数执行 json_dumps,request_body 含 datetime 等不可序列化对象时抛出 TypeError(沙箱实测 issubclass(TypeError, httpx.HTTPError) == False),客户端已关闭时抛出 RuntimeError,都会原样漏出。

触发条件: 模型或调用方传入含 datetime/自定义对象等非 JSON 可序列化值的 request_body;或客户端被关闭后再调用(与 A2A 生命周期问题叠加时会放大其影响面)。

实际影响: 上层 runner/执行器收到未预期的异常类型,错误信息晦涩;直接使用 tool.call() 的用户代码捕获不到文档承诺的 OpenAPIToolError,错误契约被破坏。

修正方向: 将 try 范围前移到参数组装与序列化阶段,except (httpx.HTTPError, TypeError, ValueError, RuntimeError),对不可序列化数据给出明确提示后转为 OpenAPIToolError。

Comment on lines +175 to +181
def _validate_base_url(value: str) -> str:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise OpenAPISpecError(f"OpenAPI base URL must be an absolute HTTP(S) URL: {value!r}")
if parsed.username or parsed.password or parsed.fragment:
raise OpenAPISpecError("OpenAPI base URL must not contain credentials or a fragment")
return value.rstrip("/")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _validate_base_url 只拒绝凭据与 fragment,允许 URL 携带 query 字符串;而 call() 的 urljoin 拼接在 base 含 query 时会把 query 丢弃并替换路径最后一段(沙箱实测 urljoin("https://x/v1?env=prod/", "items") → https://x/items)。

触发条件: 用户 base_url 或 OpenAPI servers 条目 URL 包含查询串(如签名/环境参数 https://api.example.com/v1?env=prod)时,该工具集所有操作全部路由到错误地址。

实际影响: 请求静默发往错误的路径(丢失 /v1 前缀与整段 query),出现难以排查的 404/401 类故障。

修正方向: 在 _validate_base_url 中显式拒绝带 query 的 base URL 并给出清晰错误;或改用 urlunsplit 显式组装 path 与 query,不依赖 urljoin 的隐式解析。

Comment on lines +395 to +400
if client is not None and transport is not None:
raise OpenAPISpecError("Provide either client or transport, not both")
self.document = load_openapi_document(source)
self._allowed_hosts = _normalize_allowed_hosts(allowed_hosts)
self._owns_client = client is None
self._client = client or httpx.AsyncClient(auth=auth, timeout=timeout, transport=transport)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 构造 OpenAPIToolSet 时若同时传入 client=,auth/timeout 会被静默忽略(它们只应用于自建客户端分支),没有任何错误或告警。

触发条件: 用户提供自建客户端并同时配置 auth=BasicAuth(...) 或 timeout=,期望其生效。

实际影响: 请求以未认证/默认超时发出,得到 401 或超时故障,但原因无法从代码路径察觉,排障成本高。

修正方向: 在 client 与 auth/timeout 同时传入时抛出 OpenAPISpecError 明确互斥,或在 call() 中把 auth 应用到请求级。

Comment on lines +250 to +259
def _response_content(response: httpx.Response) -> Any:
if not response.content:
return None
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type == "application/json" or content_type.endswith("+json"):
try:
return response.json()
except ValueError:
return response.text
return response.text

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _response_content 对响应体没有任何大小上限,OpenAI 路径的 tool_response_clip_chars 裁剪默认配置为 0(关闭),Anthropic 路径完全没有裁剪机制。

触发条件: 对接的 OpenAPI 服务返回大体积响应(大列表、导出文件、日志聚合等接口),模型工具结果随之整体进入上下文。

实际影响: 单次工具调用即可向模型上下文注入数 MB 文本,快速耗尽上下文窗口导致请求失败或成本剧增,存在资源放大风险。

修正方向: 为 _response_content 提供可配置的 max_chars 上限(参照 webfetch 的 max_length 模式),超限时截断并标注截断标记。

@@ -0,0 +1,4 @@
TRPC_AGENT_API_KEY=

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 文档 docs/mkdocs/zh/openapi_tools.md 与 docs/mkdocs/en/openapi_tools.md 均指引用户执行 cp .env.example .env,但本变更只提交了值为空的 .env 文件,仓库中没有 .env.example。

触发条件: 用户按文档操作时 cp .env.example .env 直接失败;按仓库自带 .env 直接运行示例时 TRPC_AGENT_API_KEY 等三个必填环境变量为空。

实际影响: 示例无法按文档引导运行,新用户上手即遇到失败,示例可复现性差。

修正方向: 提交 .env.example 模板(或改为「编辑 .env」的指引),并在示例启动前对缺失环境变量做显式校验与提示。

@raychen911
raychen911 merged commit b80d43a into main Sep 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants