跳到主要内容

工具体系:发现、执行、审批与持久化日志

30 秒导读: 第 2 章讲了「LLM 说要调工具 → 循环怎么把它跑起来」。本章往下钻一层,讲工具本身怎么被接到 agent 上并安全执行:一个工具要满足什么契约、系统怎么把一目录的工具文件自动发现出来、LLM 给的函数名怎么映射回真正的工具、要不要人工审批、执行结果怎么截断入库,以及最关键的——每一次工具调用怎么被记进一张对账日志表,让"提议了什么 / 真跑了没有 / 成功还是失败"事后都查得到。

本章不重复第 2 章的「工具循环调度」(LLMHandler 如何驱动多轮),只讲这一次调用从"被发现"到"落库"的纵向切面。


1. 这是什么(零基础也能懂)

一句话定义

工具(Tool)= 给 LLM 的一只手。 模型本身只会生成文字;要它真去搜文档、调 HTTP API、连远程 MCP 服务器、在沙箱里跑一段 Python,就得把这些能力包装成"工具",告诉模型"你可以调用这些函数",再由后端替模型真的去执行

解决什么问题

假设你在 DocsGPT 里问:"把这个仓库里所有 .py 文件的行数统计成一张表。" 模型不能凭空数文件,它需要:

  1. 知识库找到文件列表(internal_search 工具);
  2. 再在沙箱里跑一段统计代码(code_executor 工具);
  3. 可能还要调你自己配的外部 API(api_tool)或连一台 MCP 服务器(mcp_tool)。

工具体系要回答的问题是:这些五花八门的能力,怎么用同一套机制挂上去、被模型看见、被安全地执行,并且留下可对账的痕迹?

一句话直觉

把工具体系想成一家餐厅的后厨传菜系统:

  • 菜谱(契约):每道菜必须写清"叫什么、要什么料、怎么做"——对应 Tool 的三个方法。
  • 点菜台(发现):开店时自动把后厨所有菜谱扫一遍贴到菜单上——对应 ToolManagerpkgutil 扫描。
  • 划单员(执行器):顾客(LLM)点的菜名要翻译成后厨编号、决定要不要经理签字(审批)、把菜做出来、并在流水账本上记一笔——对应 ToolExecutortool_call_attempts 日志。

2. 顶层全景(它大概怎么转)

一次工具调用,数据流是这样走的(从左到右,自上而下):

┌──────────────────────────────────────┐
开店/建请求时 │ ① 发现 (ToolManager) │
───────────────► │ pkgutil 扫 tools/ 目录,把每个 Tool │
│ 子类实例化进 self.tools 字典 │
└───────────────┬──────────────────────┘
│ 可用工具集 tools_dict

每一轮 LLM 调用前 ┌──────────────────────────────────────┐
───────────────► │ ② 准备 (prepare_tools_for_llm) │
│ 工具动作 → LLM 函数 schema; │
│ 重名消歧 + 截到 64 字符 + 建反向名称映射 │
└───────────────┬──────────────────────┘
│ 函数列表交给模型

模型回了 tool_call ┌──────────────────────────────────────┐
───────────────► │ ③ 解析 (ToolActionParser) │
│ 函数名 → (tool_id, action_name) + 参数 │
└───────────────┬──────────────────────┘

┌──────────────────────────────────────┐
│ ④ 审批 (check_pause) │
│ client_side? require_approval? │
│ headless? → 暂停 / 拒绝 / 放行 │
└───────────────┬──────────────────────┘
│ 放行

┌──────────────────────────────────────┐
│ ⑤ 执行 (execute) │
│ _record_proposed → 加载工具 │
│ → tool.execute_action → _mark_executed │
│ 截断结果 / 判定状态 / 落库 │
└──────────────────────────────────────┘

各部件一句话职责:

部件干什么在哪个文件
Tool(ABC)定义所有工具必须实现的三方法契约 + internal 标志application/agents/tools/base.py
ToolManagerpkgutil 动态发现工具模块;按需实例化(部分工具带 user_id 作用域)application/agents/tools/tool_manager.py
ToolExecutor准备 schema、名称映射、审批、执行、写对账日志application/agents/tool_executor.py
ToolActionParser把 LLM 的函数调用解析回 (tool_id, action_name, 参数)application/agents/tools/tool_action_parser.py
ToolCallAttemptsRepository对账日志表的 proposed/executed/failed 三种写法application/storage/db/repositories/tool_call_attempts.py
default_tools合成"默认开"和"agent 可选内置"工具的伪 user_toolsapplication/agents/default_tools.py

3. 工具契约:一个工具要长什么样

3.1 三个方法 + 一个标志

所有工具都继承 Tool 这个抽象基类。它只要求实现三件事——application/agents/tools/base.py:4 class Tool(ABC):

# 示意,非源码 —— 真实定义见 base.py
class Tool(ABC):
internal: bool = False # True = 不出现在"添加工具"目录里

def execute_action(self, action_name, **kwargs): ... # 真正干活
def get_actions_metadata(self): ... # 我有哪些动作、参数长啥样
def get_config_requirements(self): ... # 我需要哪些配置(如 API Key)

三方法各自的职责,可以对上"传菜系统"的三个动作:

方法回答的问题谁来消费它
get_actions_metadata()我能做哪几件事?每件事要什么参数?prepare_tools_for_llm 拿去转成给模型的函数 schema
execute_action(action_name, **kwargs)给定动作名和参数,真去执行ToolExecutor.execute 在放行后调用
get_config_requirements()我需要用户填哪些配置(URL / 密钥…)前端渲染配置表单;启动校验默认工具时用

internal 标志的作用点很关键:一个 internal = True 的工具不会被 ToolManager.load_tools 自动挂到用户菜单(见下一节的过滤条件),但仍可被显式加载。典型例子是 InternalSearchTool——它是把 RAG 检索包成工具的"合成工具",不该出现在用户的"添加工具"目录里。

3.2 两类"动作"来源

不是所有工具的动作都写死在代码里。DocsGPT 里动作元数据有两种来源:

  • 静态:代码里 get_actions_metadata() 直接返回,如 internal_searchcode_executor
  • 动态:运行时从外部拉取。mcp_tool 连上 MCP 服务器后 list_tools,把远端工具翻译成本地动作(mcp_tool.py:542 get_actions_metadata);api_tool 干脆返回空 [](api_tool.py:231),因为它的动作完全由用户在配置里定义。

4. 动态发现:一个目录变成一份菜单

4.1 pkgutil 扫描

ToolManager 一被构造就调 load_tools(),用标准库 pkgutil.iter_modules 遍历 tools/ 目录下的每个模块——tool_manager.py:15 load_tools:

# 示意,非源码 —— 真实逻辑见 tool_manager.py:15
for finder, name, ispkg in pkgutil.iter_modules([tools_dir]):
if name == "base" or name.startswith("__"):
continue # 跳过基类和 dunder
module = importlib.import_module(f"...tools.{name}")
for member_name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, Tool) and obj is not Tool and not obj.internal:
self.tools[name] = obj(self.config.get(name, {})) # 实例化

重点看那行 if:一个类要被自动挂上,必须同时满足——是 Tool 的子类、不是 Tool 本身、且 internal 为假。这就是 §3.1 里 internal 标志的落点:合成工具(如 internal_search)被这一步主动排除在自动菜单外。

新增一个工具,不用改任何注册表——tools/ 目录扔一个文件、定义一个 Tool 子类,下次构造 ToolManager 时自动就在了。这就是"约定优于配置"。

4.2 哪些工具需要 user_id 作用域

有些工具的行为依赖调用者是谁:它要读某个用户的笔记、待办、连他授权过的 MCP 服务器、在他的会话沙箱里跑代码。这类工具不能在"开店时"一次性实例化——那时还不知道请求属于谁。

ToolManager 用一个硬编码集合区分它们。load_tool(单个按需加载)和 execute_action 里都有同一份名单——tool_manager.py:26 load_tool:

# 示意,非源码 —— 名单见 tool_manager.py:31-46
USER_SCOPED = {
"mcp_tool", "notes", "memory", "todo_list", "scheduler",
"remote_device", "code_executor", "artifact_generator", "read_document",
}
if tool_name in USER_SCOPED and user_id:
return obj(tool_config, user_id) # 多传一个 user_id
else:
return obj(tool_config) # 普通工具只吃 config

坑点(inferred): 这份名单在 load_toolexecute_action 里各写了一份、且和 ToolExecutor._get_or_load_tool 的加载路径并行存在。名单一旦有工具漏加,那个工具就会丢掉 user 上下文(拿 Noneuser_id),表现为"读不到该用户的数据"。改这里要三处一起核对。


5. 名称映射:LLM 看到的名字 ≠ 内部标识

5.1 为什么需要映射

模型那边只认一个扁平的函数名(如 search),但后端要知道这属于哪个工具实例的哪个动作——即 (tool_id, action_name)。而且两个不同工具可能有同名动作(braveduckduckgo 都有 search),OpenAI 的函数名还有 ^[a-zA-Z0-9_-]{1,64}$ 的限制。

prepare_tools_for_llm 就负责把 tools_dict 里每个动作转成一条 LLM 函数 schema,同时解决重名消歧64 字符截断——tool_executor.py:391 prepare_tools_for_llm。它的策略:

情况LLM 看到的名字
动作名唯一且 ≤ 64 字符原样(get_weather)
动作名重复加工具名前缀消歧(brave_search / duckduckgo_search)
仍冲突追加数字后缀(..._1),且不能偷走某个唯一动作已占的名字
超长截到 64 字符

5.2 反向映射表:抗脆弱的关键

转换时,执行器建立一张反向映射 _name_to_tool: {llm_name → (tool_id, action_name)}——tool_executor.py:453。这样模型回调时,直接查表就能定位,不靠字符串切割

早期实现是"按 _ 切分函数名反推 tool_id"的脆弱做法,如今只作为 legacy 回退保留——tool_action_parser.py:34:

# 示意,非源码 —— 见 _parse_openai_llm, tool_action_parser.py:26
resolved = self._resolve_via_mapping(call.name) # 优先查反向映射表
if resolved:
return resolved[0], resolved[1], call_args # (tool_id, action_name, args)
# 查不到才退回按 "_" 切割的老逻辑(并告警可能是幻觉调用)

ToolActionParser 按 LLM 家族分派解析器(OpenAILLM / GoogleLLM)。它还兜了几个健壮性坑:

  • Google 的 resume 路径会把本是 dict 的 arguments 字符串化,解析器要 json.loads 还原,失败则退回空 dict,避免下游 .items() 崩流——tool_action_parser.py:57 _parse_google_llm
  • 函数名切不出 ≥2 段、或 tool_id 不是数字,都打 warning 提示"可能是模型幻觉出来的工具调用"。

6. 审批与暂停:执行前的那道闸

check_pause 在真正执行前跑,决定这次调用是直接放行、暂停等人批、还是在无人值守模式下拒绝——tool_executor.py:486 check_pause。它返回一个 pending-action 字典(或 None 表示放行)。

6.1 三条岔路

check_pause

┌──────────────┼─────────────────┐
▼ ▼ ▼
client_side? require_approval? 都不是
│ │ │
客户端工具 需要人工批准 放行
(浏览器端跑) │ (return None)
│ ┌─────┴─────┐
headless? 交互模式 headless 模式
│ 暂停等批准 查 tool_allowlist
拒绝 (awaiting_ 命中且非强制 → 放行
(denied) approval) 否则 → headless_denied

为什么 headless(无人值守)要单独处理? 定时任务、webhook 触发的 run 没有人能点"批准"。所以 check_pauseself.headless 为真时,把本该暂停的调用改成返回 pause_type: "headless_denied" 的哨兵,上游循环据此合成一个错误结果喂回模型,而不是卡死等一个永远不会来的批准。

6.2 审批决策不信缓存,直接问工具

user_tools.actions[].require_approval 是一份快照,可能已经过时。对两类高危工具,check_pause 跳过快照、当场实例化工具去问:

  • remote_device:调 preview_decision,按设备的实时 approval_mode、allow/denylist、sticky 模式判定;出任何错就默认强制弹批准,绝不静默放行——tool_executor.py:612 _remote_device_requires_approval
  • code_executor:部署级 config.require_approval 是权威,盖过快照;出错fail-closed(要求批准),绝不静默跑未审代码——tool_executor.py:640 _code_executor_requires_approval

一个安全细节(denylist_forced): headless 模式下,预授权的 tool_allowlist 可以让"普通需批准"的调用放行;但如果批准是硬性 denylist 强制的(denylist_forced = True),allowlist 不得绕过——tool_executor.py:575。即"这台设备被加白名单"不等于"denylist 里的命令也能跑"。


7. 执行:从提议到落库

execute 是主干——tool_executor.py:667。它是个 generator:一路 yield 状态事件(pending / error / 最终结果),最后 return (result, call_id)。核心顺序:

① 解析 call → (tool_id, action_name, call_args)
② tool_id 不合法 / 找不到? → 记 proposed + mark_failed,产 error 事件,提前返回
③ _record_proposed(...) ← 先写"提议"日志,拿到 proposed_ok
④ call_args 不是 dict? → mark_failed,防 .items() 崩流
⑤ 按 action 元数据分拣参数到 query_params/headers/body/parameters
⑥ _get_or_load_tool(...) ← 加载工具(带缓存 + 解密凭据)
⑦ tool.execute_action(...) ← 真正执行;异常则 mark_failed 后 re-raise
⑧ 抽取 artifact_id(如有)
⑨ 截断结果 + 判定 status + _mark_executed(...) ← 回填"已执行"日志
⑩ yield 最终事件(剔除 result_full 等重字段)

7.1 结果的两种"瘦身"

工具原始结果可能很大(一段代码输出、一个 API 的大 JSON)。执行器对它做两件事:

  • 截断入库/推流:truncate_tool_result 把持久化到 message、推给前端 UI 的副本截到 PERSISTED_RESULT_MAX_LEN = 2000 字符——tool_executor.py:48。注意注释里的教训:早期截到 50 字符,把所有真实报错都藏在 ... 后面,导致"重试风暴"无从诊断。但 LLM 和对账日志始终拿完整结果,截断只作用于 message 的 JSONB 副本。
  • 状态判定:result_status 从结果里读出真实状态——tool_executor.py:56。工具用**带内(in-band)**方式报错:返回 {"status": "error", ...} 或带 error 键。执行器据此把日志状态标成 error 而非一律 completed,否则"存档里失败的调用看起来都成功了"。

7.2 凭据按"工具所有者"解密(委托授权)

_get_or_load_tool 加载工具时,如果配置里有 encrypted_credentials,解密用的不是调用者的 sub,而是工具行的 user_id(所有者)——tool_executor.py:978。这样团队成员跑一个共享工具时,用的是所有者的凭据(有意的委托授权),并且会打一条 tool_credential_delegation 审计日志。

工具实例还带缓存:cache_key = f"{name}:{tool_id}:{user}"。命中缓存时会刷新 attachments,避免上一轮的附件残留到这一轮——tool_executor.py:944api_tool 因为 config 随 action 变,不缓存


8. 持久化日志:tool_call_attempts 对账机制

这是本章最"精华"的一块。DocsGPT 为每一次工具调用tool_call_attempts 表里留痕,状态机是:

proposed ──成功──► executed / confirmed

└──失败──► failed

(proposed 写失败但工具照跑了 → upsert 直接补一行 executed)

三个执行器级函数各管一段——都在 tool_executor.py:

