Skip to content

feat: add youcom (You.com) provider to WebSearchTool - #358

Open
valueadd-lgtm wants to merge 7 commits into
trpc-group:mainfrom
valueadd-lgtm:feat/youcom-search-integration
Open

valueadd-lgtm wants to merge 7 commits into
trpc-group:mainfrom
valueadd-lgtm:feat/youcom-search-integration

Conversation

@valueadd-lgtm

Copy link
Copy Markdown

Why

The default duckduckgo backend is keyless but returns DDG's curated instant-answer set rather than real web results, and the google / tavily backends both require an API key before they return anything. You.com exposes its you-search MCP tool through a keyless free-profile endpoint (https://api.you.com/mcp?profile=free), so it fits the existing provider surface as a zero-setup backend that returns real web hits — the same property that makes the DDG default attractive.

What changed

  • trpc_agent_sdk/tools/_websearch_tool.py: new "youcom" provider following the existing provider pattern — ProviderType literal, _search_youcom dispatch, youcom_extra_params passthrough, YDC_API_KEY env fallback. The endpoint is stateless JSON-RPC over streamable HTTP: one tools/call POST answered by a text/event-stream body, parsed by a new _parse_sse_payloads helper (progress notifications are skipped, unparseable lines ignored). Keyless by default; a configured key switches to https://api.you.com/mcp with a Bearer header.
    • web + news hits map to SearchHit (description → snippet, first query-relevant highlight as fallback)
    • lang maps to You.com's inline lang: filter; blocked_domains map to server-side exclude_domains, allowed_domains filter client-side (plus the usual _is_blocked pass)
    • JSON-RPC errors and MCP isError results surface as summary text instead of raising
  • tests/tools/test_websearch_tool.py: TestYoucomProvider mirroring the Tavily coverage — SSE parsing, keyless/authenticated base URLs, Bearer header, Accept header requirement (the endpoint returns 406 for JSON-only), inline lang: mapping, server-side exclude_domains, client-side allowlist, extra params, count clamping, URL dedup, JSON-RPC/isError/missing-result/non-JSON error paths. Plus keyless-by-default and env-fallback init tests and a base-props-only declaration check.
  • docs/mkdocs/en/tool.md + docs/mkdocs/zh/tool.md: backend lists, parameter tables, return-fields table, and a usage example for the keyless configuration (the English page also catches up the missing tavily rows it was lagging).

Default behavior is unchanged: provider="duckduckgo" stays the default and all existing provider paths are untouched — this is purely additive and opt-in via provider="youcom".

Setup

No setup required — the free profile is keyless. Optional upgrades:

web_search = WebSearchTool(
    provider="youcom",
    # api_key=os.getenv("YDC_API_KEY"),  # optional: switches to the authenticated endpoint
    youcom_extra_params={"freshness": "week"},
)

Validation

  • pytest tests/tools/test_websearch_tool.py → 91 passed (75 pre-existing + 16 new)
  • yapf --diff and flake8 clean on both changed Python files (the CI checks)
  • Live smoke test against the real keyless endpoint returned real results through the new provider (no key, no auth header)
  • The failing tests elsewhere in the suite (test_agent_tool.py, test_function_parameter_parse.py, langgraph/DSL collection errors) fail identically on main in this environment — unrelated to this change

For context, You.com also ships ready-made agent skills and MCP server configs (https://github.com/youdotcom-oss/agent-skills) for harnesses that want the full MCP server rather than a single tool.

Happy to adjust the shape if you'd rather land this differently — e.g. folding the SSE handling into the existing _post_json helper, or trimming the docs diff.

Tracking: youdotcom-oss/integration-tracking#477

Add a fourth pluggable search backend, "youcom", which calls the You.com
you-search MCP tool over stateless JSON-RPC (streamable HTTP):

- keyless by default via the free-profile endpoint
  (https://api.you.com/mcp?profile=free); YDC_API_KEY (or api_key)
  switches to the authenticated endpoint
- maps web + news hits to SearchHit (description first, query-relevant
  highlights as snippet fallback)
- lang maps to the inline lang: filter; blocked_domains map to
  server-side exclude_domains, allowed_domains filter client-side
- JSON-RPC / tool errors surface as structured summaries
- youcom_extra_params passthrough (freshness, safesearch, ...)
- tests: SSE parsing, keyless/authenticated URLs, Bearer header, domain
  filtering, extra params, error paths, dedup
- docs updated (en + zh): backend list, parameter tables, usage example

Default provider and all existing behavior unchanged.
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

CLA Assistant Lite bot All contributors have signed the CLA ✍️ ✅

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

审查范围:commit ed846d4(base 02509b2)为 WebSearchTool 新增 youcom(You.com you-search MCP)provider,共 4 个变更文件(trpc_agent_sdk/tools/_websearch_tool.py、tests/tools/test_websearch_tool.py、docs/mkdocs 英文与中文文档),对全部非生成变更文件完成了逐行、跨文件、协议与语言语义层面的审查。

计划符合性:计划为“add youcom (You.com) provider to WebSearchTool”。实现完整覆盖计划:youcom_extra_params 构造参数、YDC_API_KEY 环境变量回退、free/认证端点自动切换、_run_async_impl 分派分支、ProviderType Literal 扩展、SSE 解析(_parse_sse_payloads/_post_sse)与 15 个新增单元测试,中英文文档同步更新,无缺项。

测试充分性:正常路径、lang 内联过滤、域名过滤、extra params、count 限制、URL 去重、JSON-RPC 错误、isError、无结果与畸形 text 等路径均有覆盖,且所有新测试均正确关闭 mock client(aclose 无遗漏),测试内嵌 mock 与实现假设一致。缺口主要在与上游真实行为相关的畸形响应形态(highlights 非数组、isError 空文本、SSE 多行帧、results 形状漂移)上,已随各条评论给出。

主要风险:已证实 2 个 MODERATE 问题——(1) highlights[0] 未做类型校验,字符串→单字符 snippet、dict→KeyError 逃逸到 _run_async_impl 宽泛 except 使整次搜索变 SEARCH_ERROR;(2) isError=true 且 text 为空时落入误导性的 unexpected payload 摘要、且从不读取 MCP structuredContent。另有 3 个机制真实但触发依赖上游行为的 PLAUSIBLE 问题(error/result 帧优先级与 id 匹配、lang: 操作符拼接、SSE 跨行 data 帧/BOM)。经核实的排除项:域名 allow/block 列表有第 868 行 _is_blocked 客户端二次过滤、无绕过;JSON 对象键恒为字符串,"result" in item 不可能抛 TypeError;text 经 str() 强转后 json.loads 的类型异常已被捕获;_post_sse 客户端生命周期与既有 _get_json/_post_json 模式一致(注入 client 不 aclose、内部 client 在 finally 关闭),无泄漏。未发现安全、数据完整性或向后兼容性破坏。

门禁结论:无 SEVERE 级缺陷,state 为 PASSED。建议后续针对上述已证实问题(highlights 类型防御、isError 空文本路径)及其余 PLAUSIBLE 项补充畸形 payload 测试并修复。

发现的问题

中等

trpc_agent_sdk/tools/_websearch_tool.py:877-880

问题: _search_youcom 中 highlights 的类型校验缺失:代码只检查了 contents 是否为 dict,随后 if highlights: description = str(highlights[0]).strip() 直接对 highlights[0] 按下标取值,与其它 provider 对每个字段先做类型判断的防御风格不一致。

触发条件: You.com 返回的命中项中 contents.highlights 不是数组。若为字符串(如 "some text"),highlights[0] 取到单字符 "s";若为 dict 或其它类型,highlights[0] 抛 KeyError/TypeError。该异常在 _search_youcom 内部未捕获,逃逸到 _run_async_impl 第 566 行的宽泛 except Exception(SEARCH_ERROR)。已实测复现两种行为。

实际影响: 一条畸形命中会把整次成功搜索变成 {"error": "SEARCH_ERROR: ..."},所有已解析的合法命中与 query 一并丢失,LLM 无法降级使用部分结果;highlights 为字符串时用户看到 1 个字符的残缺 snippet。

修正方向: 与其它字段一致做类型防御:if isinstance(highlights, list) and highlights:,并对首元素做元素级校验后取值,或至少用 try/except 包裹该段,避免异常逃逸出 _search_youcom。

中等

trpc_agent_sdk/tools/_websearch_tool.py:834-845

问题: 工具错误分支 if result.get("isError") and text: 要求 text 非空才返回错误摘要;若 isError=true 且 content[].text 为空(或信息只存在于 MCP 规范的 structuredContent 字段),代码落入后续 json.loads(""),被捕获后返回误导性的 "You.com search returned an unexpected payload." 成功形态摘要,且代码从未读取 structuredContent。

触发条件: 上游服务端标记 isError: true 但未携带 text 内容(MCP 层常如此),或把结构化结果放在 structuredContent 而 text 仅为人读摘要。两种情况在单元测试中均未覆盖(测试只覆盖了 isError 且 text 非空、以及 text 携带 JSON 两种形态)。

实际影响: LLM 收到 "unexpected payload" 而非真实错误信息,无法区分“上游失败”与“响应格式异常”,文档承诺的“错误写入 summary”契约在该形态下失效,排障与降级都受到误导,并可能因此误报为“无结果”。

修正方向: 将 isError 判定提前到内容提取之前:先检查 result.get("isError"),无论 text 是否为空都返回 You.com search error: ... 摘要;可同时读取 structuredContent 作为 text 的补充来源。

中等

trpc_agent_sdk/tools/_websearch_tool.py:806-813

问题: 对 SSE 帧的扫描 if "result" in item ... elif "error" in item 使“先 error 后 result”或“同帧双键”的流中 error 信息被错误取舍:错误分支在循环外 if error is not None 无条件优先于 result,且末帧胜出语义未按 JSON-RPC 的 id 匹配(请求固定 id: 1,响应未校验 id)。

触发条件: 流内任一帧(如进度通知)携带 error 键且其后跟有效 result 帧(已用模拟流实测:error 残留导致有效结果被丢弃);或单帧同时含 result: null 与 error(elif 使 error 被丢弃,最终报 "no result payload")。

实际影响: 合法搜索结果被误报为错误摘要,或真实限流/配额错误被吞掉并表现为空结果成功,两种情况都与文档“错误写入 summary”的契约相反,结果不稳定且难以排障。

修正方向: 按 JSON-RPC 语义处理:只接受与请求 id 匹配的帧;对同帧 result+error 共存时以 error 为准(先判 error 再判 result 或独立记录);采用“最后一帧有效结果、显式指定优先级”的明确策略并补充对应测试。

较低

trpc_agent_sdk/tools/_websearch_tool.py:285-307

问题: _parse_sse_payloads 把每个物理 data: 行当作独立 JSON 文档解析,未实现 SSE 规范允许的“单条 data 帧跨多行(后续行以空格开头)拼接”语义;第 297 行 line[len("data:"):] 只按行切片,且未处理首个 data: 行前的 UTF-8 BOM(Python 默认 utf-8 解码不剥离 BOM,line.startswith("data:") 对该行直接失配)。

触发条件: 上游把长 JSON 结果拆分为两行 data: 帧(两段各自无法被 json.loads 解析而被静默跳过,实测确认),或响应体以 BOM 起始;二者都会让一次 200 成功响应被解析为空帧列表。

实际影响: _search_youcom 返回 "You.com search returned no result payload." 空结果——成功搜索被误报为无结果,LLM 无法区分“真无结果”与“解析失败”,影响召回准确性。

修正方向: 按 SSE 规范支持跨行 data 帧(累计同一事件的多行 data 并检查本行是否为续行 data:),并在首行前剥离 BOM;或直接使用仓库已声明的 httpx-sse 依赖(pyproject 已含 httpx-sse>=0.4.0)解析。

较低

trpc_agent_sdk/tools/_websearch_tool.py:783-789

问题: arguments.update(self._youcom_extra_params) 在 query/count/lang: 重写之后执行,youcom_extra_params 中若包含 query、count 或 exclude_domains 键会静默覆盖本工具构造的核心参数;且 lang 值未做任何清洗,schema 描述(第 456 行)明确告诉模型“You.com maps it to the inline lang: filter”,模型可能直接传 lang="lang:ja",拼接出 query lang:lang:ja。

触发条件: 调用方固化 youcom_extra_params={"query": "...", "count": 3} 等键,或 LLM 按 schema 字面解释传入已带 lang: 前缀的 lang 值;两种都无任何校验或警告。

实际影响: 调用时的 query / 语言过滤被静默替换或失效(lang:lang: 双操作符无法被服务端正确匹配),行为与文档“每次工具调用自动带上固定高级参数、不覆盖调用参数”的表述不符,且无日志可追踪。

修正方向: 仅允许 extra params 覆盖白名单之外的键(对 query/count/exclude_domains 做保护性合并,异常键记录 warning),并给 lang 增加防重前缀清洗(如去除已存在的 lang: 前缀)。

较低

trpc_agent_sdk/tools/_websearch_tool.py:855-862

问题: sections = data.get("results"); if not isinstance(sections, dict): sections = {} 对 dict 但形状非预期的响应(如 results 为数组、或嵌套层级与实现假设不同)静默降级为 {},返回 summary="" 的空成功结果,未写入任何错误信号;测试仅覆盖了 results 为 dict 的正常形态。

触发条件: You.com 响应结构漂移(如 results 变更为扁平数组,或 free profile 命中位于其它嵌套层级)时发生。

实际影响: LLM 收到“搜索成功但 0 结果”,会如实向用户报告“未找到结果”而不会重试,与其它错误路径“结构化错误写入 summary”的契约不一致,掩盖了真实的协议兼容问题。

修正方向: 对非 dict 且非空的 results 返回显式提示(如 _truncate("You.com search returned an unexpected results shape.", ...) 写入 summary),并在映射循环中对 sections.get(section) 的非 list 值同样降级提示。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +877 to +880
contents = item.get("contents")
highlights = contents.get("highlights") if isinstance(contents, dict) else None
if highlights:
description = str(highlights[0]).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.

问题: _search_youcom 中 highlights 的类型校验缺失:代码只检查了 contents 是否为 dict,随后 if highlights: description = str(highlights[0]).strip() 直接对 highlights[0] 按下标取值,与其它 provider 对每个字段先做类型判断的防御风格不一致。

触发条件: You.com 返回的命中项中 contents.highlights 不是数组。若为字符串(如 "some text"),highlights[0] 取到单字符 "s";若为 dict 或其它类型,highlights[0] 抛 KeyError/TypeError。该异常在 _search_youcom 内部未捕获,逃逸到 _run_async_impl 第 566 行的宽泛 except Exception(SEARCH_ERROR)。已实测复现两种行为。

实际影响: 一条畸形命中会把整次成功搜索变成 {"error": "SEARCH_ERROR: ..."},所有已解析的合法命中与 query 一并丢失,LLM 无法降级使用部分结果;highlights 为字符串时用户看到 1 个字符的残缺 snippet。

修正方向: 与其它字段一致做类型防御:if isinstance(highlights, list) and highlights:,并对首元素做元素级校验后取值,或至少用 try/except 包裹该段,避免异常逃逸出 _search_youcom。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +834 to +845
if result.get("isError") and text:
return WebSearchResult(
query=query,
provider="youcom",
results=[],
summary=_truncate(f"You.com search error: {text}", self._snippet_len),
)

data: Any = None
try:
data = json.loads(text)
except (json.JSONDecodeError, TypeError):

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.

问题: 工具错误分支 if result.get("isError") and text: 要求 text 非空才返回错误摘要;若 isError=true 且 content[].text 为空(或信息只存在于 MCP 规范的 structuredContent 字段),代码落入后续 json.loads(""),被捕获后返回误导性的 "You.com search returned an unexpected payload." 成功形态摘要,且代码从未读取 structuredContent。

触发条件: 上游服务端标记 isError: true 但未携带 text 内容(MCP 层常如此),或把结构化结果放在 structuredContent 而 text 仅为人读摘要。两种情况在单元测试中均未覆盖(测试只覆盖了 isError 且 text 非空、以及 text 携带 JSON 两种形态)。

实际影响: LLM 收到 "unexpected payload" 而非真实错误信息,无法区分“上游失败”与“响应格式异常”,文档承诺的“错误写入 summary”契约在该形态下失效,排障与降级都受到误导,并可能因此误报为“无结果”。

修正方向: 将 isError 判定提前到内容提取之前:先检查 result.get("isError"),无论 text 是否为空都返回 You.com search error: ... 摘要;可同时读取 structuredContent 作为 text 的补充来源。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +806 to +813
result: Optional[dict[str, Any]] = None
error: Optional[dict[str, Any]] = None
for item in payloads:
if "result" in item:
result = item["result"]
elif "error" in item:
error = item["error"]
if error is not None:

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.

问题: 对 SSE 帧的扫描 if "result" in item ... elif "error" in item 使“先 error 后 result”或“同帧双键”的流中 error 信息被错误取舍:错误分支在循环外 if error is not None 无条件优先于 result,且末帧胜出语义未按 JSON-RPC 的 id 匹配(请求固定 id: 1,响应未校验 id)。

触发条件: 流内任一帧(如进度通知)携带 error 键且其后跟有效 result 帧(已用模拟流实测:error 残留导致有效结果被丢弃);或单帧同时含 result: null 与 error(elif 使 error 被丢弃,最终报 "no result payload")。

实际影响: 合法搜索结果被误报为错误摘要,或真实限流/配额错误被吞掉并表现为空结果成功,两种情况都与文档“错误写入 summary”的契约相反,结果不稳定且难以排障。

修正方向: 按 JSON-RPC 语义处理:只接受与请求 id 匹配的帧;对同帧 result+error 共存时以 error 为准(先判 error 再判 result 或独立记录);采用“最后一帧有效结果、显式指定优先级”的明确策略并补充对应测试。

Comment on lines +285 to +307
def _parse_sse_payloads(text: str) -> List[dict[str, Any]]:
"""Parse the ``data:`` lines of a streamable-HTTP SSE body.

A streamable-HTTP server answers a JSON-RPC request with one SSE body
whose ``data:`` lines carry one JSON object each (progress notifications
followed by the final result). Lines that fail to parse are skipped so a
stray keep-alive or partial frame cannot break the search.
"""
payloads: List[dict[str, Any]] = []
for line in (text or "").splitlines():
if not line.startswith("data:"):
continue
raw = line[len("data:"):].strip()
if not raw:
continue
try:
decoded = json.loads(raw)
except json.JSONDecodeError:
logger.warning("WebSearchTool: skipping unparseable SSE line: %.80s", raw)
continue
if isinstance(decoded, dict):
payloads.append(decoded)
return payloads

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.

问题: _parse_sse_payloads 把每个物理 data: 行当作独立 JSON 文档解析,未实现 SSE 规范允许的“单条 data 帧跨多行(后续行以空格开头)拼接”语义;第 297 行 line[len("data:"):] 只按行切片,且未处理首个 data: 行前的 UTF-8 BOM(Python 默认 utf-8 解码不剥离 BOM,line.startswith("data:") 对该行直接失配)。

触发条件: 上游把长 JSON 结果拆分为两行 data: 帧(两段各自无法被 json.loads 解析而被静默跳过,实测确认),或响应体以 BOM 起始;二者都会让一次 200 成功响应被解析为空帧列表。

实际影响: _search_youcom 返回 "You.com search returned no result payload." 空结果——成功搜索被误报为无结果,LLM 无法区分“真无结果”与“解析失败”,影响召回准确性。

修正方向: 按 SSE 规范支持跨行 data 帧(累计同一事件的多行 data 并检查本行是否为续行 data:),并在首行前剥离 BOM;或直接使用仓库已声明的 httpx-sse 依赖(pyproject 已含 httpx-sse>=0.4.0)解析。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +783 to +789
arguments: dict[str, Any] = {"query": query, "count": n}
if lang:
# You.com maps an inline ``lang:`` operator to a language filter.
arguments["query"] = f"{query} lang:{lang}"
if blocked:
arguments["exclude_domains"] = blocked
arguments.update(self._youcom_extra_params)

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.

问题: arguments.update(self._youcom_extra_params) 在 query/count/lang: 重写之后执行,youcom_extra_params 中若包含 query、count 或 exclude_domains 键会静默覆盖本工具构造的核心参数;且 lang 值未做任何清洗,schema 描述(第 456 行)明确告诉模型“You.com maps it to the inline lang: filter”,模型可能直接传 lang="lang:ja",拼接出 query lang:lang:ja。

触发条件: 调用方固化 youcom_extra_params={"query": "...", "count": 3} 等键,或 LLM 按 schema 字面解释传入已带 lang: 前缀的 lang 值;两种都无任何校验或警告。

实际影响: 调用时的 query / 语言过滤被静默替换或失效(lang:lang: 双操作符无法被服务端正确匹配),行为与文档“每次工具调用自动带上固定高级参数、不覆盖调用参数”的表述不符,且无日志可追踪。

修正方向: 仅允许 extra params 覆盖白名单之外的键(对 query/count/exclude_domains 做保护性合并,异常键记录 warning),并给 lang 增加防重前缀清洗(如去除已存在的 lang: 前缀)。

Comment on lines +855 to +862
sections = data.get("results")
if not isinstance(sections, dict):
sections = {}
items: List[dict[str, Any]] = []
for section in ("web", "news"):
for item in sections.get(section) or []:
if isinstance(item, dict):
items.append(item)

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.

问题: sections = data.get("results"); if not isinstance(sections, dict): sections = {} 对 dict 但形状非预期的响应(如 results 为数组、或嵌套层级与实现假设不同)静默降级为 {},返回 summary="" 的空成功结果,未写入任何错误信号;测试仅覆盖了 results 为 dict 的正常形态。

触发条件: You.com 响应结构漂移(如 results 变更为扁平数组,或 free profile 命中位于其它嵌套层级)时发生。

实际影响: LLM 收到“搜索成功但 0 结果”,会如实向用户报告“未找到结果”而不会重试,与其它错误路径“结构化错误写入 summary”的契约不一致,掩盖了真实的协议兼容问题。

修正方向: 对非 dict 且非空的 results 返回显式提示(如 _truncate("You.com search returned an unexpected results shape.", ...) 写入 summary),并在映射循环中对 sections.get(section) 的非 list 值同样降级提示。

Address the openreview findings on the youcom provider:

- _parse_sse_payloads: join multi-line data: frames per the SSE spec,
  strip a leading UTF-8 BOM, and fall back to per-line parsing for
  servers that omit blank-line event separators.
- Frame selection: match the JSON-RPC request id (frames for other
  requests are ignored), give a same-frame error precedence over
  result: null, and let a later valid result supersede a stray earlier
  error frame.
- isError: report the error whatever the text content is, using
  structuredContent as the detail source when no text is present,
  instead of falling through to payload parsing.
- Read structuredContent as the results payload when the text content
  is not JSON (human-readable text + structured results).
- Surface a non-dict non-empty results field as an explicit
  unexpected-shape summary instead of silently reporting 0 hits; skip
  non-list web/news sections without crashing.
- Validate the first highlight is a string before using it as the
  description fallback.
- Strip a pre-existing lang: prefix from the lang argument and ignore
  youcom_extra_params keys that would override per-call
  query/count/exclude_domains.

Adds 13 tests covering each path; full websearch suite (104 tests),
yapf and flake8 clean.
@valueadd-lgtm

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA


@valueadd-lgtm

Copy link
Copy Markdown
Author

Thanks for the thorough review. Both verified MODERATE issues and the other findings are fixed in 04c3ea9, plus malformed-payload tests as suggested:

  • Highlights type defense: the description fallback now only accepts a list whose first element is a string. A bare string, a dict, or a non-string first element is skipped, so no single-character snippets and no SEARCH_ERROR.
  • isError with empty text: isError now takes the error branch unconditionally. Without text, structuredContent is serialized as the error detail; with neither, the summary says 'no detail returned' instead of falling through to a misleading 'unexpected payload' 0-hit success.
  • SSE parsing: multi-line data: frames of one event are joined with newlines before parsing (spec-correct), a leading UTF-8 BOM is stripped, and per-line parsing is kept as a fallback for servers that omit blank-line event separators. Garbage lines are still skipped.
  • Frame selection: only frames whose id matches this request are considered. A same-frame result: null + error is treated as an error, and a later valid result supersedes a stray earlier error frame.
  • Results shape drift: a non-empty non-dict results now returns an explicit 'unexpected results shape' summary; non-list web/news sections are skipped with a warning instead of being silently coerced.
  • structuredContent fallback: when the text content is not JSON but structuredContent carries the results object, it is used as the data source (human-readable text + structured results).
  • Smaller items: a lang argument already carrying the lang: prefix is de-duplicated (lang:ja -> query lang:ja, not lang:lang:ja); query / count / exclude_domains are reserved keys in youcom_extra_params and pinned values are ignored with a warning (docs updated in both languages).

Added 13 targeted tests (malformed highlights, isError without text, structuredContent, cross-request ids, multi-line data, BOM, missing separators, shape drift). Full websearch suite: 104 tests passing; yapf and flake8 clean. The CLA is signed via the bot's comment format as well.

@valueadd-lgtm

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

Rook1ex added a commit to trpc-group/cla-database that referenced this pull request Oct 9, 2026
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

审查范围:base 02509b20 → head 04c3ea9,共 4 个变更文件(trpc_agent_sdk/tools/_websearch_tool.py +322 行、tests/tools/test_websearch_tool.py +693 行、en/zh 文档),含两个提交:ed846d4 新增 youcom provider、04c3ea9 加固 SSE 解析与载荷处理。

计划符合性:实现与计划"add youcom (You.com) provider to WebSearchTool"一致——JSON-RPC tools/call + you-search、tools/call 载荷、Authorization: Bearer、free-profile 免密钥端点、web/news 命中映射、lang 内联过滤、blocked_domains→exclude_domains、youcom_extra_params 透传均已落地;ProviderType 扩展与 youcom_extra_params 构造参数(* 关键字参数)均向后兼容,既有 provider 行为未变。SSE 解析器(多行 data: 连接、BOM 剥离、逐行回退、空行分发)经独立运行验证正确,与官方 dsh-client 的实现兼容。

主要风险:1) 命中字段映射与官方 you-search 契约存在偏差:官方规范字段为 snippets(数组,默认返回),代码只读 description 并回退到仅付费 profile 才有的 extraction 产生的 contents.highlights,真实免密钥端点下新闻等缺失 description 的命中会得到空摘要,测试 fixture 也按错误契约建模;2) 免密钥路径 _post_sse 不设置 Authorization,当按文档推荐共享带客户端级认证头的 http_client 时,httpx 会把该凭据随请求发给 api.you.com(DDG 已有同款模式,但 youcom 是新增暴露面);3) 帧选择逻辑中"后被 result 覆盖的 error 帧"完全静默(相对加固前的无条件报错是行为回退,失败不可观测)、id 类型严格比较可能丢弃合法帧、空 data: 行回退路径产生告警噪音。

