跳到主要内容

工具系统与 Copilot

agent 的"手脚"就是工具。本章讲 Captain 的工具基类、三类典型工具,以及一条和客服机器人平行的链路——给人类坐席用的 Copilot 副驾。

4.1 工具基类:站在 RubyLLM::Tool 上

所有 Captain 工具继承自 gem 的 RubyLLM::Tool,Chatwoot 包了一层基类塞进 assistant/user 上下文:

# enterprise/app/services/captain/tools/base_tool.rb:1
class Captain::Tools::BaseTool < RubyLLM::Tool
prepend Captain::Tools::Instrumentation
attr_accessor :assistant

def initialize(assistant, user: nil)
@assistant = assistant
@user = user
super()
end
end

基类还提供权限检查 user_has_permission——给 Copilot 那些"代表某个坐席操作"的工具用,检查这个 user 在该账号下有没有对应权限(base_tool.rb:18)。

定义一个工具,声明 description + param 即可,gem 会自动把它转成 LLM 的 function schema:

# enterprise/lib/captain/tools/faq_lookup_tool.rb:1
class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
description 'Search FAQ responses using semantic similarity to find relevant answers'
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
def perform(_tool_context, query:); ...; end
end

4.2 内置工具:assistant 默认拿什么

主 agent 默认带两个工具——查 FAQ 和转人工:

# enterprise/app/models/captain/assistant.rb:94
def agent_tools
[
self.class.resolve_tool_class('faq_lookup').new(self),
self.class.resolve_tool_class('handoff').new(self)
]
end

库里还有更多内置工具(enterprise/lib/captain/tools/):加标签 add_label_to_conversation_tool、改优先级 update_priority_tool、写私有备注 add_private_note_tool、写联系人备注 add_contact_note_tool、解决会话 resolve_conversation_tool、通用 HTTP http_tool。scenario agent 可以按需挑选这些(见第 2 章 scenario 的 resolved_tools)。

4.3 handoff 工具:双重信号的来源

handoff 工具值得单独看,因为它既在 agent 循环内立即生效,又给上层留信号。第 1 章的三岔路就靠它。

# enterprise/lib/captain/tools/handoff_tool.rb:5
def perform(tool_context, reason: nil)
conversation = find_conversation(tool_context.state) # 从 context.state 取会话
return 'Conversation not found' unless conversation
trigger_handoff(conversation, reason)
"Conversation handed off to human support team..."
end

def trigger_handoff(conversation, reason)
conversation.messages.create!(message_type: :outgoing, private: true, # 把转交理由写成私有备注
sender: @assistant, content: reason)
conversation.bot_handoff! # 切 open + 派事件
send_out_of_office_message_if_applicable(conversation) # 必要时发离线语
end

注意它在 agent 循环内就已经 bot_handoff!——会话状态当场变 open。所以回到第 1 章 process_response:V2 handoff 触发后若会话已不 pending,说明工具成功执行了,只需补一条客户可见的 follow-up(process_v2_handoff);若还 pending(工具内部出错返回了 "Conversation not found"),才回落到完整的 V1 转人工。这就是那段"V2 优先"逻辑的根因。

4.4 自定义工具:把客户自己的 API 接进来

最灵活的是 Captain::CustomTool——让用户在后台配一个 HTTP 端点,Captain 就能调它(比如"查订单状态"打到客户自己的订单系统)。

# enterprise/app/models/captain/custom_tool.rb:26 字段(Schema 注释)
# endpoint_url :text # 打哪个 URL
# http_method :string # GET / POST
# auth_type :string # none / bearer / basic / api_key
# param_schema :jsonb # 这个工具收哪些参数
# request_template :text # 请求体模板(Liquid)
# response_template :text # 怎么把响应整理给 LLM

安全与配额上有几道闸:

  • 数量上限 MAX_PER_ACCOUNT = 15,且用账号行锁串行化创建,防并发超额(custom_tool.rb:85)。
  • slug 唯一 + 长度 ≤64,因为 slug 直接当 LLM 函数名,必须满足 OpenAI 限制(custom_tool.rb:38)。
  • 端点校验 SafeEndpointValidatable(防 SSRF 之类,custom_tool.rb:31 include)。
  • 参数 schema 校验 用 JSON Schema 验 param_schema 结构(custom_tool.rb:68)。

运行时,V1 链路把启用的自定义工具实例化成 CustomHttpTool 挂到 chat 上(assistant_chat_service.rb:36),并把工具名/描述也写进系统 prompt,让模型知道有这些工具可用(assistant_chat_service.rb:52)。

4.5 工具引用的小语法糖:在 scenario 指令里 @ 工具

scenario 的指令文本里可以用 [@Add Private Note](tool://add_private_note) 这种 markdown 链接语法引用工具,保存时会被自动解析进 tools 字段:

# enterprise/app/models/captain/scenario.rb:171
def resolve_tool_references
return if instruction.blank?
tool_ids = extract_tool_ids_from_text(instruction) # 从 tool://xxx 抽出工具 id
self.tools = tool_ids.presence
end

保存前还会校验引用的工具都真实存在,否则报错(scenario.rb:143 validate_instruction_tools)。这让"非技术人员在后台写 scenario 指令"也能安全地挂工具。

4.6 Copilot:另一条链路,给人类坐席的副驾

注意区分两个东西:

Captain assistantCopilot
服务对象客户(自动回客户)人类坐席(帮坐席,不直接面客)
触发客户发消息自动触发坐席在侧边栏主动问
工具性质可改会话状态(写)主要是只读检索
入口AgentRunnerService / AssistantChatServiceCaptain::Copilot::ChatService

Copilot 是坐席处理疑难时的助手——"帮我查一下这个客户以前的会话""Linear 上有没有相关 issue"。它挂的是一组搜索类只读工具:

# enterprise/app/services/captain/copilot/chat_service.rb:63
def build_tools
tools = []
tools << Captain::Tools::SearchDocumentationService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::GetConversationService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::SearchConversationsService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::GetContactService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::SearchArticlesService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::SearchContactsService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::SearchLinearIssuesService.new(@assistant, user: @user) # 连 Linear
tools.select(&:active?)
end

关键区别:Copilot 工具都带 user:(哪个坐席在用),因为它要用 4.1 的 user_has_permission 做权限校验——坐席只能搜到自己有权看的数据。系统 prompt 还会把"当前账号 id、语言、正在看的会话/联系人"作为上下文塞进去(chat_service.rb:93:100)。

4.7 V1 的工具循环长什么样

顺带看一眼 V1(ChatHelper)怎么手动跑工具循环——和 V2 把循环交给 gem 不同,V1 自己挂回调:

# enterprise/app/helpers/captain/chat_helper.rb:47
def setup_event_handlers(chat)
chat.on_end_message { |message| record_llm_generation(chat, message) } # 记 token 计费
chat.on_tool_call { |tool_call| handle_tool_call(tool_call) } # 模型要调工具
chat.on_tool_result { |result| handle_tool_result(result) } # 工具返回
chat
end

RubyLLM 的 chat.ask 内部会自动多轮:模型说"调 faq_lookup" → gem 执行工具 → 把结果回灌 → 模型再答,直到产出最终 JSON。V1 在这些回调里顺带做了"思考中"消息持久化和 Langfuse 追踪(chat_helper.rb:58)。


小结: 工具是 agent 的手脚——内置工具改会话、自定义工具打外部 API、handoff 工具兼当"放弃信号"。Copilot 是平行的只读副驾,服务人类坐席。最后一章收口:巧妙之处、V1/V2 取舍、边界与代码地图。