函数时机做什么
_record_proposed执行前proposed 行;返回 True 当且仅当这次调用真的建了行
_mark_executed执行后翻成 executed;若 proposed 曾失败,upsert 补一行
_mark_failed出错时翻成 failed,写异常文本

8.1 为什么按 message_id 命名空间去重

tool_call_attempts.call_id全表主键,但 LLM 会跨轮、跨用户复用确定性 id(如 functions.create_artifact:0)。若直接拿 call_id 当键,不同的逻辑调用会在主键上撞车,后来的日志行被 ON CONFLICT DO NOTHING 静默丢弃。

解法是 _journal_key——tool_executor.py:84:

# 示意,非源码 —— 见 _journal_key, tool_executor.py:84
def _journal_key(call_id, message_id):
return f"{message_id}:{call_id}" if message_id else call_id
  • 有 message_id(每轮唯一):用 message_id:call_id 作键,让每个逻辑调用有自己的行;而同一轮内真正的重试(同 call_id、同 message_id)仍能去重。
  • 无 message_id(headless):保留裸 call_id,维持既有行为。
  • 原始 call_id 不动:仍用于 LLM 的 tool_call/tool_result 配对和 UI。

8.2 ON CONFLICT DO NOTHING 的坑与守卫

record_proposedON CONFLICT (call_id) DO NOTHING 防止重复 id 抛 IntegrityError——tool_call_attempts.py:51。但这带来一个必须处理的语义:插入返回 rowcount == 0(即 proposed_ok = False)时,那行可能属于另一个还在飞的请求。所以 _record_proposed 的文档明确警告:此时调用方不得再去 _mark_failed / _mark_executed 翻它——否则会篡改别人的行。执行器里凡是后续的 _mark_failed 都包在 if proposed_ok: 里(如 tool_executor.py:789)。

三个 repo 方法都加了同样的守卫,值得记住这套模式:

  • mark_executed / mark_failed 的 UPDATE 都带 WHERE ... status = 'proposed'——只翻还处于 proposed 的行,绝不把已终态(executed/confirmed/failed)或别的租户的行改掉——tool_call_attempts.py:170tool_call_attempts.py:190
  • 给了 user_id 时,再加 AND user_id IS NOT DISTINCT FROM :user_id,让跨租户撞 id 也伤不到这行。
  • upsert_executedON CONFLICT DO UPDATE 同样只升级"仍是 proposed 且同 user"的行——tool_call_attempts.py:110

一句话精华: 因为主键 call_id 被 LLM 复用,这张表的所有写操作都遵循同一条铁律——只动"我提议的、还没终结的、属于我的"那一行。命名空间键 + 状态守卫 + 租户守卫,三者缺一,就会出现"A 请求的成功被 B 请求标成失败"这种幽灵 bug。


9. 代表性工具:一句话职责 + 关键指针

前面讲的是"骨架"。真正干活的是 tools/ 目录里 20 多个工具。挑四把最能说明设计意图的:

9.1 internal_search —— 把 RAG 检索包成工具

职责: 让 LLM 自己决定何时、搜什么,而不是把文档预填进 prompt。

  • internal = True——所以它不进自动菜单,由检索层显式注入(internal_search.py:24)。
  • 两个动作:search(检索)和 list_files(浏览目录结构,仅当源有 directory_structure 时才暴露)——internal_search.py:128 execute_action
  • 合成注入的关键坑: 它没有 DB 行。add_internal_search_tool 必须给它盖上哨兵 id INTERNAL_TOOL_ID = "internal"(internal_search.py:319),否则执行器的 _get_or_load_tool 会因"缺 id"把它丢弃(tool_missing_row_id)——internal_search.py:456
  • 检索本身走第 4 章的 build_dispatcher(按源路由),kill-switch 时回退 legacy classic 检索——internal_search.py:59

9.2 mcp_tool —— 接远程 MCP 服务器

