Skip to content

[BUG][P1] 独立 ReAct 应遵守拦截器的显式工具筛选与动态工具声明 #268

Closed

Description

@Aias00

已确认问题

ModelRequest 已区分“没有指定筛选”与“显式空集合”,并提供 dynamicToolRegistry。独立 ReAct 的 invokeModel() 直接使用 request.getToolRegistry().definitions(),没有读取 hasToolSelection()/getTools() 或动态注册表。拦截器明确关闭工具后,模型仍收到原工具声明。

代码:

ChatRequest chatRequest = new ChatRequest(request.getMessages(), request.getToolRegistry().definitions(),

最小复现场景

  1. 创建包含 secret 工具的 ReActAgent,用记录 ChatRequest 的假 ChatClient 返回正常终态。
  2. 增加 ModelInterceptor,调用 next.call(ModelRequest.builder(request).tools(List.of()).build())。
  3. 调用 Agent,检查假客户端收到的 ChatRequest.tools()。

实际结果:explicit empty tool selection disclosed=[secret]。期望为空,且模型即使返回未允许的调用,也不能执行该工具。

同一路径还会忽略通过 ModelRequest.dynamicToolRegistry(...) 提供的调用级工具。

交付与验收

  • 明确并执行静态工具、动态工具和显式选择的组合规则;对重复名称采用明确、可测试的策略。
  • 未指定筛选和显式空集合语义不同;显式空集合必须不暴露、不执行任何工具。
  • 选择子集时,只声明和执行该次选择中的工具;动态工具可按相同规则参与筛选。
  • 增加无筛选、空筛选、子集、动态工具、重复名称及模型请求未授权名称的行为测试。
  • 非流式和流式调用、重复订阅与并发调用保持一致,调用间工具集合不能串用。

关联与范围

关联 #196、#195。实际执行应使用哪份调用级绑定另行建单;本单负责筛选和动态披露语义,不新建授权体系,也不将工具未披露等同于已完成所有业务授权。

验证基线与变更约束

审阅基线:Core b34835354b27f1f320ea8438edf2e9d170e69c84 / Extensions cb2635ab2fc338e8aade28a132a15f48b4e655fc。以下为离线 stub、独立 Java 探针或候选工件编译验证的结果,不代表真实模型、外部服务或生产环境验收。

修复保持独立 ReAct/Workflow 不依赖 Graph/Loop,不向 Core 引入供应商 SDK。优先修正现有契约的执行行为;确需改变公开 API、配置或定义格式时,说明较小替代方案、收益、消费者影响、目标版本与迁移方式。不得以跳过测试或删除能力换取通过。

配套缺陷

#269

Activity

  1. konlue commented on Oct 25, 2026

    @konlue
    Contributor

    你好,我想认领 #268。

    基于 main b34835354 确认:

    1. 缺口一行级
    ReActAgent.invokeModel 组装 ChatRequest 时直接用 request.getToolRegistry().definitions() 暴露全量工具,ModelRequest 上现成的工具选择语义完全没被消费——hasToolSelection()(区分“未指定”与“显式空集”)、getTools()(显式名单)、dynamicToolRegistry(调用级动态披露)都在,但声明侧一个都没接。所以拦截器 tools(List.of()) 之后模型仍然收到全部工具声明,和 issue 里的 repro 一致。

    2. 声明与执行共享同一 registry
    invokeModel 的声明来源与 ToolCall 执行(executeTool 里的 find/execute)用的是同一个 availableTools(每 turn 由 resolveTools 静态∪动态合并产出)。这意味着修复不能只筛声明侧——筛选结果必须同步到执行侧,否则“declared and executed”只完成一半(执行侧的一致性正是 #269 的主题,两票在这一点上耦合)。

    3. 重名现状
    ToolRegistry 构造器对重名直接抛异常,而 ModelRequest.getTools() 的 distinct 是按 name 保序(静态优先)——组合规则可以顺势定为静态优先、动态同名视为被遮蔽,不需要引入新的去重协议。


    方案(单 PR,零新协议)

    • 计算生效工具集:invokeModel 在拦截器链尾计算「生效工具集」:静态 registry ∪ dynamicToolRegistry(静态优先去重)∩ 显式筛选(hasToolSelection() 时按 getTools() 名单收窄;显式空集 → 生效集为空)。用生效集生成模型声明。
    • 回传执行侧:ToolCall 执行改用同一生效集,保证“声明给模型看的”和“实际可执行的”完全一致——拦截器筛掉的模型既不被声明、也不可被执行;模型坚持调用被筛工具时走既有未知工具错误消息路径。显式空集时声明为空、任何工具调用都得到该错误。
    • 边界保持不变:既有授权检查与 trustedExecutionContext 语义不动;不新增 API、不建新的授权体系(工具不披露 ≠ 业务授权,与 issue 非目标一致)。

    测试

    照 ReActAgentTest 的 ScriptedClient 模式,全离线:无筛选 / 显式空集 / 子集筛选 / 动态工具参与筛选 / 静态动态重名 / 流式与非流式一致 / 并发调用无工具集泄漏;含 issue 的最小 repro(拦截器空选集 → fake client 观察到的声明为空)。

    组合规则如果需要更复杂的策略(比如动态优先、按 tag 匹配)后续 PR 再演进。

    两个实现决策

    1. 局部引用回传:生效集回传执行侧用 modelTurn 内的局部引用回传(invokeModel 在拦截器链尾算好生效集,同 turn 的 executeTool 直接使用)——零 API 变化;让 ModelResponse 携带生效 registry 更显式但要动公共契约,留给后续有跨 turn 需求时再议。
    2. 重名处理规则:静态×动态重名按「静态优先、动态被遮蔽」处理——与 ModelRequest.getTools() 现有 distinct 语义一致,不引入新的去重协议;ToolRegistry 构造器的重名快速失败继续作为合并入口的兜底。
  2. added a commit that references this issue on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions