工具目录

ToolCatalog 是每个智能体工具的单一真实来源。每个工具只声明一次,包含名称、可用性规则和构建器。调用方(AgentServiceChatToolConfigurationMessagingAgentConcernChannelMentionWorkflow)通过列出所需的工具组来选择性加入,而不是维护容易漂移的并行 allowed_tools 数组。

整体架构

                ┌────────────────────────────────────────────────┐
                │  app/services/tool_catalog.rb                  │
                │  ───────────────────────────────────────────── │
                │  ONE register(...) call per tool               │
                │                                                │
                │   register(:save_memory,                       │
                │            groups: :core, available: ...)      │
                │   register(:show_widget,                       │
                │            groups: :chat_only, ...)            │
                │   register(:trigger_pr_review,                 │
                │            groups: :triggers, ...)             │
                │   ...                                          │
                └────────────────────────────────────────────────┘
                                     ▲
                       build_tools(ctx, include: GROUPS)
                       tool_names(ctx,  include: GROUPS)
                                     │
        ┌───────────────┬────────────┼────────────┬──────────────┐
        │               │            │            │              │
 ┌──────┴──────┐ ┌──────┴─────┐ ┌────┴──────┐ ┌───┴──────────────┐
 │ AgentService│ │ ChatJob    │ │ Messaging │ │ ChannelMention   │
 │ full agent  │ │ web/API    │ │ Telegram/ │ │ Workflow         │
 │ FULL_AGENT_ │ │ CHAT_TOOL_ │ │ Teams     │ │ FULL_AGENT_      │
 │ GROUPS      │ │ GROUPS     │ │ MESSAGING_│ │ GROUPS           │
 │             │ │            │ │ TOOL_     │ │  + :triggers     │
 │             │ │            │ │ GROUPS    │ │                  │
 └─────────────┘ └────────────┘ └───────────┘ └──────────────────┘
                                     │
                                     ▼
                        ┌─────────────────────────┐
                        │  ToolContext            │  per-invocation
                        │  ─────────────────────  │
                        │  space, user, conv,     │  memoized predicates:
                        │  message, queue,        │    credentials
                        │  factories              │    channel_configured?
                        │                         │    skills_enabled?
                        │                         │    ...
                        └─────────────────────────┘
                                     │
                                     ▼
       Catalog keeps entries where
         (entry.groups ∩ include).any?  AND  entry.available?(ctx)
                                     │
                       ┌─────────────┴─────────────┐
                       ▼                           ▼
                builder blocks run         full_names resolve
                → SDK tool instances       → "mcp__*" name strings
                       │                           │
                       ▼                           ▼
                create_sdk_mcp_server       options.allowed_tools
                (name: "themis")

三块、三种职责:

  1. ToolCatalog —— 声明式注册表。每个工具一行 register(...)。决定什么存在
  2. ToolContext —— 每次调用的值对象。决定当前什么可用(作用域内的用户/空间/凭证)。
  3. 调用方的组 —— FULL_AGENT_GROUPS / CHAT_TOOL_GROUPS / MESSAGING_TOOL_GROUPS 等。决定该调用方想要目录的哪个切片

每次调用(build_tools 用于 SDK 工具对象、tool_names 用于 allowed_tools 白名单)都会针对当前上下文重新求值,因此 false 可用性的工具只是消失,调用方无需察觉。

何时使用

场景使用目录?
暴露给一个或多个智能体的新进程内 Themis 工具
现有工具需要新的调用方(工作流 / 任务) —— 添加到组中,引用该组
仅一个工作流使用、不复用的一次性工具否 —— 通过 AgentServiceextra_themis_tools: 传递
外部 MCP 通道(Linear、Sentry、Metabase、Google Drive / Calendar / Gmail、Google Analytics、Google Maps、Flight Search)存在于 ChannelRegistry 中;只有允许工具名称镜像到目录中
共享工具上的调用方特定副作用(任务入队、推送到实例变量)使用 :triggers 工厂(见下文)

注册工具

app/services/tool_catalog.rbregister_core_tools!register_chat_only_tools! 中添加一个条目:

register(:my_tool, groups: :core, available: :sentry_configured?) do |ctx|
  MyToolBuilder.build_tool(space: ctx.space)
end

名称和 full_names

符号会自动变成 mcp__themis__my_tool。仅当下方规则不适用时才覆盖 full_names:

工具类型命名规则示例
进程内 Themis MCPmcp__themis__<name>(默认)mcp__themis__get_pr_info
外部 MCP 服务器mcp__<channel>__<tool>mcp__sentry__list_issues
SDK 内置裸名称"Skill"
一个构建器生成 N 个工具显式列出名称mcp__themis__get_pr_info, mcp__themis__get_pr_diff, …
名称依赖运行时状态传入在 tool_names 时求值的 Proc:google_drive_bundle
工具注册但通过 SDK 发现full_names: [](抑制输出):skills_bundle, :automations_bundle

选择有意义的最小集合:

覆盖范围
:core完整智能体 + 对话 + 消息
:chat_only仅 Web/API 对话(小组件、文件创建、技能、自动化、个人任务、GDrive)
:triggers目录声明、调用方提供工厂
:skill_builtinSDK 发现的 “Skill” 工具
:linear, :sentry_full, :sentry_messaging, :metabase_full, :metabase_messaging外部 MCP 允许列表镜像

调用方按组组合,而不是按条目 —— 不存在排除列表。如果你需要”:core 减一个工具”,答案是拆分组,而不是在调用方减去。

可用性规则

优先使用 ToolContext predicate 符号而不是内联 Proc