职责:FastMCP 客户端连上一台 MCP 服务器,把它的工具动态变成本地动作。

  • 动作元数据运行时才知道:discover_toolslist_tools,get_actions_metadata 把远端 schema 翻成本地格式——mcp_tool.py:328mcp_tool.py:542
  • SSRF 防护: server_urlvalidate_url,指向私网就抛错——mcp_tool.py:95 _validate_server_url。STDIO transport 被明确禁用(mcp_tool.py:217)。
  • OAuth 双模: 交互模式 DocsGPTOAuth 走前端重定向 + Redis 传 code;查询执行时用 NonInteractiveOAuth,401 直接快速失败而不是卡住流式响应等一个不会来的授权——mcp_tool.py:815。token 存在 Postgres 的 connector_sessions(DBTokenStorage)。
  • 客户端有 5 分钟进程内缓存(_mcp_clients_cache,mcp_tool.py:147),401 时清缓存重连再试一次。

9.3 api_tool —— 任意 HTTP API 变工具

职责: 用户在配置里描述一个 HTTP 端点(URL / method / 参数),它就变成一个可调工具。

  • get_actions_metadata 返回空 []——动作全由用户定义,不是代码写死的(api_tool.py:231)。执行器对 api_tool 特判:参数分 query_params / headers / body 三桶,结果不缓存。
  • 路径参数注入 + SSRF 防护: URL 里 {param} 占位用 quote 转义后替换,其余进 query string;请求走 pinned_request(把 DNS 钉死防 rebinding),不安全 URL 抛 UnsafeUserUrlError——api_tool.py:130
  • body 序列化交给 RequestBodySerializer,支持按 OpenAPI encoding rules 处理多种 content-type——api_tool.py:107

9.4 code_executor —— 沙箱执行

职责: 在一个绑定到会话的沙箱里跑 Python,代码写出的文件自动变成可下载 artifact。

  • 只有一个动作 run_code;preview_decision 返回 (require_approval, False)——审批看部署级 config,从不 denylist 强制(code_executor.py:180)。
  • 只回摘要,绝不回原始字节: stdout/stderr 只取尾部 _OUTPUT_TAIL_BYTES = 4000;产出文件转成 artifact 引用——code_executor.py:405 _shape_payload
  • 会话 id 从 conversation/workflow_run 派生并按网关字符集 [A-Za-z0-9_-] 清洗——code_executor.py:441
  • 超时不可调: 每次 run_codeSANDBOX_EXEC_TIMEOUT(默认 60s)硬顶;长任务要模型自己起后台进程 + persist=true 保活会话再轮询——code_executor.py:405
  • 输入 artifact 走 parent-scoped 网关解析(短 ref A1 也只在本会话内解析),防止 ref 跨租户——code_executor.py:257 _materialize_inputs

10. 默认工具:不用配置就在的那些

default_tools.py 解决一个问题:有些工具(如 thinkread_webpage)config-free、该默认开,但它们没有 user_tools 表里的真实行。方案是合成一份 user_tools-形状的伪行,盖上确定性的 uuid5 id。

两个由本章执行器直接消费的函数:

  • synthesized_default_tools(user_doc, headless=...)——default_tools.py:304:为无 agent 的聊天返回默认工具行。它在 ToolExecutor._get_user_tools 里被合并进用户工具集(tool_executor.py:346)。会扣掉用户手动关掉的、以及 headless 下排除的。
  • is_headless_excluded_tool(name)——default_tools.py:327:判断一个工具名是否必须从 headless run 隐藏。目前名单是 {"scheduler"}——因为让一个定时任务里的 LLM 去调 schedule_task,会每次触发都链式新建计划,是成本陷阱。执行器的三条工具解析路径(_get_tools_by_ids / _get_tools_by_api_key / _get_user_tools)调它做过滤。

一个 schema 陷阱(_FK_BOUND_TOOLS): notestodo_list 的存储表把 tool_id 外键到 user_tools.id。合成工具的 id 没有真实行,写入会 FK 违约——所以 validate_default_chat_tools 在启动时硬拒把这类工具设为默认开(default_tools.py:201)。这解释了为什么它们出现在 USER_SCOPED 名单(§4.2)、却不能进默认工具。