测试充分性:104 个 websearch 测试(新增 26 个 youcom + 4 个 SSE 解析测试),覆盖 keyless/认证端点、Bearer、域过滤、lang 过滤、JSON-RPC 错误、isError、structuredContent、去重、形状漂移等路径,质量高;缺口是 fixture 未按官方 snippets 契约建模、共享客户端凭据透传路径无测试。

门禁结论:未发现阻止合入的 SEVERE 级缺陷;报告 2 条 MODERATE、3 条 LOW 可操作问题,建议修复后合入。

发现的问题

中等

trpc_agent_sdk/tools/_websearch_tool.py:966-973

问题: 新增的 _search_youcom 命中解析只读取 description,回退使用 contents.highlights[0],完全没有读取 You.com you-search 的规范摘要字段 snippets(字符串数组)。

触发条件: 真实端点的 results.web[].snippets 是默认返回的规范字段(官方 quickstart 与官方 @youdotcom-oss/dsh-plugin 客户端均以 snippets 为第一摘要来源),而 description 并非所有命中都带(news 命中及部分 web 命中缺失);contents.highlights 仅在开启 extraction 时存在,而本实现默认的 free-profile 端点恰好不提供 extraction。

实际影响: 在缺省配置(免密钥 free profile)下,凡是没有 description 的命中(尤以 news 区为主)其 snippet 会退化为空字符串,LLM 拿到的摘要信息与官方返回不一致,工具核心产出(标题/URL/摘要)质量下降;新增的 26 个测试 fixture 全部按 description+contents.highlights 建模,测试通过但不能发现该契约偏差。

修正方向: 按官方契约补充 snippets 读取:优先取 item.get("description") 或 item.get("snippets") 列表中第一个非空字符串,其次才回退 contents.highlights;同时为测试 fixture 增加带 snippets 字段的用例,与官方响应样例对齐。

中等

trpc_agent_sdk/tools/_websearch_tool.py:669-676

问题: _post_sse(youcom 免密钥路径)的请求头固定为 User-Agent + Accept,仅当 self._api_key 非空时才在调用处追加 Authorization;httpx 对 client.post(..., headers=...) 的语义是请求头与客户端级头合并、同名覆盖,客户端级头不会被清除。

触发条件: 文档(docs/mkdocs/en/tool.md:2552/2665)明确推荐把预建的 httpx.AsyncClient 通过 http_client 参数跨 Agent/调用共享连接池;当该共享客户端带有客户端级敏感头(如为代理/网关/其它服务配置的 Authorization、Cookie、X-API-Key)时,youcom 免密钥请求不会覆盖这些头。

实际影响: 未预期的凭据会被随请求发送到第三方端点 https://api.you.com/mcp?profile=free,构成凭据泄露;现有测试 test_keyless_search_maps_web_and_news_hits 仅断言无头客户端下 authorization is None,该路径无测试覆盖。(DDG 免密钥路径有同款模式,但 youcom 是本变更新增的暴露面。)

修正方向: 在 _post_sse 内对 youcom 请求执行白名单化:显式构造仅含 User-Agent/Accept/(有 key 时的)Authorization 的请求头,避免继承客户端级头;或至少在 _search_youcom 免密钥分支记录警告,并为共享带头客户端场景补充测试。

较低

trpc_agent_sdk/tools/_websearch_tool.py:861-869

问题: 帧选择循环中,一旦出现 error 帧随后又出现 result 帧(本提交新增的 test_stray_error_superseded_by_later_result 已证明服务端确实可能先发 error 帧再发 result 帧),error_wins 被置回 False,最终在 error is not None and (error_wins or result is None) 处走成功分支,被覆盖的 error 对象被静默丢弃、无任何日志。

触发条件: 服务端在返回最终有效结果前发出携带 id: 1 的 error 帧(如瞬时限流、配额提示),随后正常返回结果帧。

实际影响: 相对加固前“无条件报错”的行为,现在真实发生过的失败在结果中完全不可见(summary=""),运维无法区分“恢复后成功”与“从未失败”,限流类问题的可观测性丢失。

修正方向: 在“后到 result 覆盖前序 error”的分支处补一条 logger.warning(记录被覆盖的 error code/message),使瞬时失败可观测,同时保留“结果优先”的既有语义。

较低

trpc_agent_sdk/tools/_websearch_tool.py:859-860

问题: frame_id is not None and frame_id != payload["id"] 使用严格的 Python 类型比较(int vs int),而 payload["id"] 恒为整数 1;官方 @youdotcom-oss/dsh-plugin 客户端对同一过滤使用 String(candidate.id) === String(id) 的宽松比较。

触发条件: 服务端把请求 id 以不同 JSON 类型回显(例如把 1 回显为字符串 "1")时,所有帧都会被当作“其它请求的帧”过滤掉。

实际影响: 搜索结果被整体丢弃,返回 "You.com search returned no result payload.",且无任何日志提示原因,排障困难。

修正方向: 仿照官方客户端改为宽松比较(str(frame_id) != str(payload["id"]) 或对二者做类型归一后相等判断),并增加 id 为字符串形式的测试用例。

较低

trpc_agent_sdk/tools/_websearch_tool.py:322-327

问题: _parse_sse_payloads 逐行回退路径中,空 data: 行(keep-alive 常用)或内容为合法 JSON 但非 dict 的 data: 行(如 data: 123、data: [1,2])现在会触发 logger.warning("WebSearchTool: skipping unparseable SSE line: ...");同时 _try_decode 对空串/非 dict 不再静默跳过。

触发条件: 服务端在事件流中周期性发送空 data: keep-alive 行,或发送非对象 JSON 的 data 行;这些行导致所在块的整块 join 解析失败后进入逐行回退,每行各打一条警告。

实际影响: 每个 keep-alive 行产生一条无意义告警(空内容告警尤其无信息量),日志噪音增大,掩盖真正值得关注的解析失败;旧实现(if not raw: continue)对这些行是静默处理的。

修正方向: 在 _try_decode/回退循环中将空串与非 dict 的 JSON(列表、数字、字符串、true/null)与真正的 JSON 解析失败区分开:空串直接跳过不告警,非 dict 可降级为 debug 级别或仅在计数告警;为空 data 行场景补充测试。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +966 to +973
description = str(item.get("description") or "").strip()
if not description:
contents = item.get("contents")
highlights = contents.get("highlights") if isinstance(contents, dict) else None
# Only a list of strings is a usable fallback; anything else
# (a bare string, a dict, ...) must not crash the whole search.
if isinstance(highlights, list) and highlights and isinstance(highlights[0], str):
description = highlights[0].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.

问题: 新增的 _search_youcom 命中解析只读取 description,回退使用 contents.highlights[0],完全没有读取 You.com you-search 的规范摘要字段 snippets(字符串数组)。

触发条件: 真实端点的 results.web[].snippets 是默认返回的规范字段(官方 quickstart 与官方 @youdotcom-oss/dsh-plugin 客户端均以 snippets 为第一摘要来源),而 description 并非所有命中都带(news 命中及部分 web 命中缺失);contents.highlights 仅在开启 extraction 时存在,而本实现默认的 free-profile 端点恰好不提供 extraction。

实际影响: 在缺省配置(免密钥 free profile)下,凡是没有 description 的命中(尤以 news 区为主)其 snippet 会退化为空字符串,LLM 拿到的摘要信息与官方返回不一致,工具核心产出(标题/URL/摘要)质量下降;新增的 26 个测试 fixture 全部按 description+contents.highlights 建模,测试通过但不能发现该契约偏差。

修正方向: 按官方契约补充 snippets 读取:优先取 item.get("description") 或 item.get("snippets") 列表中第一个非空字符串,其次才回退 contents.highlights;同时为测试 fixture 增加带 snippets 字段的用例,与官方响应样例对齐。

Comment on lines +669 to +676
request_headers = {
"User-Agent": self._user_agent,
# The streamable-HTTP server rejects JSON-only Accept headers
# with 406 Not Acceptable, so SSE must be listed here.
"Accept": "application/json, text/event-stream",
}
if headers:
request_headers.update(headers)

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.

问题: _post_sse(youcom 免密钥路径)的请求头固定为 User-Agent + Accept,仅当 self._api_key 非空时才在调用处追加 Authorization;httpx 对 client.post(..., headers=...) 的语义是请求头与客户端级头合并、同名覆盖,客户端级头不会被清除。

触发条件: 文档(docs/mkdocs/en/tool.md:2552/2665)明确推荐把预建的 httpx.AsyncClient 通过 http_client 参数跨 Agent/调用共享连接池;当该共享客户端带有客户端级敏感头(如为代理/网关/其它服务配置的 Authorization、Cookie、X-API-Key)时,youcom 免密钥请求不会覆盖这些头。

实际影响: 未预期的凭据会被随请求发送到第三方端点 https://api.you.com/mcp?profile=free,构成凭据泄露;现有测试 test_keyless_search_maps_web_and_news_hits 仅断言无头客户端下 authorization is None,该路径无测试覆盖。(DDG 免密钥路径有同款模式,但 youcom 是本变更新增的暴露面。)

修正方向: 在 _post_sse 内对 youcom 请求执行白名单化:显式构造仅含 User-Agent/Accept/(有 key 时的)Authorization 的请求头,避免继承客户端级头;或至少在 _search_youcom 免密钥分支记录警告,并为共享带头客户端场景补充测试。

Comment on lines +861 to +869
if "error" in item:
# A JSON-RPC response carries either ``result`` or ``error``;
# a frame carrying both is treated as an error.
error = item["error"]
error_wins = True
if "result" in item and "error" not in item:
# A later valid result supersedes an earlier stray error.
result = item["result"]
error_wins = False

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.

问题: 帧选择循环中,一旦出现 error 帧随后又出现 result 帧(本提交新增的 test_stray_error_superseded_by_later_result 已证明服务端确实可能先发 error 帧再发 result 帧),error_wins 被置回 False,最终在 error is not None and (error_wins or result is None) 处走成功分支,被覆盖的 error 对象被静默丢弃、无任何日志。

触发条件: 服务端在返回最终有效结果前发出携带 id: 1 的 error 帧(如瞬时限流、配额提示),随后正常返回结果帧。

实际影响: 相对加固前“无条件报错”的行为,现在真实发生过的失败在结果中完全不可见(summary=""),运维无法区分“恢复后成功”与“从未失败”,限流类问题的可观测性丢失。

修正方向: 在“后到 result 覆盖前序 error”的分支处补一条 logger.warning(记录被覆盖的 error code/message),使瞬时失败可观测,同时保留“结果优先”的既有语义。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +859 to +860
if frame_id is not None and frame_id != payload["id"]:
continue

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.

问题: frame_id is not None and frame_id != payload["id"] 使用严格的 Python 类型比较(int vs int),而 payload["id"] 恒为整数 1;官方 @youdotcom-oss/dsh-plugin 客户端对同一过滤使用 String(candidate.id) === String(id) 的宽松比较。

触发条件: 服务端把请求 id 以不同 JSON 类型回显(例如把 1 回显为字符串 "1")时,所有帧都会被当作“其它请求的帧”过滤掉。

实际影响: 搜索结果被整体丢弃,返回 "You.com search returned no result payload.",且无任何日志提示原因,排障困难。

修正方向: 仿照官方客户端改为宽松比较(str(frame_id) != str(payload["id"]) 或对二者做类型归一后相等判断),并增加 id 为字符串形式的测试用例。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +322 to +327
for piece in buffer:
decoded = _try_decode(piece)
if decoded is None:
logger.warning("WebSearchTool: skipping unparseable SSE line: %.80s", piece)
continue
payloads.append(decoded)

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.

问题: _parse_sse_payloads 逐行回退路径中,空 data: 行(keep-alive 常用)或内容为合法 JSON 但非 dict 的 data: 行(如 data: 123、data: [1,2])现在会触发 logger.warning("WebSearchTool: skipping unparseable SSE line: ...");同时 _try_decode 对空串/非 dict 不再静默跳过。

触发条件: 服务端在事件流中周期性发送空 data: keep-alive 行,或发送非对象 JSON 的 data 行;这些行导致所在块的整块 join 解析失败后进入逐行回退,每行各打一条警告。

实际影响: 每个 keep-alive 行产生一条无意义告警(空内容告警尤其无信息量),日志噪音增大,掩盖真正值得关注的解析失败;旧实现(if not raw: continue)对这些行是静默处理的。

修正方向: 在 _try_decode/回退循环中将空串与非 dict 的 JSON(列表、数字、字符串、true/null)与真正的 JSON 解析失败区分开:空串直接跳过不告警,非 dict 可降级为 debug 级别或仅在计数告警;为空 data 行场景补充测试。

…, harden SSE parsing

- Send the JSON-RPC request as an explicitly built httpx.Request via
  client.send() so client-level headers on a shared http_client (gateway
  bearer tokens, cookie jars) are never forwarded to the You.com endpoint;
  the per-call timeout is preserved through request.extensions.
- Map You.com's canonical snippets list into hit descriptions when
  description is empty or missing (notably news hits), guarded the same
  way as contents.highlights.
- Compare JSON-RPC frame ids by string form so servers that echo the id
  back as "1" still match the outgoing request.
- Log a stray error frame superseded by a later result frame instead of
  dropping it silently.
- Quiet down SSE parsing: empty keep-alive data lines and valid
  non-object JSON no longer warn; only truly unparseable lines do.

Tests: new cases for each fix; the full file passes (110 tests) on both
httpx 0.27.2 and 0.28.1.
@valueadd-lgtm

Copy link
Copy Markdown
Author

Thanks for the thorough review — all five points are addressed in 5874601:

Header isolation (the big one): confirmed the leak — client.post() merges client-level headers into the outgoing request, so a shared http_client carrying a gateway bearer token / cookie jar would have forwarded those credentials to the You.com endpoint. The SSE request is now built as an explicit httpx.Request carrying only the tool's own headers and sent via client.send(), which does not merge. The per-call timeout is preserved by setting request.extensions["timeout"] before send (verified against both httpx 0.27 and 0.28, since the project floor is 0.27). Two tests pin the behavior: gateway Authorization/X-Api-Key/Cookie on the shared client never reach the wire, and when YDC_API_KEY is set, only that one bearer goes out.

snippets field: hit summaries now fall back description → snippets (first non-empty string entry) → contents.highlights. The news-hit fixture uses the real shape (snippets only, no description), and a test covers the guard cases (non-list value, non-string and blank entries).

Frame id comparison: compared by string form now — a server that echoes the id back as "1" still matches the outgoing integer id, so reply frames are no longer dropped.

Superseded error frames: a stray error frame overridden by a later result frame is logged at WARNING (with the error payload included) instead of disappearing silently.

SSE noise: empty keep-alive data: lines are skipped quietly, valid non-object JSON (data: 123) logs at DEBUG, and only genuinely unparseable lines warn — with a parse-level test asserting exactly one warning for one garbage line.

The full tests/tools/test_websearch_tool.py passes (110 tests) on both httpx 0.27.2 and 0.28.1, yapf/flake8 clean, and the en/zh docs now describe the snippet precedence and the header isolation.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

不通过

审查范围:base 02509b2..head 5874601,共 4 个文件(_websearch_tool.py +368 行、新增 youcom 测试 924 行、中英文档)。计划为新增 youcom (You.com) WebSearchTool provider,实现为经 streamable-HTTP 的 JSON-RPC tools/call、SSE 解析、keyless free profile / YDC_API_KEY 认证端点、lang: 内联过滤、snippets/highlights 摘要回退、extra params 与域名过滤。计划符合度:功能完整,测试覆盖较好(按官方响应形状建模的 fixture、SSE 多行/BOM/keep-alive、frame id 字符串比较、isError/structuredContent 路径等),文档同步更新。主要风险:1) _post_sse 声称"凭据完全不转发",但经 httpx 0.27/0.28 源码与实测验证,客户端级 auth= 仍会被 client.send() 应用(Basic/Bearer/自定义 Auth 流程直接给裸请求加 Authorization,DigestAuth 甚至响应跨源 401 质询),且 You.com 的 Set-Cookie 仍写入共享 cookie jar 并可能泄漏到后续其他 provider 请求,属安全边界缺陷(SEVERE);2) 重定向未跟随且 3xx 不抛错,静默空结果(MODERATE);3) results 形状漂移时静默丢弃全部命中(LOW)。测试充分性:youcom 测试覆盖成功/边界/失败路径较全,但未覆盖 auth= 泄漏与重定向场景,这两处测试缺口与上述缺陷同根因。门禁结论:FAILED。

发现的问题

严重

trpc_agent_sdk/tools/_websearch_tool.py:704-709

问题: _post_sse() 通过 client.send(request) 发送裸 httpx.Request,仅隔离了客户端级 headers/cookies,但客户端级的 auth(如 httpx.AsyncClient(auth=(user, pass))、BearerAuth、自定义 Auth 流程)仍会被应用:httpx 0.27–0.28 的 BaseClient.send() 内部调用 _build_request_auth(request, USE_CLIENT_DEFAULT) 返回 self._auth,随后 _send_handling_auth 在裸请求上执行 auth 流程(BasicAuth/BearerAuth 直接设置 Authorization 头,DigestAuth 甚至会响应 You.com 服务端发来的 WWW-Authenticate 质询),这与代码注释和英文文档承诺的“凭据不会转发给 You.com”(docs/mkdocs/en/tool.md:2540、docs/mkdocs/zh/tool.md:2592)相矛盾。另外 client.send() 仍会把 You.com 响应中的 Set-Cookie 写入共享客户端 cookie jar(self.cookies.extract_cookies(response)),该 cookie 会被后续经同一 http_client 发给其他供应商(如 Tavily)的请求附带。

触发条件: 调用方复用共享 http_client 且该客户端配置了 auth(网关 Basic/Bearer/自定义鉴权),或 You.com 端点为某次响应设置了 cookie 后再次复用同一客户端。

实际影响: 网关凭据(Basic 用户名密码、Bearer token)被发送到第三方 You.com 端点,凭据泄露;You.com 设置的 cookie 随后可能被发送到 Tavily/Google 等端点,造成跨供应商凭据污染,属于安全边界破坏,且新增测试(test_shared_client_credentials_not_forwarded 等)只覆盖了 headers= 场景,未覆盖 auth=,测试结论会误导后续维护者。

修正方向: 在 await client.send(request) 处显式传入 auth=None 禁止应用客户端 auth(已在 httpx 0.27/0.28 验证可抑制),并考虑在发送后不依赖共享客户端 cookie jar 的隐式写入(或使用独立的请求级 client/transport 发送该请求);同时补充以 auth=("u", "p") 配置的共享客户端用例来锁定该行为。

中等

trpc_agent_sdk/tools/_websearch_tool.py:876

问题: _post_sse 的请求未经 follow_redirects 控制,且 You.com 端点在重定向场景下可能把裸请求重新发送到第三方地址;更直接的问题是:httpx.Request 未启用 follow_redirects 时,3xx 响应不会跟随,但 resp.raise_for_status() 对 3xx 不抛错,落到 _parse_sse_payloads(resp.text) 解析空 body,静默返回 0 结果,掩盖重定向/端点变更。

触发条件: 默认客户端 follow_redirects 为 False(httpx 默认)时 You.com 端点返回 301/302(如 base_url 变更)。

实际影响: 搜索静默返回空结果,下游 LLM 会得到误导性“无结果”结论,且无法从日志区分是真实无结果还是端点重定向。

修正方向: 在 client.send(request) 处显式传 follow_redirects=True(与其他 provider 的 client.post 一致),并在 raise_for_status 前对 3xx 记录警告。

较低

trpc_agent_sdk/tools/_websearch_tool.py:968-979

问题: 当 data["results"] 为非空非 dict(如列表 [{"url":...}, ...],You.com 可能的形状变体)时,代码返回 results=[] + summary="You.com search returned an unexpected results shape.",直接丢弃全部命中且摘要不包含任何命中数量或提示可恢复的信息,与 Tavily/Google 路径对形状漂移的处理(保留可解析命中)不一致。

触发条件: 上游把 results 从 dict 改为 list 形状(测试 test_non_dict_results_shape_surfaced_as_summary 也证实该路径存在)。

实际影响: 现实命中被静默丢弃,LLM 得到 0 结果,无法区分“真无结果”与“上游形状漂移”,可用性下降。

修正方向: 在该分支把可解析的 list 命中映射为 SearchHit(复用下方 hit 构建逻辑),或至少在 summary 中带上命中数与命中标题样例,便于诊断。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +704 to +709
request = httpx.Request("POST", url, json=payload, headers=request_headers)
# send() falls back to the client timeout when the extension is
# absent; keep the per-call timeout semantics of the other providers.
request.extensions["timeout"] = httpx.Timeout(self._timeout).as_dict()
try:
resp = await client.send(request)

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.

问题: _post_sse() 通过 client.send(request) 发送裸 httpx.Request,仅隔离了客户端级 headers/cookies,但客户端级的 auth(如 httpx.AsyncClient(auth=(user, pass))、BearerAuth、自定义 Auth 流程)仍会被应用:httpx 0.27–0.28 的 BaseClient.send() 内部调用 _build_request_auth(request, USE_CLIENT_DEFAULT) 返回 self._auth,随后 _send_handling_auth 在裸请求上执行 auth 流程(BasicAuth/BearerAuth 直接设置 Authorization 头,DigestAuth 甚至会响应 You.com 服务端发来的 WWW-Authenticate 质询),这与代码注释和英文文档承诺的“凭据不会转发给 You.com”(docs/mkdocs/en/tool.md:2540、docs/mkdocs/zh/tool.md:2592)相矛盾。另外 client.send() 仍会把 You.com 响应中的 Set-Cookie 写入共享客户端 cookie jar(self.cookies.extract_cookies(response)),该 cookie 会被后续经同一 http_client 发给其他供应商(如 Tavily)的请求附带。

触发条件: 调用方复用共享 http_client 且该客户端配置了 auth(网关 Basic/Bearer/自定义鉴权),或 You.com 端点为某次响应设置了 cookie 后再次复用同一客户端。

实际影响: 网关凭据(Basic 用户名密码、Bearer token)被发送到第三方 You.com 端点,凭据泄露;You.com 设置的 cookie 随后可能被发送到 Tavily/Google 等端点,造成跨供应商凭据污染,属于安全边界破坏,且新增测试(test_shared_client_credentials_not_forwarded 等)只覆盖了 headers= 场景,未覆盖 auth=,测试结论会误导后续维护者。

修正方向: 在 await client.send(request) 处显式传入 auth=None 禁止应用客户端 auth(已在 httpx 0.27/0.28 验证可抑制),并考虑在发送后不依赖共享客户端 cookie jar 的隐式写入(或使用独立的请求级 client/transport 发送该请求);同时补充以 auth=("u", "p") 配置的共享客户端用例来锁定该行为。

if self._api_key:
headers = {"Authorization": f"Bearer {self._api_key}"}

payloads = await self._post_sse(self._base_url, payload, headers=headers)

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.

问题: _post_sse 的请求未经 follow_redirects 控制,且 You.com 端点在重定向场景下可能把裸请求重新发送到第三方地址;更直接的问题是:httpx.Request 未启用 follow_redirects 时,3xx 响应不会跟随,但 resp.raise_for_status() 对 3xx 不抛错,落到 _parse_sse_payloads(resp.text) 解析空 body,静默返回 0 结果,掩盖重定向/端点变更。

触发条件: 默认客户端 follow_redirects 为 False(httpx 默认)时 You.com 端点返回 301/302(如 base_url 变更)。

实际影响: 搜索静默返回空结果,下游 LLM 会得到误导性“无结果”结论,且无法从日志区分是真实无结果还是端点重定向。

修正方向: 在 client.send(request) 处显式传 follow_redirects=True(与其他 provider 的 client.post 一致),并在 raise_for_status 前对 3xx 记录警告。

Comment on lines +968 to +979
sections = data.get("results")
if not isinstance(sections, dict):
if sections:
# A non-empty non-dict ``results`` is a shape change upstream,
# not an empty search; surface it instead of reporting 0 hits.
return WebSearchResult(
query=query,
provider="youcom",
results=[],
summary="You.com search returned an unexpected results shape.",
)
sections = {}

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.

问题: 当 data["results"] 为非空非 dict(如列表 [{"url":...}, ...],You.com 可能的形状变体)时,代码返回 results=[] + summary="You.com search returned an unexpected results shape.",直接丢弃全部命中且摘要不包含任何命中数量或提示可恢复的信息,与 Tavily/Google 路径对形状漂移的处理(保留可解析命中)不一致。

触发条件: 上游把 results 从 dict 改为 list 形状(测试 test_non_dict_results_shape_surfaced_as_summary 也证实该路径存在)。

实际影响: 现实命中被静默丢弃,LLM 得到 0 结果,无法区分“真无结果”与“上游形状漂移”,可用性下降。

修正方向: 在该分支把可解析的 list 命中映射为 SearchHit(复用下方 hit 构建逻辑),或至少在 summary 中带上命中数与命中标题样例,便于诊断。

… call

- Pass auth=None to client.send so client-level Basic/Bearer/custom auth
  flows on a shared http_client are not applied to the bare You.com
  request (httpx applies them even to requests sent as-is).
- Drop any Set-Cookie the You.com response wrote into the shared client's
  cookie jar afterwards, so it cannot be replayed to other providers
  (e.g. Tavily) through the same client.
- Pass follow_redirects=True so an endpoint 301/302 does not degrade to
  parsing the empty 3xx body as a silent 0-hit search; log a warning if a
  residual 3xx still surfaces.
- Map a flat list of hits in results to SearchHit entries instead of
  dropping them, and name the observed type in the unexpected-shape
  summary.
- Add tests: shared-client basic-auth suppression (with and without the
  tool key), Set-Cookie jar isolation, redirect following, flat-list
  results shape.
@valueadd-lgtm

Copy link
Copy Markdown
Author

Thanks again for the careful review — you were right that client.send() was still applying the client-level auth. All three findings are addressed in b5515a2:

  • Client auth leakage (severe): confirmed the leak — a shared http_client configured with auth=("user", "pass") got Authorization: Basic ... on the wire. The SSE call now passes auth=None to client.send(), which suppresses Basic/Bearer/custom auth flows on the bare request (verified against httpx 0.28.1). Any Set-Cookie the You.com response writes into the shared client's cookie jar is also dropped again right after the send, so it cannot be replayed to Tavily/Google through the same client — pre-existing cookies on the jar are preserved. Added tests for the auth=("u", "p") shared-client case (with and without the tool's own key) and for Set-Cookie jar isolation.
  • Redirects: the send now passes follow_redirects=True, matching the other providers, and a residual 3xx (defensive case) logs a warning before raise_for_status() instead of silently parsing an empty body. Added a 302-following test.
  • Non-dict results: a flat list of hits now routes through the same section loop as web/news and maps parseable entries to SearchHit instead of dropping them; a scalar shape still surfaces as a summary, now naming the observed type. Updated the shape-drift tests accordingly.

Full tests/tools/test_websearch_tool.py suite passes locally (115 passed), and yapf/flake8 are clean on the changed files.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

不通过

审查范围:base 02509b2..head b5515a2,共 4 个提交(feat: add youcom provider + 三次加固),涉及 3 个文件:trpc_agent_sdk/tools/_websearch_tool.py(新增 _parse_sse_payloads、_post_sse、_search_youcom,扩展 ProviderType)、tests/tools/test_websearch_tool.py(新增约 1060 行测试)、两份文档。计划符合性:youcom provider 的 keyless free-profile 调用、YDC_API_KEY 认证端点、SSE 解析、snippets 回退等需求均已实现,且文档、导出与声明同步更新;测试覆盖非常充分(正常/边界/失败路径、共享客户端凭据隔离、重定向、SSE 多行/BOM、形状漂移等),并包含日志捕获工具与完整的安全回归测试。主要发现集中在两处:一是 _post_sse 通过 follow_redirects=True 自动跟随重定向,在跨源重定向时 httpx 在直连 HTTP→HTTPS 同主机场景(及同源场景)会保留 Authorization 头——工具自带 key 配置(或共享客户端的 cookie,重定向请求会携带 jar 中所有 cookie)时,该凭据会跟随重定向按原样发送给第三方域名,属于本变更引入的凭据泄露风险(httpx 0.27~0.28 均如此);二是 _parse_sse_payloads 对跨行 data 片段错误地按 \n 拼接后解析(多行 data 在 JSON 字符串内部拼接原始换行导致 json.loads 必败),随后静默丢弃整段事件、仅记录 warning,最终整次搜索静默降级为 0 条结果(原 SSE 规范要求事件间无空行分割时应拼接整段,而不是按行尝试)。另有若干中低严重度问题:响应体无限读取(超时内可无限偏离网络超时语义,超时取消后 httpx 0.27/0.28 会持久化连接损坏,与后续复用同一共享客户端的 Tavily 等请求产生 httpx.RemoteProtocolError/Request body read error);lang 仅加在 query 上而与 youcom_extra_params 中自定义的 query 交互(保留参数检查遗漏)以及 lang: 剥离仅处理 stripped 前缀;.strip() 后 lines[0] 为空字符串时索引越界(导致 _parse_sse_payloads 直接抛异常、搜索整体失败);BaseTool 默认超时与帧间护栏缺失;http_client 并发复用时的 cookies 竞态;search_youcom 参数注入风险;httpx.Timeout 构造歧义;真实 You.com free-profile 端点是 MCP SSE 流而不是一次请求拿到整个 body,单次 POST 读取整个响应正文的假设可能不成立。门禁结论:存在 1 个 SEVERE(重定向时凭据泄露)、1 个 SEVERE(SSE 多行 data 静默丢结果,若上游实际存在该形态则为高影响正确性问题,证据确凿为解析逻辑缺陷)及若干中低危问题,建议修复后合入。

发现的问题

严重

trpc_agent_sdk/tools/_websearch_tool.py:723-730

问题: _post_sse 为修复未跟随重定向导致空结果的问题,改为 follow_redirects=True 自动跟随,但 httpx 的重定向逻辑(0.27~0.28.1 的 _redirect_headers)在『同源重定向』以及『直连 HTTP→HTTPS 同主机升级』场景下会原样保留 Authorization 头(仅跨源且非 HTTPS 升级时剥离);同时 _build_redirect_request 会用 Cookies(self.cookies) 重新填充重定向请求的 Cookie 头。当 You.com 端点对同一 base_url 返回 301/302(换域名或临时跳转)时,本请求携带的 Authorization: Bearer <YDC_API_KEY>(或共享 http_client cookie jar 中的凭据)会被直接发送给重定向目标域名,构成凭据泄露面。

触发条件: 配置了 YDC_API_KEY 或 api_key 的 provider="youcom" 请求,You.com 端点返回 301/302 且目标为同源 URL 或同主机 HTTP→HTTPS(框内自动跟随,且 auth=None 只抑制了 client 级 auth,并不会剥离已显式写入 request headers 的 Authorization)。

实际影响: YDC API Key 或共享客户端的 cookie 会随重定向按原样发给重定向目标主机,第三方可借此获取凭据或身份信息,属于本变更引入的凭据泄露风险。

修正方向: 在 _post_sse 中改用手动处理重定向:先以 follow_redirects=False 发送,收到 3xx 时仅当目标 _same_origin 才放行(否则记录 warning 并返回空结果),或改用显式 httpx.AsyncClient(..., follow_redirects=True) 并在 _redirect_headers 等价逻辑处强制剥离 Authorization/Cookie 后再放行;至少不要对含 Key 的请求自动跟随跨源重定向。

严重

trpc_agent_sdk/tools/_websearch_tool.py:350-356

问题: _parse_sse_payloads 的多行 data 处理逻辑错误:SSE 规范规定一个事件的数据可以跨多条连续 data: 行,完整事件数据应整体拼接后解析;此处 _flush 用 \n 拼接后 json.loads 解析——json 标准库对字符串内部的裸控制字符(含 \n)直接报 Invalid control character,必然解析失败(已实测验证),然后静默丢弃整段事件、只记一条 warning,最终结果可能是 0 命中却返回空成功结果。

触发条件: 上游在 SSE 事件内用多行 data 传输(例如 You.com 将 \n 转义的 JSON 内容拆成多行,或将超大 JSON 对象分段发送),该事件被整段丢弃。

实际影响: 整次搜索静默降级为空结果(results=[]、summary="")返回给 LLM,用户看到『0 条结果』却无任何错误提示,与代码注释宣称的『stray keep-alive 不会破坏搜索』相悖。

修正方向: 多行 data 拼接时应按 SSE 规范将各 data: 行的内容用 \n 连接(保留原始换行作为 JSON 内容的一部分),而不是在拼接后统一 json.loads 失败就丢弃;或者先按空白行切出完整事件、仅对完整事件的 data 整体解析,解析失败再逐行回退。

中等

trpc_agent_sdk/tools/_websearch_tool.py:717-741

问题: _post_sse 直接用 await client.send(request, ...) 后 resp.text 整个读取响应正文——而 You.com 的 streamable-HTTP MCP 端点实际返回的是持续 SSE 流(事件流不结束、或通过 keep-alive 维持连接),resp.text 会无限等待直到超时;超时发生后 httpx 0.27/0.28 会因连接未完全关闭导致后续复用该共享 http_client 的 Tavily 等请求出现 httpx.RemoteProtocolError / Request body read error。同时本文件其它 provider 使用 self._timeout 逐请求传参,此处在 httpx.Timeout(self._timeout) 设置于 request.extensions 但 client.send 超时后不会关闭连接。

触发条件: You.com 服务端按 MCP 流式规范持续推送事件或 keep-alive 不结束正文,或响应正文较大导致客户端读取时间超过超时阈值。

实际影响: 搜索请求挂起 15 秒后报 HTTP_ERROR 或异常,且共享连接池内连接状态损坏,同进程后续其它 provider 的搜索也会失败;用户侧表现为偶发的『搜索超时 + 后续搜索全部报错』。

修正方向: 使用 httpx-sse(项目已声明依赖 httpx-sse>=0.4.0)以 acontext_stream 逐事件读取并尽早 break,或对 resp.text 明确设置 read 超时并在 finally 中 await response.aclose() 关闭连接,避免无限读取和连接池污染。

中等

trpc_agent_sdk/tools/_websearch_tool.py:871-891

问题: lang 内联过滤符与 youcom_extra_params 的交互有缺陷:arguments["query"] = f"{query} lang:{lang_value}" 之后,youcom_extra_params 的循环只拦截 query/count/exclude_domains 三个 key,lang(或 lang:)不在拦截列表里,用户配置 youcom_extra_params={"query": "pinned"} 时 arguments["query"] 会被派生值覆盖(符合预期),但成对出现的 arguments["query"] = lang 拼接 与后续 arguments[key] = value 会互相覆盖;且 lang 值为 "lang:ja" 时 while lang_value.startswith("lang:") 只处理了 stripped 前缀," lang:ja"(带空格)无法剥离。

触发条件: 用户设置了 youcom_extra_params={"query": ...} 且同时传入 lang,或 lang 参数带前导空格/大小写变体。

实际影响: lang 过滤符丢失或意外覆盖、查询字符串被非预期拼接,最终搜索词与用户意图不一致,且 youcom_extra_params 中被拒 key 的 warning 日志与文档承诺(保留参数仅 query/count/exclude_domains)不符。

修正方向: 将 lang 与 lang: 纳入 _youcom_extra_params 的保留参数拦截列表(与 query 一起处理),并对 lang_value 先做 strip() 再循环剥离 lang: 前缀。

较低

trpc_agent_sdk/tools/_websearch_tool.py:300-302

问题: lines[0] = lines[0].lstrip("") 在 lines[0] 为纯 BOM 或空串时会得到空字符串,后续 _flush 中 buffer 可能为空,但若输入只有一行且为 BOM,lines[0].startswith 判断为 True 后 lstrip 得到 "",随后 elif not line.strip() 分支在 _flush 中被跳过,lines 索引访问不会越界——真正的问题在 _parse_sse_payloads 对『只有 BOM 且无 data 行』的输入:lines 长度为 1、内容为 ,lines[0].startswith("") 为 True,lstrip 后为空串,最终 _flush() 空 buffer 直接返回,payloads 为空——不会崩溃但会静默返回空结果。

触发条件: 服务端返回的 SSE 正文以 BOM 开头且仅含 BOM(或 BOM 后无任何 data: 行);或 data: 行首字节为 BOM。

实际影响: 空 payload 导致 _search_youcom 走到 result=None 分支,返回『You.com search returned no result payload.』,与真实的 0 命中错误信息混淆,无法区分上游故障与正常空结果。