register(:query_themis_data, groups: :core, available: :can_query_themis?) { ... }

仅当条件确实是一次性的时使用 Proc。如果两个条目共享同一检查,则ToolContext 中添加方法

available: 接受::always / true / nil(始终启用)、false(始终禁用)、Symbol(在 ctx 上调用)或 Proc(以 ctx 调用)。

按调用方扩展工具的 JSON schema

当同一目录工具需要调用方特定的 schema 字段时(例如 trigger_code_generation 仅在消息上下文中需要 project 参数),通过构建器传递,而不是目录:

# ChannelMentionWorkflow —— 添加 `project` 作为必需参数
CodeGenerationToolBuilder.build_tool(
  extra_properties: { project: { type: "string", description: "..." } },
  extra_required:   %w[project]
) { |args| handle_code_generation_tool(args) }

通过 :triggers 工厂(下一节)连接此项,使目录条目保持声明式。

调用方接线

三步:声明组常量、构建 ToolContext、向目录询问两次。

MY_GROUPS = %i[core triggers chat_only].freeze

ctx = ToolContext.new(
  space: space, user: user, conversation: conv, message: msg,
  message_queue: queue,
  factories: { trigger_pr_review: ->(_ctx) { build_my_handler } }
)

tools = ToolCatalog.build_tools(ctx, include: MY_GROUPS)
mcp = ClaudeAgentSDK.create_sdk_mcp_server(name: "themis", tools: tools)

allowed = ToolCatalog.tool_names(ctx, include: MY_GROUPS)

build_toolstool_names 复用同一个 ToolContextcredentialschannel_configured?can_query_themis?google_drive_available?skills_enabled?automations_enabled? 都按上下文级别记忆 —— 共享上下文可避免每个 predicate 运行两次。

标准示例

  • app/services/agent_service.rb:37 —— FULL_AGENT_GROUPS(完整智能体路径)
  • app/jobs/concerns/chat_tool_configuration.rb:15 —— CHAT_TOOL_GROUPS(Web/API 对话)
  • app/services/concerns/messaging_agent_concern.rb:13 —— MESSAGING_TOOL_GROUPS(Telegram/Teams)
  • app/services/workflows/channel_mention_workflow.rb:302 —— 使用 :triggers 构建 themis MCP 服务器

工厂(:triggers 组)

trigger_code_generationtrigger_pr_review 是目录声明的,但其处理器因调用方而异(对话将任务入队,提及将请求捕获到实例变量)。目录条目以工厂的存在为门禁 —— 不需要触发器的调用方只需省略工厂,条目即自动跳过:

# 在 tool_catalog.rb 中 —— 一次性声明
register(:trigger_pr_review,
        groups: :triggers,
        available: ->(ctx) { ctx.factory(:trigger_pr_review).present? },
        full_names: "mcp__themis__trigger_pr_review") do |ctx|
  ctx.factory(:trigger_pr_review).call(ctx)
end

# 在你的调用方 —— 提供工厂
factories: { trigger_pr_review: ->(_ctx) { PRReviewTriggerToolBuilder.build_tool { |args| ... } } }

副作用依赖于调用方状态时使用工厂。如果你只需要不同的配置,则通过构建器块(ctx.conversationctx.message)传递。

外部 MCP 和安全钩子镜像

外部 MCP 工具名称(:linear:sentry_full:metabase_full)在 tool_names 时从 ChannelRegistry.tools_for(...) 获取。受限变体(:sentry_messaging:metabase_messaging)是 ToolCatalog 中的硬编码常量,因为对话/消息表面积有意小于完整智能体路径。

⚠️ allowed_tools 列表对于外部 MCP 服务器不会被 SDK 强制执行 —— MCP 服务器注册所有工具,SDK 无论白名单如何都会转发调用。破坏性的 Metabase 工具通过 PreToolUse 钩子由 AgentSecurityHooks::METABASE_RESTRICTED_TOOLS 阻止。如果你添加新的危险外部工具,真正的守卫是钩子,而不是目录。

测试

  • 目录 DSL 变更 → 在 test/services/tool_catalog_test.rb 添加用例。在测试顶部使用 ToolCatalog.reset! 获得隔离的注册表;teardown 已通过 load_defaults! 恢复默认值。
  • ToolContext predicate 变更 → 在 test/services/tool_context_test.rb 添加用例。用 expects(:method).once 验证记忆化。
  • 调用方集成 → 调用调用方的 allowed_tools(或等价物)并断言期望的名称。模式见 test/jobs/chat_job_test.rb

如果你不小心 register 同一个名称两次,目录将引发 ArgumentError —— 重复工具的 bug 在启动时浮现,而不是在生产中悄悄发生。

弃用工具

  1. tool_catalog.rb 删除 register(...) 块。
  2. 删除任何硬编码工具名称的 full_names 引用。
  3. app/prompts/app/services/ 和测试中执行 grep -r mcp__themis__<name> —— 清理过时的指令或断言。
  4. 如果底层构建器没有其他调用方,则删除它。

注意事项

  • Queueblank? —— 检查队列存在时使用 !ctx.message_queue.nil?(不是 .present?)。
  • full_names: [] 抑制 allowed_tools 输出,但构建器仍然运行 —— 用于像技能/自动化这样的 SDK 发现的捆绑包。
  • 构建器块在每次调用时运行,而不是在注册时。 工具在每次 build_tools 调用时重建;不要在构建器内记忆工具实例。
  • 布尔 predicate 需要 defined? 记忆化,而不是 ||= —— false ||= ... 每次调用都会重新运行。ToolContext 已经这样做;如果你添加 predicate,请遵循相同的模式。