11. 巧妙之处(值得带走的技术)

妙在哪一句话指针
约定发现,零注册表扔个文件进目录即上线,internal 标志控制可见性tool_manager.py:15 load_tools
反向名称映射抗脆弱不靠切字符串反推 tool_id,查表定位tool_executor.py:453 _name_to_tool
命名空间键破主键复用message_id:call_id 让复用 id 的调用各自有行tool_executor.py:84 _journal_key
三重守卫的日志写状态 + 租户 + 命名空间守卫,只动自己那行tool_call_attempts.py:170 mark_executed
带内报错入库从结果读 status,失败不再伪装成 completedtool_executor.py:56 result_status
审批不信缓存高危工具当场问 preview_decision,fail-closedtool_executor.py:640 _code_executor_requires_approval
headless 拒绝而非卡死无人可批准 → 合成错误喂回模型tool_executor.py:486 check_pause

12. 边界与局限(诚实)

  • USER_SCOPED 名单三处重复:tool_manager.pyload_tool/execute_action 与执行器的加载路径各持一份判断,新增 user-scoped 工具时容易漏改导致丢 user 上下文。(inferred,基于三处独立硬编码集合)
  • _run_async_operation 里有一行 unreachable 的重复 raise——mcp_tool.py:304-305,第二行永远执行不到,是遗留笔误(不影响行为)。
  • 进程内缓存不跨 worker:_mcp_clients_cachedefault_tools 的各 _*_cache 都是模块级 dict,多进程部署下各 worker 各有一份,靠 TTL / 确定性 id 保证一致性,而非共享。
  • 合成工具必须手动盖 id:internal_search 若忘了 INTERNAL_TOOL_ID 就被执行器静默丢弃——合成工具没有 DB 行这一前提贯穿全链路,是个隐性契约。

13. 代码地图(导航索引)

主题文件路径符号名
工具契约(三方法 + internal)application/agents/tools/base.pyTool
动态发现 / pkgutil 扫描application/agents/tools/tool_manager.pyToolManager.load_tools / load_tool / execute_action
执行器主体application/agents/tool_executor.pyToolExecutor
LLM 函数 schema + 名称映射application/agents/tool_executor.pyprepare_tools_for_llm / _name_to_tool
审批 / 暂停 / headless 拒绝application/agents/tool_executor.pycheck_pause / _remote_device_requires_approval / _code_executor_requires_approval
执行 + 结果截断 + 状态判定application/agents/tool_executor.pyexecute / truncate_tool_result / result_status
工具加载 + 凭据委托解密 + 缓存application/agents/tool_executor.py_get_or_load_tool
对账日志(执行器侧)application/agents/tool_executor.py_record_proposed / _mark_executed / _mark_failed / _journal_key
对账日志(repo 侧,ON CONFLICT + 守卫)application/storage/db/repositories/tool_call_attempts.pyrecord_proposed / mark_executed / upsert_executed / mark_failed
参数解析application/agents/tools/tool_action_parser.pyToolActionParser / _parse_openai_llm / _parse_google_llm
RAG 包成工具application/agents/tools/internal_search.pyInternalSearchTool / INTERNAL_TOOL_ID / add_internal_search_tool
MCP 服务器接入application/agents/tools/mcp_tool.pyMCPTool / DocsGPTOAuth / NonInteractiveOAuth
HTTP API 变工具application/agents/tools/api_tool.pyAPITool / _make_api_call
沙箱执行application/agents/tools/code_executor.pyCodeExecutorTool / _run_code / preview_decision
默认 / 内置工具合成application/agents/default_tools.pysynthesized_default_tools / is_headless_excluded_tool / _FK_BOUND_TOOLS

相关章节: 工具循环怎么驱动多轮调用与暂停续跑见 02-agent-tool-loop.md;检索层的 Dispatcher 与多检索器见 04-retrieval-rag.md;从请求到 agent 的入口见 01-request-and-agents.md