修正方向: 对 lines[0] 在 lstrip 后仍为空的情况做守卫(如 if not lines[0]: lines = lines[1:] 或直接剥掉 BOM 后继续处理),并对空输入显式返回空列表而不产生误导性错误信息。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +723 to +730
resp = await client.send(
request,
auth=None,
follow_redirects=True,
)
for cookie in list(client.cookies.jar):
if (cookie.name, cookie.domain, cookie.path) not in known_cookies:
client.cookies.delete(cookie.name, domain=cookie.domain, path=cookie.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.

问题: _post_sse 为修复未跟随重定向导致空结果的问题,改为 follow_redirects=True 自动跟随,但 httpx 的重定向逻辑(0.27~0.28.1 的 _redirect_headers)在『同源重定向』以及『直连 HTTP→HTTPS 同主机升级』场景下会原样保留 Authorization 头(仅跨源且非 HTTPS 升级时剥离);同时 _build_redirect_request 会用 Cookies(self.cookies) 重新填充重定向请求的 Cookie 头。当 You.com 端点对同一 base_url 返回 301/302(换域名或临时跳转)时,本请求携带的 Authorization: Bearer <YDC_API_KEY>(或共享 http_client cookie jar 中的凭据)会被直接发送给重定向目标域名,构成凭据泄露面。

触发条件: 配置了 YDC_API_KEY 或 api_key 的 provider="youcom" 请求,You.com 端点返回 301/302 且目标为同源 URL 或同主机 HTTP→HTTPS(框内自动跟随,且 auth=None 只抑制了 client 级 auth,并不会剥离已显式写入 request headers 的 Authorization)。

实际影响: YDC API Key 或共享客户端的 cookie 会随重定向按原样发给重定向目标主机,第三方可借此获取凭据或身份信息,属于本变更引入的凭据泄露风险。

修正方向: 在 _post_sse 中改用手动处理重定向:先以 follow_redirects=False 发送,收到 3xx 时仅当目标 _same_origin 才放行(否则记录 warning 并返回空结果),或改用显式 httpx.AsyncClient(..., follow_redirects=True) 并在 _redirect_headers 等价逻辑处强制剥离 Authorization/Cookie 后再放行;至少不要对含 Key 的请求自动跟随跨源重定向。

Comment on lines +350 to +356
for line in lines:
if line.startswith("data:"):
buffer.append(line[len("data:"):].strip())
elif not line.strip():
# A blank line dispatches the event per the SSE spec.
_flush()
_flush()

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.

问题: _parse_sse_payloads 的多行 data 处理逻辑错误:SSE 规范规定一个事件的数据可以跨多条连续 data: 行,完整事件数据应整体拼接后解析;此处 _flush 用 \n 拼接后 json.loads 解析——json 标准库对字符串内部的裸控制字符(含 \n)直接报 Invalid control character,必然解析失败(已实测验证),然后静默丢弃整段事件、只记一条 warning,最终结果可能是 0 命中却返回空成功结果。

触发条件: 上游在 SSE 事件内用多行 data 传输(例如 You.com 将 \n 转义的 JSON 内容拆成多行,或将超大 JSON 对象分段发送),该事件被整段丢弃。

实际影响: 整次搜索静默降级为空结果(results=[]、summary="")返回给 LLM,用户看到『0 条结果』却无任何错误提示,与代码注释宣称的『stray keep-alive 不会破坏搜索』相悖。

修正方向: 多行 data 拼接时应按 SSE 规范将各 data: 行的内容用 \n 连接(保留原始换行作为 JSON 内容的一部分),而不是在拼接后统一 json.loads 失败就丢弃;或者先按空白行切出完整事件、仅对完整事件的 data 整体解析,解析失败再逐行回退。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +717 to +741
request = httpx.Request("POST", url, json=payload, headers=request_headers)
# send() falls back to the client timeout when the extension is
# absent; keep the per-call timeout semantics of the other providers.
request.extensions["timeout"] = httpx.Timeout(self._timeout).as_dict()
try:
known_cookies = {(cookie.name, cookie.domain, cookie.path) for cookie in client.cookies.jar}
resp = await client.send(
request,
auth=None,
follow_redirects=True,
)
for cookie in list(client.cookies.jar):
if (cookie.name, cookie.domain, cookie.path) not in known_cookies:
client.cookies.delete(cookie.name, domain=cookie.domain, path=cookie.path)
if 300 <= resp.status_code < 400:
# Defensive: with redirects followed this should not be
# reachable, but a residual 3xx must not read downstream as
# a normal empty search.
logger.warning(
"WebSearchTool: You.com endpoint responded %d without "
"following the redirect; search may return no results.",
resp.status_code,
)
resp.raise_for_status()
return _parse_sse_payloads(resp.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.

问题: _post_sse 直接用 await client.send(request, ...) 后 resp.text 整个读取响应正文——而 You.com 的 streamable-HTTP MCP 端点实际返回的是持续 SSE 流(事件流不结束、或通过 keep-alive 维持连接),resp.text 会无限等待直到超时;超时发生后 httpx 0.27/0.28 会因连接未完全关闭导致后续复用该共享 http_client 的 Tavily 等请求出现 httpx.RemoteProtocolError / Request body read error。同时本文件其它 provider 使用 self._timeout 逐请求传参,此处在 httpx.Timeout(self._timeout) 设置于 request.extensions 但 client.send 超时后不会关闭连接。

触发条件: You.com 服务端按 MCP 流式规范持续推送事件或 keep-alive 不结束正文,或响应正文较大导致客户端读取时间超过超时阈值。

实际影响: 搜索请求挂起 15 秒后报 HTTP_ERROR 或异常,且共享连接池内连接状态损坏,同进程后续其它 provider 的搜索也会失败;用户侧表现为偶发的『搜索超时 + 后续搜索全部报错』。

修正方向: 使用 httpx-sse(项目已声明依赖 httpx-sse>=0.4.0)以 acontext_stream 逐事件读取并尽早 break,或对 resp.text 明确设置 read 超时并在 finally 中 await response.aclose() 关闭连接,避免无限读取和连接池污染。

Comment on lines +871 to +891
arguments: dict[str, Any] = {"query": query, "count": n}
if lang:
# You.com maps an inline ``lang:`` operator to a language filter.
# Guard against the model passing the operator form verbatim
# (``lang:ja``) after reading the schema description.
lang_value = str(lang).strip()
while lang_value.startswith("lang:"):
lang_value = lang_value[len("lang:"):].strip()
if lang_value:
arguments["query"] = f"{query} lang:{lang_value}"
if blocked:
arguments["exclude_domains"] = blocked
for key, value in self._youcom_extra_params.items():
if key in ("query", "count", "exclude_domains"):
# These are built per call; a pinned value would silently
# override every search the model issues.
logger.warning(
"WebSearchTool: ignoring youcom_extra_params key %r; "
"it would override per-call arguments", key)
continue
arguments[key] = value

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.

问题: lang 内联过滤符与 youcom_extra_params 的交互有缺陷:arguments["query"] = f"{query} lang:{lang_value}" 之后,youcom_extra_params 的循环只拦截 query/count/exclude_domains 三个 key,lang(或 lang:)不在拦截列表里,用户配置 youcom_extra_params={"query": "pinned"} 时 arguments["query"] 会被派生值覆盖(符合预期),但成对出现的 arguments["query"] = lang 拼接 与后续 arguments[key] = value 会互相覆盖;且 lang 值为 "lang:ja" 时 while lang_value.startswith("lang:") 只处理了 stripped 前缀," lang:ja"(带空格)无法剥离。

触发条件: 用户设置了 youcom_extra_params={"query": ...} 且同时传入 lang,或 lang 参数带前导空格/大小写变体。

实际影响: lang 过滤符丢失或意外覆盖、查询字符串被非预期拼接,最终搜索词与用户意图不一致,且 youcom_extra_params 中被拒 key 的 warning 日志与文档承诺(保留参数仅 query/count/exclude_domains)不符。

修正方向: 将 lang 与 lang: 纳入 _youcom_extra_params 的保留参数拦截列表(与 query 一起处理),并对 lang_value 先做 strip() 再循环剥离 lang: 前缀。

Comment on lines +300 to +302
lines = (text or "").splitlines()
if lines and lines[0].startswith("\ufeff"):
lines[0] = lines[0].lstrip("\ufeff")

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.

问题: lines[0] = lines[0].lstrip("") 在 lines[0] 为纯 BOM 或空串时会得到空字符串,后续 _flush 中 buffer 可能为空,但若输入只有一行且为 BOM,lines[0].startswith 判断为 True 后 lstrip 得到 "",随后 elif not line.strip() 分支在 _flush 中被跳过,lines 索引访问不会越界——真正的问题在 _parse_sse_payloads 对『只有 BOM 且无 data 行』的输入:lines 长度为 1、内容为 ,lines[0].startswith("") 为 True,lstrip 后为空串,最终 _flush() 空 buffer 直接返回,payloads 为空——不会崩溃但会静默返回空结果。

触发条件: 服务端返回的 SSE 正文以 BOM 开头且仅含 BOM(或 BOM 后无任何 data: 行);或 data: 行首字节为 BOM。

实际影响: 空 payload 导致 _search_youcom 走到 result=None 分支,返回『You.com search returned no result payload.』,与真实的 0 命中错误信息混淆,无法区分上游故障与正常空结果。

修正方向: 对 lines[0] 在 lstrip 后仍为空的情况做守卫(如 if not lines[0]: lines = lines[1:] 或直接剥掉 BOM 后继续处理),并对空输入显式返回空列表而不产生误导性错误信息。

- follow 3xx responses manually, same-origin only, so Authorization and
  jar cookies can never reach a redirect-target host; warn and return no
  results on cross-origin/invalid targets or an exhausted redirect budget
- close each response in a finally block so a mid-stream failure cannot
  leak a broken connection into a shared http_client pool
- join a JSON frame chunked across data: lines without a separator
  (json.loads rejects the raw control character the SSE \n join inserts)
- drop a BOM-only first SSE line instead of treating it as data
- reserve lang in youcom_extra_params (it collides with the inline
  lang: operator), with docs and tests for all of the above
@valueadd-lgtm

Copy link
Copy Markdown
Author

Thanks for the detailed review — pushed dc9f1f9 addressing the findings:

  • Unreleased responses: _post_sse now closes every response in a finally block (resp.aclose() before the optional client close), so a mid-stream read failure can't strand a broken connection in a shared http_client pool.
  • Redirect credential forwarding: redirects are followed manually now (follow_redirects=False plus a same-origin check against the original URL). A 3xx naming a different host/port, or downgrading the scheme, is dropped with a warning — Authorization and any jar cookies can never reach the redirect target. Same-origin moves (and http→https upgrades on the same host) are still followed, with the redirect budget capped at 3. New tests: test_youcom_cross_origin_redirect_dropped, test_youcom_same_origin_redirect_followed_with_key, and a TestSameOrigin unit class.
  • Chunked SSE frames: _flush now falls back to a separator-less join when the SSE-spec join fails to parse and more than one data: line is buffered — json.loads rejects the raw \n control character the spec join inserts mid-string, which silently dropped such frames as 0-hit searches. Covered by test_parse_sse_payloads_joins_chunked_frames_without_separator.
  • BOM: a BOM-only first line is dropped as framing noise rather than parsed as data (new tests for the BOM-only-line and BOM-only-body cases).
  • lang in youcom_extra_params: now reserved like query/count/exclude_domains — it collides with the per-call inline lang: operator, so pinning it is rejected with a warning instead of landing in the tools/call arguments. Docs (en + zh) updated; covered by test_youcom_lang_extra_param_is_reserved.
  • Docstring: updated to describe the redirect policy and response release accurately.

Full suite for this file: 131 tests pass, flake8 clean on both touched files. Happy to adjust the shape of any of these if something looks off.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

不通过

审查范围:base 02509b2..head dc9f1f9,4 个文件(_websearch_tool.py、test_websearch_tool.py、docs en/zh tool.md),新增 youcom provider(SSE JSON-RPC、keyless free profile、YDC_API_KEY 认证切换)。方法与证据:10 个角度并行发现候选,对照本地克隆 httpx 0.28.1 源码与 httpcore 0.14.7 逐条验证(send 的 auth=None/headers/超时扩展、extract_cookies 对未跟随 3xx 的调用、CookieJar.clear、raise_for_status 对 3xx 抛 HTTPStatusError 等)。计划符合性:功能完整实现,测试覆盖充分(约 1190 行新增),httpx>=0.27.0 兼容性核实通过。主要风险:SEVERE — 跨源重定向分支 return [] 位于 Cookie 清理循环之前,You.com Set-Cookie 残留共享 jar 并被后续 Tavily/Google/DDG 请求重放(docstring 承诺被破坏);MODERATE — 重定向逐跳校验基准错误且 _same_origin 允许 http 明文降级链携带 Bearer 头;MODERATE — 相对跳转丢失 ?profile=free 致 keyless 请求落到认证端点;LOW — 扁平 list 形状分支的 sections 键名覆盖、非字符串字段 str() 静默转换、en 文档 summary 行未同步(锚定到同 hunk 已变更的 provider 表行 2594)。测试影响:新增测试设计良好但 test_flat_list_results_shape_parses_hits 的单键输入未暴露 1088-1094 行缺陷。门禁结论:FAILED(存在 1 条 SEVERE + 2 条 MODERATE)。

发现的问题

严重

trpc_agent_sdk/tools/_websearch_tool.py:804-808

问题: 跨源重定向分支在响应里直接 return [],跳过了紧随其后的 Cookie 清理循环(806-808 行)。httpx 在 _send_single_request 中对每次响应(包括未跟随的 3xx)都会调用 extract_cookies,因此 You.com 3xx 响应携带的 Set-Cookie 已写入共享 http_client 的 cookie jar;早退使清理永不执行,与 _post_sse docstring 中"任何 Set-Cookie 都会被丢弃"的安全承诺直接冲突。

触发条件: 传入共享 http_client(其 jar 会被 client.post/client.get 合并进后续请求),且 You.com 端点返回 302/301 重定向到非同一源(如 https://collector.example.com/...)并带 Set-Cookie;或重定向链中的任意一跳 client.send 抛传输异常。

实际影响: You.com 种下的 you_session 等 cookie 残留在共享 jar 中,同一 client 后续发往 Tavily/Google/DDG 的请求会携带该 Cookie 头——把第三方端点写入的 Cookie 重放到其他提供方,正是本次变更要防止的泄漏场景。

修正方向: 将 Cookie 回滚移到 finally 块(或先于各 return 的公共出口),确保跨源早退、跳转预算耗尽、异常路径都执行清理;同时考虑对跳转中间响应(跳过的 3xx)的 cookie 也按快照回滚。

中等

trpc_agent_sdk/tools/_websearch_tool.py:794-805

问题: 重定向循环每跳都调用 _same_origin(url, next_url),把目标与最初 URL 比较而非与上一跳 current_url 比较。httpx 已确认 URL.join 对相对 Location 保留原 scheme/host,故同源跳转测试能过;但 _same_origin 允许同主机 https→http 升级被放行后,后续 http 明文跳转不再被拦(仍带 Bearer 头);多跳链的校验基准也不一致。

触发条件: 服务器返回如 https://api.you.com/mcp → http://api.you.com/mcp → http://api.you.com/mcp2 的链(首次升级被策略放行),或第一跳同源、第二跳跨源但相对目标与原始 URL 同源的情形。

实际影响: YDC_API_KEY Bearer 头沿明文 http 跳转被发送,或在应拦截的跨源第二跳被放行;违反"Bearer 头绝不离开 you.com 源"的原始设计意图。

修正方向: 用 _same_origin(current_url, next_url) 逐跳比较,并额外要求每跳目标与原始 URL 同源或严格 https(禁止任何 http 明文目标),补多跳链测试。

中等

trpc_agent_sdk/tools/_websearch_tool.py:785

问题: 相对 Location 通过 httpx.URL(current_url).join(location) 解析时,?profile=free 查询串不会随相对路径保留(httpx URL.join 委托 urljoin,仅当 Location 为绝对 URL 或空时才保留查询),导致 keyless 请求在跳转后落到认证端点。

触发条件: 配置了 keyless free 端点 https://api.you.com/mcp?profile=free,服务器对 /mcp 返回相对 Location(如 /mcp 或 /mcp-moved)。

实际影响: 后续请求打到 https://api.you.com/mcp(无 profile)且未配置 API key,认证端点拒绝请求产生错误摘要/0 结果,keyless 搜索在跳转泛化部署下静默失败。

修正方向: 跳转目标解析后,若原始 URL 带 profile 查询串且新 URL 无此参数,则将查询串合并到 next_url 再发送;或改用显式检查 next_url.query 保留原始查询。

较低

trpc_agent_sdk/tools/_websearch_tool.py:1088-1094

问题: 当 results 是扁平 list(上游形状漂移分支)时,sections = {"web": sections} 以 "web" 键覆盖 data 中已存在的真实 web 段:真实 web 段被整个丢弃,扁平列表被当作唯一 web 段解析。

触发条件: 响应同时包含 results.web 真实段与扁平列表形式(如 {"results": [{...}], "results.web": [...]}),或扁平列表形式恰好被测试用例 test_flat_list_results_shape_parses_hits 触发但现有测试仅有单键输入未察觉。

实际影响: 真实 web 命中被静默丢弃,用户只看到扁平列表子集;依赖 web+news 合并语义的下游引用会缺漏来源。

修正方向: 改为保留真实键:如 if isinstance(sections, list): sections = {"web": sections} 前先检查 sections.get("web") 是否已存在,不存在再赋值;并补充包含双键 web + 扁平列表的回归测试。

较低

trpc_agent_sdk/tools/_websearch_tool.py:1124-1150

问题: 命中解析对非字符串字段用 str(item.get("url") or "") 等强制转字符串,dict/list 类型的 title、description、snippets 元素会被转成 Python repr 文本而非跳过或取内嵌字符串,且 _is_blocked 对这类假 URL 返回 True 会把合法命中静默丢弃(无日志)。

触发条件: You.com 变体返回结构化 URL 对象(如 {"url": {"kind": "web", "href": "https://..."}})或 list 型 title/description。

实际影响: 合法命中被静默丢弃(url 变 repr → _is_blocked 返回 True)或模型在 Sources 段引用 {'kind': ...} 形式的杂散文本;列表型 description 被当作 snippet 输出污染结果。

修正方向: 对 url/title/description 校验类型:非 str 时尝试取内部标准字段(如 href)或直接跳过并告警,与 DDG/Tavily 的防御式 dict 处理保持一致。

较低

docs/mkdocs/en/tool.md:2594

问题: 本行把 provider 返回值表更新为包含 youcom,但紧随其后的 summary 字段行未同步:zh 文档已加入"You.com 在 JSON-RPC / 工具错误时也会把错误信息写入此字段",en 文档仍只写 DDG/Google/Tavily,中英文档对新行为的描述不一致。

触发条件: 阅读英文文档的用户按需依赖 summary 作为错误通道时无此信息。

实际影响: 文档误导——en 用户会以为 youcom 从不往 summary 写错误信息,错误处理与引用逻辑按文档假设实现时行为偏差。

修正方向: 将 zh 对应的 summary 行说明同步到 docs/mkdocs/en/tool.md("You.com 在 JSON-RPC / 工具错误时也会把错误信息写入 summary")。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +804 to +808
return []
current_url = next_url
for cookie in list(client.cookies.jar):
if (cookie.name, cookie.domain, cookie.path) not in known_cookies:
client.cookies.delete(cookie.name, domain=cookie.domain, path=cookie.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.

问题: 跨源重定向分支在响应里直接 return [],跳过了紧随其后的 Cookie 清理循环(806-808 行)。httpx 在 _send_single_request 中对每次响应(包括未跟随的 3xx)都会调用 extract_cookies,因此 You.com 3xx 响应携带的 Set-Cookie 已写入共享 http_client 的 cookie jar;早退使清理永不执行,与 _post_sse docstring 中"任何 Set-Cookie 都会被丢弃"的安全承诺直接冲突。

触发条件: 传入共享 http_client(其 jar 会被 client.post/client.get 合并进后续请求),且 You.com 端点返回 302/301 重定向到非同一源(如 https://collector.example.com/...)并带 Set-Cookie;或重定向链中的任意一跳 client.send 抛传输异常。

实际影响: You.com 种下的 you_session 等 cookie 残留在共享 jar 中,同一 client 后续发往 Tavily/Google/DDG 的请求会携带该 Cookie 头——把第三方端点写入的 Cookie 重放到其他提供方,正是本次变更要防止的泄漏场景。

修正方向: 将 Cookie 回滚移到 finally 块(或先于各 return 的公共出口),确保跨源早退、跳转预算耗尽、异常路径都执行清理;同时考虑对跳转中间响应(跳过的 3xx)的 cookie 也按快照回滚。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +794 to +805
if not _same_origin(url, next_url):
# The request carries this tool's Authorization header when
# a key is configured; following a redirect to another
# origin would forward it (and any cookies httpx re-attaches
# to redirect requests) to whatever host the 3xx names.
logger.warning(
"WebSearchTool: dropping redirect from the You.com endpoint to "
"non-same-origin URL %r; search may return no results.",
next_url,
)
return []
current_url = next_url

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.

问题: 重定向循环每跳都调用 _same_origin(url, next_url),把目标与最初 URL 比较而非与上一跳 current_url 比较。httpx 已确认 URL.join 对相对 Location 保留原 scheme/host,故同源跳转测试能过;但 _same_origin 允许同主机 https→http 升级被放行后,后续 http 明文跳转不再被拦(仍带 Bearer 头);多跳链的校验基准也不一致。

触发条件: 服务器返回如 https://api.you.com/mcp → http://api.you.com/mcp → http://api.you.com/mcp2 的链(首次升级被策略放行),或第一跳同源、第二跳跨源但相对目标与原始 URL 同源的情形。

实际影响: YDC_API_KEY Bearer 头沿明文 http 跳转被发送,或在应拦截的跨源第二跳被放行;违反"Bearer 头绝不离开 you.com 源"的原始设计意图。

修正方向: 用 _same_origin(current_url, next_url) 逐跳比较,并额外要求每跳目标与原始 URL 同源或严格 https(禁止任何 http 明文目标),补多跳链测试。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
break
location = resp.headers.get("location", "")
try:
next_url = str(httpx.URL(current_url).join(location))

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.

问题: 相对 Location 通过 httpx.URL(current_url).join(location) 解析时,?profile=free 查询串不会随相对路径保留(httpx URL.join 委托 urljoin,仅当 Location 为绝对 URL 或空时才保留查询),导致 keyless 请求在跳转后落到认证端点。

触发条件: 配置了 keyless free 端点 https://api.you.com/mcp?profile=free,服务器对 /mcp 返回相对 Location(如 /mcp 或 /mcp-moved)。

实际影响: 后续请求打到 https://api.you.com/mcp(无 profile)且未配置 API key,认证端点拒绝请求产生错误摘要/0 结果,keyless 搜索在跳转泛化部署下静默失败。

修正方向: 跳转目标解析后,若原始 URL 带 profile 查询串且新 URL 无此参数,则将查询串合并到 next_url 再发送;或改用显式检查 next_url.query 保留原始查询。

Comment on lines +1088 to +1094
if isinstance(sections, list):
# Shape drift upstream: some You.com variants return a flat list
# of hits instead of ``{"web": [...], "news": [...]}`` sections.
# Route the parseable entries through the same section loop
# instead of dropping every hit.
sections = {"web": sections}
if not isinstance(sections, dict):

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.

问题: 当 results 是扁平 list(上游形状漂移分支)时,sections = {"web": sections} 以 "web" 键覆盖 data 中已存在的真实 web 段:真实 web 段被整个丢弃,扁平列表被当作唯一 web 段解析。

触发条件: 响应同时包含 results.web 真实段与扁平列表形式(如 {"results": [{...}], "results.web": [...]}),或扁平列表形式恰好被测试用例 test_flat_list_results_shape_parses_hits 触发但现有测试仅有单键输入未察觉。

实际影响: 真实 web 命中被静默丢弃,用户只看到扁平列表子集;依赖 web+news 合并语义的下游引用会缺漏来源。

修正方向: 改为保留真实键:如 if isinstance(sections, list): sections = {"web": sections} 前先检查 sections.get("web") 是否已存在,不存在再赋值;并补充包含双键 web + 扁平列表的回归测试。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +1124 to +1150
url = str(item.get("url") or "").strip()
if not url or _is_blocked(url, allowed, blocked):
continue
if self._dedup_urls:
key = _dedup_key(url)
if key in seen:
continue
seen.add(key)
description = str(item.get("description") or "").strip()
if not description:
# ``snippets`` is You.com's canonical summary field (a list
# of strings returned by default); some hits — notably
# news — carry it while ``description`` is empty or absent.
snippets = item.get("snippets")
if isinstance(snippets, list):
for snippet in snippets:
if isinstance(snippet, str) and snippet.strip():
description = snippet.strip()
break
if not description:
contents = item.get("contents")
highlights = contents.get("highlights") if isinstance(contents, dict) else None
# Only a list of strings is a usable fallback; anything else
# (a bare string, a dict, ...) must not crash the whole search.
if isinstance(highlights, list) and highlights and isinstance(highlights[0], str):
description = highlights[0].strip()
hits.append(

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.

问题: 命中解析对非字符串字段用 str(item.get("url") or "") 等强制转字符串,dict/list 类型的 title、description、snippets 元素会被转成 Python repr 文本而非跳过或取内嵌字符串,且 _is_blocked 对这类假 URL 返回 True 会把合法命中静默丢弃(无日志)。

触发条件: You.com 变体返回结构化 URL 对象(如 {"url": {"kind": "web", "href": "https://..."}})或 list 型 title/description。

实际影响: 合法命中被静默丢弃(url 变 repr → _is_blocked 返回 True)或模型在 Sources 段引用 {'kind': ...} 形式的杂散文本;列表型 description 被当作 snippet 输出污染结果。

修正方向: 对 url/title/description 校验类型:非 str 时尝试取内部标准字段(如 href)或直接跳过并告警,与 DDG/Tavily 的防御式 dict 处理保持一致。

Comment thread docs/mkdocs/en/tool.md
|------|------|------|
| `query` | `str` | Query term used for this search (echoed back verbatim) |
| `provider` | `"duckduckgo" \| "google"` | Provider actually used |
| `provider` | `"duckduckgo" \| "google" \| "tavily" \| "youcom"` | Provider actually used |

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.

问题: 本行把 provider 返回值表更新为包含 youcom,但紧随其后的 summary 字段行未同步:zh 文档已加入"You.com 在 JSON-RPC / 工具错误时也会把错误信息写入此字段",en 文档仍只写 DDG/Google/Tavily,中英文档对新行为的描述不一致。

触发条件: 阅读英文文档的用户按需依赖 summary 作为错误通道时无此信息。

实际影响: 文档误导——en 用户会以为 youcom 从不往 summary 写错误信息,错误处理与引用逻辑按文档假设实现时行为偏差。

修正方向: 将 zh 对应的 summary 行说明同步到 docs/mkdocs/en/tool.md("You.com 在 JSON-RPC / 工具错误时也会把错误信息写入 summary")。

…parsing

- Roll the shared-jar cookie snapshot/rollback into _post_sse's finally
  block so it also runs on the cross-origin early return, the
  redirect-budget break, and exception paths (previously skipped by the
  early return, leaking You.com Set-Cookie into the shared jar).
- Validate each redirect hop against the URL it redirects *from*
  (current_url, not the original), and forbid plaintext http targets
  unless the endpoint was configured that way (same-origin with the
  original URL) — stops the Authorization header from traveling over
  plaintext http after a same-host upgrade hop.
- Re-attach the original ?profile=free query param on relative
  redirects (URL.join drops it), so keyless calls do not land on the
  authenticated endpoint.
- Merge (not clobber) real web/news sections that sit next to a
  flat-list results payload.
- Type-guard url/title/description instead of str()-coercing: unwrap
  structured url objects via href, skip unusable urls with a debug log,
  and stop Python repr text leaking into titles/snippets.
- Sync the en WebSearchResult summary row with the zh docs (You.com
  JSON-RPC/tool errors also land in summary).
@valueadd-lgtm

Copy link
Copy Markdown
Author

Thanks again for the sharp round — all six findings are addressed in b7cc7ae, with regression tests for each:

  • Cookie rollback skipped by the cross-origin early return (SEVERE): confirmed — the snapshot/rollback was sitting inside the try body after the loop, so both the return [] and any transport exception mid-loop skipped it, leaving You.com's Set-Cookie in the shared jar for the next Tavily/Google/DDG call to replay. The snapshot is now taken before the first request and the rollback moved into the finally, so it runs on every exit path — cross-origin return, redirect-budget break, exceptions included. Verified against httpx 0.28.1 that extract_cookies writes into the jar for unfollowed 3xx responses too (test_youcom_cross_origin_redirect_rolls_back_cookies).
  • Per-hop origin validation + plaintext downgrade (MODERATE): each hop is now checked against the URL it redirects from (current_url), and additionally no hop may land on plaintext http unless the endpoint was configured that way (same-origin with the original URL). The chain http → https → http that used to pass (first hop upgraded, second hop compared against the http original) is now dropped at the second hop (test_youcom_plaintext_downgrade_after_upgrade_dropped).
  • Relative Location drops ?profile=free (MODERATE): confirmed empirically — httpx.URL("https://api.you.com/mcp?profile=free").join("/mcp") yields https://api.you.com/mcp (urljoin semantics). After resolving the hop, the original profile param is re-attached via copy_with(params=...) when the Location didn't carry one, so keyless calls stay on the free profile across the redirect (test_youcom_relative_redirect_keeps_profile_query).
  • Flat-list fallback clobbering real sections (LOW): a flat-list results now merges with genuine web/news section lists sitting next to it instead of replacing them (test_flat_list_results_with_sibling_sections_merged).
  • str() coercion of non-string fields (LOW): url/title/description are type-guarded now: a structured url object is unwrapped via its href, hits without a usable url are skipped with a debug log (instead of a repr that then failed the blocklist check silently), and list/dict titles/descriptions no longer leak Python repr text — they fall through to the snippets/highlights chain (test_non_string_fields_not_coerced).
  • en docs summary row (LOW): docs/mkdocs/en/tool.md now matches the zh docs — You.com JSON-RPC/tool errors also land in the summary field.

Local results on b7cc7ae: pytest tests/tools/ → 778 passed, 13 skipped (test_mempalace_tool.py can't import its optional dep locally, same as on the base branch); flake8 clean; yapf clean on the source file.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

审查范围:b7cc7ae(HEAD)对比 02509b2(base),6 个提交,变更 4 个文件:trpc_agent_sdk/tools/_websearch_tool.py(+1150 行新增 You.com 提供商)、tests/tools/test_websearch_tool.py(+1364 行)、docs/mkdocs/en|zh/tool.md。计划意图"新增 youcom 提供商"已完整落实:_search_youcom(JSON-RPC over streamable-HTTP SSE)、keyless profile=free 与 YDC_API_KEY 认证双模式、手动逐跳重定向校验、客户端级 auth/cookie 隔离与 cookie 回滚、snippets/highlights 字段兜底、youcom_extra_params 保留键防护,配套测试与文档齐全。我依据 httpx 0.27-0.28 与 httpcore 1.0.9 源码逐一验证了传输层设计:client.send(auth=None, follow_redirects=False) 确实不合并客户端级 headers/cookies,中间 3xx 响应被 send 完整读取并关闭,不存在连接池泄漏;_same_origin 与 httpx 自身的重定向授权保留语义一致;cookie 回滚删除"快照后新增"的 cookie 符合初衷。已确认的缺陷集中在两处:SSE 解析器的 _flush 在无空行分隔帧时存在帧合并逻辑缺陷(\n 优先拼接顺序可导致尾帧被静默丢弃、字符串内换行的 JSON 帧被污染成字符串),以及 cookie 回滚对覆盖写入(值变更而非新增)和并发共享客户端的外来 cookie 存在遗漏,外加 highlights 兜底只检查首元素。所有缺陷均为本变更引入(base 无 any youcom 代码)。测试覆盖 132 个测试,含重定向、cookie 回滚、SSE 拼接、isError、错误帧等关键路径,但对无空行分隔的多事件体与覆盖写 cookie 场景存在缺口。结论:无 SEVERE 级缺陷,门禁通过(PASSED);4 条 MODERATE/LOW 建议在合入前处理。

发现的问题

中等

trpc_agent_sdk/tools/_websearch_tool.py:330-364

问题: _flush(_parse_sse_payloads)先尝试 "\n".join(buffer) 再回退到 "".join(buffer);当服务端将单个 JSON 帧按字符串边界拆成多行 data:(如 data: {"snippet": "ab + data: c"})且事件间省略空行分隔符时,\n 拼接先被判定为"有效 JSON"——字符串内的换行使整帧被解析成损坏的字符串值(如 snippet 变成 ab\nc),后续解码逻辑无法再用无分隔符拼接纠正,属于本变更新增逻辑引入的帧形变缺陷。

触发条件: 服务端跨多行拆分 JSON 帧且拆分点位于 JSON 字符串内部(构造合法 JSON 但语义失真),同时事件间无空行分隔;两条 if decoded is not None 分支使无分隔符重试成为实际不可达的死代码。

实际影响: 搜索结果的 snippet/标题文本被静默插入换行而失真(若恰好仍是合法 JSON),或被整帧丢弃退化为 0 命中的空搜索,且无任何告警;"".join 回退因永远落在 decoded is not None 之后而失去修复作用。

修正方向: 调整拼接顺序为优先 "".join(buffer)(这是 JSON 语义上正确的按数据块重拼接),\n 拼接仅在无分隔符拼接失败后才回退尝试;或者至少将二者结果都校验后再采纳,确保多行拆分的字符串帧得到正确还原。

中等

trpc_agent_sdk/tools/_websearch_tool.py:854-858

问题: _post_sse 的 cookie 回滚只按 (name, domain, path) 三元组快照比对,快照后新增的 cookie 被删除,但快照中已存在、值在本次调用期间被 Set-Cookie 覆盖的 cookie 不会回滚(如预置的 you_session=old 被响应覆盖为 you_session=new),回滚后 jar 里残留新的污染值;同时 with_httpx 的 AsyncClient 若被并发共享(仓库 example 即演示多工具共用一个持久 AsyncClient),互不相关的并发请求写进 jar 的 cookie 会被本调用的回滚误删,属于本变更新增逻辑引入的数据完整性竞态。

触发条件: (1)调用前 jar 里已存在同名 cookie 时,You.com 的 Set-Cookie 覆盖其值后回滚比对三元组命中快照而跳过删除;(2)同一共享客户端上其他提供商的请求(如 Tavily)在快照之后、回滚之前写入 cookie,回滚按三元组比对认为它是"新增"而误删。

实际影响: (1)You.com 的会话/跟踪 cookie 经共享 jar 泄漏到后续发往其他域(如 api.tavily.com)的请求中;(2)其他并发调用刚刚写入的有效 cookie 被静默删除,导致那些调用鉴权/会话失效,均无明显日志。

修正方向: 回滚时连同值一并比对(快照存 (name, domain, path, value),只有完全一致才跳过删除),并在删除前复核其值仍与"本次新增"一致(比对值变化即视为本次响应写入,予以回滚);或用不与 jar 共享的独立 cookie 存储(如请求级 cookies= 参数)从根上消除跨调用污染。

较低

trpc_agent_sdk/tools/_websearch_tool.py:1209-1214

问题: _search_youcom 的 highlights 兜底条件 isinstance(highlights, list) and highlights and isinstance(highlights[0], str) 只检查首元素类型;当首元素为非字符串(如 {"text": ...} 对象或数字)而后续元素是可用字符串时,整个兜底被跳过,后续元素永远不会扫描,属本变更新增兜底逻辑覆盖不全。

触发条件: 服务端返回 "contents": {"highlights": [{"text": "x"}, "Python is a high-level language"]} —— 首元素为 dict 而非 str,isinstance(highlights[0], str) 为 False,跳过整个列表。

实际影响: 本可命中的 snippet 文本被丢弃,命中记录的 snippet 为空,模型得到的上下文信息减少,搜索回答质量下降(部分 You.com 变体确实混合返回对象与字符串元素)。

修正方向: 改为遍历 highlights 取第一个 isinstance(x, str) 且非空白的元素(与上方 snippets 的遍历写法一致),而不是只检查 highlights[0]。

较低

trpc_agent_sdk/tools/_websearch_tool.py:366-372

问题: _parse_sse_payloads 的 _flush 在解析失败时逐行回退,会把"无空行分隔的多个完整 JSON 帧"拆成多个 payload 而丢失事件边界(服务端省略空行分隔符时,期望的是把多行作为一个合并事件处理),这种帧级歧义正是本变更新增解析器引入的确定性行为,需要测试固定其取舍。

触发条件: 输入形如 'data: {"a": 1}\ndata: {"b": 2}\n'(无空行)或有分隔符的多行帧——现有测试 test_parse_sse_payloads_falls_back_to_single_lines_without_separators 只断言了逐行正确拆分的一个方向,未覆盖 data: {"... 字符串内换行帧在无空行分隔状态下的行为(现有 test_parse_sse_payloads_joins_chunked_frames_without_separator 带空行,不会触达该分支)。

实际影响: 修复上述拼接顺序问题时若无对应回归测试,现有测试套件无法捕获帧错误合并/静默丢弃的回归,所有测试保持绿色但生产合成行为错误;当前实现本身已有测试覆盖盲区。

修正方向: 在 TestParseSsePayloads 中增加无空行分隔且含字符串内换行帧的用例(断言能正确还原为单一 payload,或至少按已声明的取舍稳定输出),并补一个 \n 拼接已产出"有效 JSON"时无分隔符拼接应被采纳的用例,作为上述修正的回归保护。

Comment on lines +330 to +364
def _flush() -> None:
if not buffer:
return
decoded = _try_decode("\n".join(buffer))
if decoded is None and len(buffer) > 1:
# A server may chunk one JSON frame across several ``data:``
# lines without regard for JSON token boundaries. Re-inserting
# newlines between the chunks (the SSE join above) puts raw
# control characters inside the serialized JSON, which strict
# ``json.loads`` always rejects; the whole event would then
# degrade to a silently dropped frame. Try the chunks joined
# with no separator before falling back to per-line parsing.
decoded = _try_decode("".join(buffer))
if decoded is not None:
payloads.append(decoded)
else:
for piece in buffer:
raw = piece.strip()
if not raw:
# Empty ``data:`` lines are keep-alives; skip silently
# instead of logging noise for every one of them.
continue
decoded = _try_decode(piece)
if decoded is not None:
payloads.append(decoded)
continue
# At this point the line is either unparseable or valid JSON
# that is not an object.
if _is_json(raw):
# Valid JSON but not an object (``123``, ``[1]``) is
# not a JSON-RPC frame; skip quietly, not as a warning.
logger.debug("WebSearchTool: skipping non-object SSE data: %.80s", raw)
else:
logger.warning("WebSearchTool: skipping unparseable SSE line: %.80s", raw)
buffer.clear()

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.

问题: _flush(_parse_sse_payloads)先尝试 "\n".join(buffer) 再回退到 "".join(buffer);当服务端将单个 JSON 帧按字符串边界拆成多行 data:(如 data: {"snippet": "ab + data: c"})且事件间省略空行分隔符时,\n 拼接先被判定为"有效 JSON"——字符串内的换行使整帧被解析成损坏的字符串值(如 snippet 变成 ab\nc),后续解码逻辑无法再用无分隔符拼接纠正,属于本变更新增逻辑引入的帧形变缺陷。

触发条件: 服务端跨多行拆分 JSON 帧且拆分点位于 JSON 字符串内部(构造合法 JSON 但语义失真),同时事件间无空行分隔;两条 if decoded is not None 分支使无分隔符重试成为实际不可达的死代码。

实际影响: 搜索结果的 snippet/标题文本被静默插入换行而失真(若恰好仍是合法 JSON),或被整帧丢弃退化为 0 命中的空搜索,且无任何告警;"".join 回退因永远落在 decoded is not None 之后而失去修复作用。

修正方向: 调整拼接顺序为优先 "".join(buffer)(这是 JSON 语义上正确的按数据块重拼接),\n 拼接仅在无分隔符拼接失败后才回退尝试;或者至少将二者结果都校验后再采纳,确保多行拆分的字符串帧得到正确还原。

Comment on lines +854 to +858
for cookie in list(client.cookies.jar):
if (cookie.name, cookie.domain, cookie.path) not in known_cookies:
client.cookies.delete(cookie.name, domain=cookie.domain, path=cookie.path)
# Release the response and its pooled connection even when the
# body read dies mid-stream (e.g. a read timeout): an abandoned

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.

问题: _post_sse 的 cookie 回滚只按 (name, domain, path) 三元组快照比对,快照后新增的 cookie 被删除,但快照中已存在、值在本次调用期间被 Set-Cookie 覆盖的 cookie 不会回滚(如预置的 you_session=old 被响应覆盖为 you_session=new),回滚后 jar 里残留新的污染值;同时 with_httpx 的 AsyncClient 若被并发共享(仓库 example 即演示多工具共用一个持久 AsyncClient),互不相关的并发请求写进 jar 的 cookie 会被本调用的回滚误删,属于本变更新增逻辑引入的数据完整性竞态。

触发条件: (1)调用前 jar 里已存在同名 cookie 时,You.com 的 Set-Cookie 覆盖其值后回滚比对三元组命中快照而跳过删除;(2)同一共享客户端上其他提供商的请求(如 Tavily)在快照之后、回滚之前写入 cookie,回滚按三元组比对认为它是"新增"而误删。

实际影响: (1)You.com 的会话/跟踪 cookie 经共享 jar 泄漏到后续发往其他域(如 api.tavily.com)的请求中;(2)其他并发调用刚刚写入的有效 cookie 被静默删除,导致那些调用鉴权/会话失效,均无明显日志。

修正方向: 回滚时连同值一并比对(快照存 (name, domain, path, value),只有完全一致才跳过删除),并在删除前复核其值仍与"本次新增"一致(比对值变化即视为本次响应写入,予以回滚);或用不与 jar 共享的独立 cookie 存储(如请求级 cookies= 参数)从根上消除跨调用污染。

Comment thread trpc_agent_sdk/tools/_websearch_tool.py Outdated
Comment on lines +1209 to +1214
contents = item.get("contents")
highlights = contents.get("highlights") if isinstance(contents, dict) else None
# Only a list of strings is a usable fallback; anything else
# (a bare string, a dict, ...) must not crash the whole search.
if isinstance(highlights, list) and highlights and isinstance(highlights[0], str):
description = highlights[0].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.

问题: _search_youcom 的 highlights 兜底条件 isinstance(highlights, list) and highlights and isinstance(highlights[0], str) 只检查首元素类型;当首元素为非字符串(如 {"text": ...} 对象或数字)而后续元素是可用字符串时,整个兜底被跳过,后续元素永远不会扫描,属本变更新增兜底逻辑覆盖不全。

触发条件: 服务端返回 "contents": {"highlights": [{"text": "x"}, "Python is a high-level language"]} —— 首元素为 dict 而非 str,isinstance(highlights[0], str) 为 False,跳过整个列表。

实际影响: 本可命中的 snippet 文本被丢弃,命中记录的 snippet 为空,模型得到的上下文信息减少,搜索回答质量下降(部分 You.com 变体确实混合返回对象与字符串元素)。

修正方向: 改为遍历 highlights 取第一个 isinstance(x, str) 且非空白的元素(与上方 snippets 的遍历写法一致),而不是只检查 highlights[0]。

Comment on lines +366 to +372
for line in lines:
if line.startswith("data:"):
buffer.append(line[len("data:"):].strip())
elif not line.strip():
# A blank line dispatches the event per the SSE spec.
_flush()
_flush()

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.

问题: _parse_sse_payloads 的 _flush 在解析失败时逐行回退,会把"无空行分隔的多个完整 JSON 帧"拆成多个 payload 而丢失事件边界(服务端省略空行分隔符时,期望的是把多行作为一个合并事件处理),这种帧级歧义正是本变更新增解析器引入的确定性行为,需要测试固定其取舍。

触发条件: 输入形如 'data: {"a": 1}\ndata: {"b": 2}\n'(无空行)或有分隔符的多行帧——现有测试 test_parse_sse_payloads_falls_back_to_single_lines_without_separators 只断言了逐行正确拆分的一个方向,未覆盖 data: {"... 字符串内换行帧在无空行分隔状态下的行为(现有 test_parse_sse_payloads_joins_chunked_frames_without_separator 带空行,不会触达该分支)。

实际影响: 修复上述拼接顺序问题时若无对应回归测试,现有测试套件无法捕获帧错误合并/静默丢弃的回归,所有测试保持绿色但生产合成行为错误;当前实现本身已有测试覆盖盲区。

修正方向: 在 TestParseSsePayloads 中增加无空行分隔且含字符串内换行帧的用例(断言能正确还原为单一 payload,或至少按已声明的取舍稳定输出),并补一个 \n 拼接已产出"有效 JSON"时无分隔符拼接应被采纳的用例,作为上述修正的回归保护。

…llback, highlights scan

- _parse_sse_payloads now joins buffered data chunks with no separator
  first (the faithful reassembly of a frame split at any boundary,
  including inside a JSON string) and only then falls back to the
  newline join, instead of the reverse order where the newline join
  could be accepted first and corrupt string contents.
- The _post_sse cookie rollback snapshots the jar with values and
  restores pre-call values for cookies the call overwrote (Set-Cookie
  on a name that already existed), not just deleting additions; and
  deletions are scoped to the endpoint host, so a cookie written by a
  concurrent call on the same shared client for an unrelated domain
  is not caught in the rollback.
- The contents.highlights fallback scans for the first string element
  (mirroring the snippets traversal) instead of checking only
  highlights[0], which disabled the fallback behind a non-string first
  element.
- Tests: string-boundary split without separators restored intact;
  mixed complete/multi-line frames without separators pin the per-line
  trade-off; overwritten cookie value restored; unrelated-domain
  cookie survives the rollback.
@valueadd-lgtm

Copy link
Copy Markdown
Author

Thanks for another sharp round — all four findings are addressed in 62a652c, with regression tests for each:

  • Separator-less join first (MODERATE): _flush now tries "".join(buffer) before "\n".join(buffer). The chunks are the server's bytes split at arbitrary points, so reassembling them with nothing in between is the faithful reconstruction; in the previous order the newline join was accepted first and, whenever the newline happened to land somewhere json.loads tolerates, the frame was kept with corrupted string contents while the separator-less retry sat behind it as unreachable dead code. The newline join stays as a fallback for pretty-printed frames.
  • Cookie rollback for overwrites and concurrent calls (MODERATE): the snapshot now stores values. A cookie whose (name, domain, path) is in the snapshot but whose value changed during the call is restored to its pre-call value in place (so expiry/secure and the other attributes survive), and additions are still deleted — but the delete scan is now scoped to the endpoint host, which every hop was validated to stay on, so only a cookie that could plausibly have come from one of this call's responses is touched. A Tavily cookie written by a concurrent call sharing the client survives the rollback. The docstring documents the residual caveat: a concurrent call to the same You.com origin can still interleave, which no snapshot/rollback over a shared jar can fully close.
  • highlights fallback (LOW): it now scans for the first string element of the list, mirroring the snippets traversal above, so a non-string first element ({"text": ...} object, number) no longer disables the fallback for the usable strings behind it.
  • Tests (LOW): test_parse_sse_payloads_string_split_without_separator_keeps_string_intact asserts a string-boundary split with no blank separator is restored as one payload with the exact string; test_parse_sse_payloads_mixed_frames_without_separators_pin_tradeoff pins the declared per-line trade-off for complete frames concatenated with a multi-line frame without separators; plus two cookie tests — test_youcom_overwritten_cookie_value_restored and test_youcom_rollback_leaves_unrelated_domain_cookies — both of which fail on the previous revision and pass now.

136 websearch tests pass locally; flake8 (the repo's lint_flake8.sh config) is clean on both changed files.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

不通过

审查范围:02509b2..62a652c,共 4 个文件(trpc_agent_sdk/tools/_websearch_tool.py 新增 598 行、tests/tools/test_websearch_tool.py 新增 1448 行、docs 英文/中文两份),为 WebSearchTool 新增 youcom(You.com MCP streamable HTTP)provider。

计划符合性:基本符合。youcom 已接入 ProviderType 与构造函数(YDC_API_KEY 环境变量回退、youcom_extra_params、keyless 与认证双端点)、lang 内联过滤符、blocked_domains 服务端 exclude_domains、snippets/contents.highlights 摘要回退、SSE 解析、手动逐跳重定向 + 共享 client cookie 回滚等,并有约 60 个针对性测试用例。

主要风险(均经 httpx 0.27 源码逐行验证):

  1. _post_sse 手动重定向循环存在三处真实缺陷(评论chore: initialize project #1/chore: 整理目录代码 #2/chore: 修改新增 example 安装命令 #3):空 Location 会导致最多 4-5 次完全相同的重复 POST;重定向预算 off-by-one 实际允许 4 跳多于常量的 3;中间跳 Response 未 aclose,中途异常时在共享连接池泄漏连接。
  2. cookie 回滚的"值恢复"分支(评论feature: support a2a #4)会在并发共享 http_client 场景把同域 cookie 并发写入的新值静默覆盖回旧值,与本函数注释"并发调用写入的无关域名 cookie 不受影响"的保证冲突。
  3. _search_youcom 帧循环(评论chore: 修改文档代码导包,新增英文使用文档 #5)对 result 先到、error 后到的顺序不对称,先到的有效结果被静默丢弃并返回错误摘要。
  4. lang 拼接(评论test: 整理部分例子 #6)未检查 query 内已含 lang: 操作符,模型按 schema 描述传参时可拼出重复过滤符。

安全评估:凭据隔离(显式 Request + auth=None、逐跳同源校验、请求级 headers)与 cookie 回滚意图正确、测试充分,未发现 YDC_API_KEY 泄漏到第三方 origin 或明文 http 的路径。

测试充分性:整体非常充分(SSE 解析各切分形态、BOM、重定向同源/降级、cookie 回滚三形态、类型防护、JSON-RPC 帧关联均有覆盖),但缺少:空 Location 循环测试、中间跳异常连接释放测试、并发同域 cookie 覆盖测试、result 先于 error 的帧序测试、query 内已含 lang: 的重复拼接测试。

结论:存在 6 个可操作问题(2 SEVERE / 3 MODERATE / 1 LOW),门禁 FAILED。

发现的问题

严重

trpc_agent_sdk/tools/_websearch_tool.py:813-815

问题: _post_sse 的重定向循环在 Location 头存在但为空('')或空白(' ')时会把同一请求重复发送到同一个 URL。resp.has_redirect_location 只要求 3xx 且 Location 头存在(httpx 源码 _models.py:704),空字符串会通过;随后 httpx.URL(current_url).join('') 按 urljoin 语义返回原 URL(含 query),_same_origin 检查必然通过,于是循环像正常重定向一样递增 redirects 并再次 POST 同一请求,直到预算耗尽。

触发条件: 服务端(或中间 CDN/代理)对 POST 返回 302/301 且 Location: 为空或仅为空白字符——这是生产中出现过的不规范响应形态,测试没有任何覆盖。

实际影响: 每个此类响应会让工具连续发出最多 4 次完全相同的 POST(叠加评论#2 的 off-by-one 实际可发 5 个请求),放大带宽与上游负载;若可跟随的 3xx 走完预算后循环退出,随后的 300 <= resp.status_code < 400 分支只打 warning,raise_for_status() 抛出 HTTPStatusError 被 _run_async_impl 包装为 HTTP_ERROR 返回给 LLM。

修正方向: 在读取 location 后先 strip(),为空时按"无法跟随的重定向"处理(打 warning 并 break,回到既有的 3xx 残余处理路径),不要进入 URL 拼接与再发送。

严重

trpc_agent_sdk/tools/_websearch_tool.py:878-889

问题: cookie 回滚的"值恢复"路径会并发覆盖同一域名上其他调用写回的 cookie 值。快照在首个请求发出前对整本 jar 取 (name, domain, path)→value;finally 中只要 key in cookie_snapshot 且值不同,就无条件 cookie.value = cookie_snapshot[key] 原地恢复。若同一个共享 http_client 上并发的另一 provider 调用(如 Tavily)在快照之后把同名同域 cookie 从旧值写成了新值,回滚会把被并发调用的新值覆盖回旧值。代码注释声称删除范围限定在 endpoint host 域、"并发调用写入的无关域名 cookie 不受影响",但被覆盖的正是与 You.com 同域(该 host 上的每个 hop 都被验证保持同源)的 cookie——例如 gateway 域下多个云 API 共用的会话 cookie。

触发条件: 共享 http_client 的 jar 中预先存在 You.com endpoint host(或包含该 host 的域后缀、带初始点)上的 cookie;You.com 调用在快照之后、回滚之前,另一个 provider 调用或用户代码更新了该 cookie 的值;且 You.com 响应本身也 Set-Cookie 了这个名字(使其值偏离快照)。

实际影响: 并发调用写入的有效会话值被静默回滚为旧值,后续请求携带过期的会话 cookie,导致其他 provider 调用鉴权失败或状态丢失;同一根因也可能误删并发新增的同域 cookie(snapshot 中不存在的 key 且域名匹配 endpoint host 时)。

修正方向: 快照记录时同时记录原 cookie 的过期/版本等标识,恢复前校验其未被并发修改(如比较 client.cookies.jar 中该 cookie 的 expires/version 或使用局部锁);或将"恢复值"改为"删除本次 Set-Cookie 带来的新值并重放并发修改"的合并策略,并明确并发场景的文档化保证。

中等

trpc_agent_sdk/tools/_websearch_tool.py:794-862

问题: _post_sse 中每次 hop 的 httpx.Response 都写入同一个 resp 变量,且 resp.aclose() 只在 finally 中对"最后一个"response 执行。当第 2 跳及以后的 client.send 抛出异常(读超时、连接重置、传输错误)时,之前跳的 response 流从未被关闭,连接不会被归还到共享连接池——这恰是该函数 docstring(第 757-760 行)声称要防止的"断连接残留"。

触发条件: 端点首先返回同源 3xx(如 302 到 /mcp-moved),随后对 follow-up POST 的响应体读取中途异常(keep-alive 密集体上的读超时、上游断连)。

实际影响: 半读的响应连接残留在共享 http_client 的连接池中,之后每个同类失败都泄漏一条连接,直至空闲超时/GC 回收;泄漏的连接可能被后续其他 provider(Tavily/Google)请求复用而失败,HTTP_ERROR 返回给 LLM。

修正方向: 在循环内每次发送后记录当前 response 并保证其关闭:发送下一跳前先 aclose() 上一跳的响应(3xx 响应体未读,直接关闭安全),并/或用列表收集所有 hop 的 response,finally 中逐个 aclose()。

中等

trpc_agent_sdk/tools/_websearch_tool.py:805-812

问题: 重定向预算 off-by-one。redirects 在每跳前先 += 1 再判断 redirects > _SSE_MAX_REDIRECTS,因此 _SSE_MAX_REDIRECTS = 3 时实际允许 4 次跳转、共 5 个请求,与常量注释"Maximum redirects the SSE POST follows"不一致。

触发条件: 端点连续返回 4 个可合法跟随、同源的 3xx(例如路径/负载均衡多次迁移)或叠加评论#1 的空 Location 重复 POST 场景。

实际影响: 每张凭证(YDC_API_KEY bearer)比承诺多发出一跳到目标外的路径,且放大评论#1 的重复请求次数(空 Location 时实际 5 个请求而不是注释承诺的 3 个),超出预算的循环以最后一次 3xx 的 warning 结束。

修正方向: 将判断改为在发送前一跳时计数:redirects >= _SSE_MAX_REDIRECTS 即拒绝跟随(在递增后判断 redirects > _SSE_MAX_REDIRECTS 之前先检查 redirects == _SSE_MAX_REDIRECTS),或把递增移到发送之后。

中等

trpc_agent_sdk/tools/_websearch_tool.py:1066-1101

问题: JSON-RPC 帧循环对"result 帧先到、error 帧后到"的顺序不对称。error_wins 是一个锁存位:只有后续的 result 帧能把它复位,而后续的 error 帧会把 error_wins 置 True 并保留已先到的 result 值;最终 error is not None and (error_wins or result is None) 恒为 True,先到的有效 result 被丢弃并静默返回错误摘要(该路径没有任何 warning 日志,与"转瞬错误帧被后续 result 覆盖要打日志"的对称处理不一致)。

触发条件: 服务端(或读取/缓冲顺序)在同一个 id 下先发送 result 帧、随后再发送一个 error 帧——JSON-RPC 2.0 规定同 id 只能有一个响应,但代码的注释和测试(test_same_frame_result_and_error_treated_as_error、test_stray_error_superseded_by_later_result)明确把"服务器违规"当作要防御的输入类型,本分支却未防御。

实际影响: 完整且可解析的搜索结果被替换为 results=[] + "You.com search error: ..." 摘要,模型把一次正常搜索当成失败,且日志无提示,排障困难。

修正方向: 将最终判断改为"最后一个语义帧胜出":例如只在 result is None 时才让 error 生效(error is not None and result is None),或在循环中记录"最后出现的帧类型",用最后类型决定返回 result 还是 error,并对"error 覆盖 result"的方向同样打 warning。

较低

trpc_agent_sdk/tools/_websearch_tool.py:1025-1034

问题: 内联 lang: 过滤符拼接未检查 query 自身是否已包含 lang: 操作符。lang_value[:5].lower() == "lang:" 循环只剥离 lang 参数值的前缀,之后无条件执行 arguments["query"] = f"{query} lang:{lang_value}";当模型按 schema 描述("You.com maps it to the inline lang: filter")在 query 里已写了 lang:ja、同时又传 lang="ja" 时,结果是 "python lang:ja lang:ja"。相应测试也只断言前缀剥离后的拼接,未覆盖 query 内已含 lang: 的重复场景。

触发条件: LLM 在 query 文本中自行使用 lang: 操作符(schema 描述鼓励该用法),同时调用参数带 lang,或工具级 lang 与 query 内操作符同值。

实际影响: 发送给 You.com 的查询被改成含重复 lang: 过滤符的复合串,可能导致该端拒绝或语义改变,搜索结果与预期不符;属于模型实际会走到的调用形态。

修正方向: 拼接前检查 query 中是否已存在 lang: 操作符(正则匹配词边界),若存在则不再追加;并把该场景补为测试用例。

Comment on lines +813 to +815
location = resp.headers.get("location", "")
try:
next_obj = httpx.URL(current_url).join(location)

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.

问题: _post_sse 的重定向循环在 Location 头存在但为空('')或空白(' ')时会把同一请求重复发送到同一个 URL。resp.has_redirect_location 只要求 3xx 且 Location 头存在(httpx 源码 _models.py:704),空字符串会通过;随后 httpx.URL(current_url).join('') 按 urljoin 语义返回原 URL(含 query),_same_origin 检查必然通过,于是循环像正常重定向一样递增 redirects 并再次 POST 同一请求,直到预算耗尽。

触发条件: 服务端(或中间 CDN/代理)对 POST 返回 302/301 且 Location: 为空或仅为空白字符——这是生产中出现过的不规范响应形态,测试没有任何覆盖。

实际影响: 每个此类响应会让工具连续发出最多 4 次完全相同的 POST(叠加评论#2 的 off-by-one 实际可发 5 个请求),放大带宽与上游负载;若可跟随的 3xx 走完预算后循环退出,随后的 300 <= resp.status_code < 400 分支只打 warning,raise_for_status() 抛出 HTTPStatusError 被 _run_async_impl 包装为 HTTP_ERROR 返回给 LLM。

修正方向: 在读取 location 后先 strip(),为空时按"无法跟随的重定向"处理(打 warning 并 break,回到既有的 3xx 残余处理路径),不要进入 URL 拼接与再发送。

Comment on lines +878 to +889
for cookie in list(client.cookies.jar):
key = (cookie.name, cookie.domain, cookie.path)
if key in cookie_snapshot:
if cookie.value != cookie_snapshot[key]:
# The call overwrote a pre-existing cookie: restore
# the pre-call value in place so every other
# attribute (expiry, secure, ...) survives.
cookie.value = cookie_snapshot[key]
continue
domain = cookie.domain.lstrip(".")
if endpoint_host == domain or endpoint_host.endswith("." + domain):
client.cookies.delete(cookie.name, domain=cookie.domain, path=cookie.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.

问题: cookie 回滚的"值恢复"路径会并发覆盖同一域名上其他调用写回的 cookie 值。快照在首个请求发出前对整本 jar 取 (name, domain, path)→value;finally 中只要 key in cookie_snapshot 且值不同,就无条件 cookie.value = cookie_snapshot[key] 原地恢复。若同一个共享 http_client 上并发的另一 provider 调用(如 Tavily)在快照之后把同名同域 cookie 从旧值写成了新值,回滚会把被并发调用的新值覆盖回旧值。代码注释声称删除范围限定在 endpoint host 域、"并发调用写入的无关域名 cookie 不受影响",但被覆盖的正是与 You.com 同域(该 host 上的每个 hop 都被验证保持同源)的 cookie——例如 gateway 域下多个云 API 共用的会话 cookie。

触发条件: 共享 http_client 的 jar 中预先存在 You.com endpoint host(或包含该 host 的域后缀、带初始点)上的 cookie;You.com 调用在快照之后、回滚之前,另一个 provider 调用或用户代码更新了该 cookie 的值;且 You.com 响应本身也 Set-Cookie 了这个名字(使其值偏离快照)。

实际影响: 并发调用写入的有效会话值被静默回滚为旧值,后续请求携带过期的会话 cookie,导致其他 provider 调用鉴权失败或状态丢失;同一根因也可能误删并发新增的同域 cookie(snapshot 中不存在的 key 且域名匹配 endpoint host 时)。

修正方向: 快照记录时同时记录原 cookie 的过期/版本等标识,恢复前校验其未被并发修改(如比较 client.cookies.jar 中该 cookie 的 expires/version 或使用局部锁);或将"恢复值"改为"删除本次 Set-Cookie 带来的新值并重放并发修改"的合并策略,并明确并发场景的文档化保证。

Comment on lines +794 to +862
try:
current_url = url
redirects = 0
while True:
request = httpx.Request("POST", current_url, json=payload, headers=request_headers)
# send() falls back to the client timeout when the extension is
# absent; keep the per-call timeout semantics of the other providers.
request.extensions["timeout"] = httpx.Timeout(self._timeout).as_dict()
resp = await client.send(request, auth=None, follow_redirects=False)
if not resp.has_redirect_location:
break
redirects += 1
if redirects > _SSE_MAX_REDIRECTS:
logger.warning(
"WebSearchTool: You.com endpoint exceeded %d redirects; "
"search may return no results.",
_SSE_MAX_REDIRECTS,
)
break
location = resp.headers.get("location", "")
try:
next_obj = httpx.URL(current_url).join(location)
# URL.join follows urljoin semantics, so a relative
# Location drops the original query string: a keyless
# "?profile=free" call would silently land on the
# authenticated endpoint after the redirect. Re-attach
# the profile param, keeping any the Location carries.
origin_profile = httpx.URL(url).params.get("profile")
if origin_profile is not None and next_obj.params.get("profile") is None:
next_obj = next_obj.copy_with(params=next_obj.params.merge({"profile": origin_profile}))
next_url = str(next_obj)
except Exception as ex: # pylint: disable=broad-except
logger.warning(
"WebSearchTool: You.com redirect target %r is not a valid URL (%s); "
"search may return no results.",
location,
ex,
)
break
# Validate each hop on its own: the request (and the tool's
# Authorization header) travels to current_url's origin, so
# the hop must be same-origin with the *previous* URL — not
# merely with the first one — and it may not downgrade to
# plaintext http unless the endpoint was configured that way
# (same-origin with the original URL). Otherwise the bearer
# header (and any cookies httpx re-attaches to redirect
# requests) would travel to whatever host or plaintext hop
# the 3xx names.
if not (_same_origin(current_url, next_url) and
(_same_origin(url, next_url) or httpx.URL(next_url).scheme == "https")):
logger.warning(
"WebSearchTool: dropping redirect from the You.com endpoint to "
"URL %r (not same-origin with the previous hop or plaintext http); "
"search may return no results.",
next_url,
)
return []
current_url = next_url
if 300 <= resp.status_code < 400:
# Defensive: a 3xx that was not followed (missing/invalid
# location, or the redirect budget ran out) must not read
# downstream as a normal empty search.
logger.warning(
"WebSearchTool: You.com endpoint responded %d without "
"following the redirect; search may return no results.",
resp.status_code,
)
resp.raise_for_status()
return _parse_sse_payloads(resp.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.

问题: _post_sse 中每次 hop 的 httpx.Response 都写入同一个 resp 变量,且 resp.aclose() 只在 finally 中对"最后一个"response 执行。当第 2 跳及以后的 client.send 抛出异常(读超时、连接重置、传输错误)时,之前跳的 response 流从未被关闭,连接不会被归还到共享连接池——这恰是该函数 docstring(第 757-760 行)声称要防止的"断连接残留"。

触发条件: 端点首先返回同源 3xx(如 302 到 /mcp-moved),随后对 follow-up POST 的响应体读取中途异常(keep-alive 密集体上的读超时、上游断连)。

实际影响: 半读的响应连接残留在共享 http_client 的连接池中,之后每个同类失败都泄漏一条连接,直至空闲超时/GC 回收;泄漏的连接可能被后续其他 provider(Tavily/Google)请求复用而失败,HTTP_ERROR 返回给 LLM。

修正方向: 在循环内每次发送后记录当前 response 并保证其关闭:发送下一跳前先 aclose() 上一跳的响应(3xx 响应体未读,直接关闭安全),并/或用列表收集所有 hop 的 response,finally 中逐个 aclose()。

Comment on lines +805 to +812
redirects += 1
if redirects > _SSE_MAX_REDIRECTS:
logger.warning(
"WebSearchTool: You.com endpoint exceeded %d redirects; "
"search may return no results.",
_SSE_MAX_REDIRECTS,
)
break

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.

问题: 重定向预算 off-by-one。redirects 在每跳前先 += 1 再判断 redirects > _SSE_MAX_REDIRECTS,因此 _SSE_MAX_REDIRECTS = 3 时实际允许 4 次跳转、共 5 个请求,与常量注释"Maximum redirects the SSE POST follows"不一致。

触发条件: 端点连续返回 4 个可合法跟随、同源的 3xx(例如路径/负载均衡多次迁移)或叠加评论#1 的空 Location 重复 POST 场景。

实际影响: 每张凭证(YDC_API_KEY bearer)比承诺多发出一跳到目标外的路径,且放大评论#1 的重复请求次数(空 Location 时实际 5 个请求而不是注释承诺的 3 个),超出预算的循环以最后一次 3xx 的 warning 结束。

修正方向: 将判断改为在发送前一跳时计数:redirects >= _SSE_MAX_REDIRECTS 即拒绝跟随(在递增后判断 redirects > _SSE_MAX_REDIRECTS 之前先检查 redirects == _SSE_MAX_REDIRECTS),或把递增移到发送之后。

Comment on lines +1066 to +1101
result: Optional[dict[str, Any]] = None
error: Optional[dict[str, Any]] = None
error_wins = False
for item in payloads:
if not isinstance(item, dict):
continue
frame_id = item.get("id")
# Only frames replying to this request carry its id; frames
# without one are notifications and carry no result/error.
# Compare the string forms: some servers echo the request id
# back as a string ("1"), and a strict typed comparison would
# drop every reply frame.
if frame_id is not None and str(frame_id) != str(payload["id"]):
continue
if "error" in item:
# A JSON-RPC response carries either ``result`` or ``error``;
# a frame carrying both is treated as an error.
error = item["error"]
error_wins = True
if "result" in item and "error" not in item:
# A later valid result supersedes an earlier stray error.
if error is not None:
# Keep transient failures observable: a stray error frame
# (e.g. a rate-limit notice) was overridden by a later
# result frame; log it rather than dropping it silently.
logger.warning("WebSearchTool: You.com error frame superseded by a later result: %.200s", error)
result = item["result"]
error_wins = False
if error is not None and (error_wins or result is None):
message = error.get("message") if isinstance(error, dict) else str(error)
return WebSearchResult(
query=query,
provider="youcom",
results=[],
summary=_truncate(f"You.com search error: {message}", self._snippet_len),
)

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.

问题: JSON-RPC 帧循环对"result 帧先到、error 帧后到"的顺序不对称。error_wins 是一个锁存位:只有后续的 result 帧能把它复位,而后续的 error 帧会把 error_wins 置 True 并保留已先到的 result 值;最终 error is not None and (error_wins or result is None) 恒为 True,先到的有效 result 被丢弃并静默返回错误摘要(该路径没有任何 warning 日志,与"转瞬错误帧被后续 result 覆盖要打日志"的对称处理不一致)。

触发条件: 服务端(或读取/缓冲顺序)在同一个 id 下先发送 result 帧、随后再发送一个 error 帧——JSON-RPC 2.0 规定同 id 只能有一个响应,但代码的注释和测试(test_same_frame_result_and_error_treated_as_error、test_stray_error_superseded_by_later_result)明确把"服务器违规"当作要防御的输入类型,本分支却未防御。

实际影响: 完整且可解析的搜索结果被替换为 results=[] + "You.com search error: ..." 摘要,模型把一次正常搜索当成失败,且日志无提示,排障困难。

修正方向: 将最终判断改为"最后一个语义帧胜出":例如只在 result is None 时才让 error 生效(error is not None and result is None),或在循环中记录"最后出现的帧类型",用最后类型决定返回 result 还是 error,并对"error 覆盖 result"的方向同样打 warning。

Comment on lines +1025 to +1034
if lang:
# You.com maps an inline ``lang:`` operator to a language filter.
# Guard against the model passing the operator form verbatim
# (``lang:ja``, possibly with stray whitespace or casing) after
# reading the schema description.
lang_value = str(lang).strip()
while lang_value[:5].lower() == "lang:":
lang_value = lang_value[5:].strip()
if lang_value:
arguments["query"] = f"{query} lang:{lang_value}"

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.

问题: 内联 lang: 过滤符拼接未检查 query 自身是否已包含 lang: 操作符。lang_value[:5].lower() == "lang:" 循环只剥离 lang 参数值的前缀,之后无条件执行 arguments["query"] = f"{query} lang:{lang_value}";当模型按 schema 描述("You.com maps it to the inline lang: filter")在 query 里已写了 lang:ja、同时又传 lang="ja" 时,结果是 "python lang:ja lang:ja"。相应测试也只断言前缀剥离后的拼接,未覆盖 query 内已含 lang: 的重复场景。

触发条件: LLM 在 query 文本中自行使用 lang: 操作符(schema 描述鼓励该用法),同时调用参数带 lang,或工具级 lang 与 query 内操作符同值。

实际影响: 发送给 You.com 的查询被改成含重复 lang: 过滤符的复合串,可能导致该端拒绝或语义改变,搜索结果与预期不符;属于模型实际会走到的调用形态。

修正方向: 拼接前检查 query 中是否已存在 lang: 操作符(正则匹配词边界),若存在则不再追加;并把该场景补为测试用例。

This branch has not been deployed

No deployments
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.

2 participants