本章目录In this chapter
- 4 · The Tool System
- 4.1 The Most Important Design Decision: Implementation Separated from Exposure
- 4.2 Composing and Resolving Toolsets
- 4.3 Central Tool Dispatch
- 4.4 The Argument Coercion Layer
- 4.5 Sanitizing Tool Error Messages
- 4.6 Observing Tool Results, and Hooks
- 4.7 Recognizing a Delegation Context
- 4.8 Caching Tool Definitions
4 · 工具系统
162 个工具文件,加上一个 39 KB 的投放策略文件。这一章讲工具怎么被组织、怎么被投放、以及调用参数怎么被矫正。
4.1 最重要的设计:实现与投放分离
tools/ 目录里是 157 个工具「怎么做」;toolsets.py 里是它们「在什么场景下该被拿出来用」。
这两件事被彻底拆开了 —— 这是 Hermes 最值得直接搬走的一个设计。
核心工具清单
# 命令行界面和所有消息平台共用的工具清单。
# 改这一处就同时更新了所有平台。
_HERMES_CORE_TOOLS = [
# 网络
"web_search", "web_extract",
# 终端与进程管理
"terminal", "process",
# 注意:桌面图形界面相关的能力(read_terminal、open_preview 等)
# 刻意不放在这里,理由和下面的 project 工具一样:
# 它们只在有图形渲染器能响应的地方才有意义。它们住在 desktop_ui
# 工具集里,只由图形网关为"来源是桌面应用"的会话启用 ——
# 绝不基于进程环境变量判断,因为那看不见"桌面客户端连的是远程后端"这种情况。
# 文件操作
"read_file", "write_file", "patch", "search_files",
# 视觉与图像生成
"vision_analyze", "image_generate",
# 技能
"skills_list", "skill_view", "skill_manage",
# 浏览器自动化
"browser_navigate", "browser_snapshot", "browser_click",
"browser_type", "browser_scroll", "browser_back",
"browser_press", "browser_get_images",
"browser_vision", "browser_console", "browser_cdp", "browser_dialog",
"browser_exec", # browser.backend 为 "browser-use" 时替代其他浏览器工具
# 文字转语音
"text_to_speech",
# 规划与记忆
"todo", "memory",
# 会话历史搜索
"session_search",
# 澄清提问
"clarify",
# 代码执行与委派
"execute_code", "delegate_task",
# 定时任务
"cronjob",
# 智能家居(通过 check_fn 检查 HASS_TOKEN 决定是否启用)
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
# 看板多智能体协作 —— 只有当智能体是作为看板工人被派生
# (设置了 HERMES_KANBAN_TASK 环境变量),或当前身份显式启用了
# kanban 工具集时,才会进入 schema。通过 check_fn 控制。
"kanban_show", "kanban_list", "kanban_complete", "kanban_block",
"kanban_request_review", "kanban_request_changes", "kanban_heartbeat",
"kanban_comment", "kanban_create", "kanban_link", "kanban_unblock",
"kanban_attach", "kanban_attach_url", "kanban_attachments",
# 计算机操作(macOS,通过 check_fn 检查 cua-driver 是否安装)
"computer_use",
]
hermes-agent/toolsets.py
那两条注释揭示的设计原则
注释里那句话值得逐字读:
「…enabled solely by the GUI gateway for a session whose SOURCE is the desktop app — never keyed on a process env var, which is blind to a desktop client talking to a remote/cloud backend.」
译:只由图形网关为「来源是桌面应用」的会话启用 —— 绝不基于进程环境变量判断,因为那看不见「桌面客户端连接的是一个远程/云端后端」这种情况。
展开这个坑:桌面应用相关的工具(比如「打开预览窗口」)需要有一个图形界面来响应。最直觉的判断方式是看环境变量「我是不是跑在桌面环境里」。
但 Hermes 的部署形态里,桌面客户端可能连着一台云端服务器上的智能体。那台服务器上没有图形界面,环境变量说「不是桌面环境」—— 但用户确实是从桌面应用发来的消息,确实需要这些工具。
正确的判断依据是「这条消息从哪个入口进来的」,而这个信息只有网关知道。
信任边界收窄:webhook 工具集
# Webhook 事件可能源自不可信的第三方内容(例如公开代码仓库的
# 合并请求标题或评论)。默认的 webhook 工具集刻意保持收窄,
# 以避免提示词注入触发本地的文件读写或系统命令执行。
_HERMES_WEBHOOK_SAFE_TOOLS = [
"web_search", # 联网搜索(只读)
"web_extract", # 提取网页内容(只读)
"vision_analyze", # 分析图片(只读)
"clarify", # 向用户提问(无副作用)
]
对比一下核心清单里被排除的:terminal(执行命令)、write_file(写文件)、patch(改文件)、execute_code(跑代码)、delegate_task(派生子智能体)—— 全部是有副作用的。
留下的四个的共同点是:即使模型被完全操控,它能造成的最大伤害也只是「搜了些无关的东西」。
这就是「按信任边界配置工具面」的范式。安全性不是靠在提示词里写「请不要执行危险命令」实现的 —— 模型无法调用一个它不知道存在的工具。
4.2 工具集的组合与解析
TOOLSETS = {
"web": {
"description": "Web research and content extraction tools",
"tools": ["web_search", "web_extract"],
"includes": [] # ★ 可以包含其他工具集
},
"search": {
"description": "Web search only (no content extraction/scraping)",
"tools": ["web_search"],
"includes": []
},
"vision": { "tools": ["vision_analyze"], ... },
"video": { "description": "…(选择性加入,不在默认工具集里)",
"tools": ["video_analyze"], ... },
"image_gen": { "tools": ["image_generate"], ... },
...
}
每个工具集有三个字段:描述(给人看的)、工具清单、包含的其他工具集。
解析函数
def get_toolset(name: str, *, include_registry: bool = True) -> Optional[Dict]
def bundle_non_core_tools(toolset_name: str) -> Set[str]
def resolve_toolset(name: str, visited: Set[str] = None, *,
include_registry: bool = True) -> List[str] # ★ 带环检测
def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]
def _get_plugin_toolset_names() -> Set[str] # 插件提供的工具集
def _get_registry_toolset_aliases() -> Dict[str, str] # 别名
def get_all_toolsets() -> Dict[str, Dict[str, Any]]
def get_toolset_names() -> List[str]
def validate_toolset(name: str) -> bool
def create_custom_toolset(...)
def get_toolset_info(name: str) -> Dict[str, Any]
resolve_toolset 的 visited 参数是环检测:工具集 A 包含 B,B 又包含 A,会造成无限递归。用一个「已访问集合」防住。
bundle_non_core_tools(打包非核心工具)的存在说明:系统需要区分「核心工具」和「附加工具」 —— 大概是为了在计算 token 成本或做投放决策时区别对待。
三个来源的工具集
从函数名可以看出工具集有三个来源:
- 内置 ——
TOOLSETS字典里写死的 - 插件提供 ——
_get_plugin_toolset_names() - 注册表 ——
include_registry参数控制,还支持别名_get_registry_toolset_aliases()
4.3 工具的中心分发
Hermes 没有 Tool 类抽象,所有工具调用都进同一个函数:
def handle_function_call(...) # model_tools.py 第 1240 行
配套的注册表机制:
TOOL_TO_TOOLSET_MAP: Dict[str, str] = registry.get_tool_to_toolset_map()
TOOLSET_REQUIREMENTS: Dict[str, dict] = registry.get_toolset_requirements()
def get_all_tool_names() -> List[str]
def get_toolset_for_tool(tool_name: str) -> Optional[str]
def get_available_toolsets() -> Dict[str, dict]
def check_toolset_requirements() -> Dict[str, bool] # ★ 依赖检查
def check_tool_availability(quiet: bool = False) -> Tuple[List[str], List[dict]]
TOOLSET_REQUIREMENTS(工具集依赖要求)是一个重要机制:有些工具需要外部条件才能工作。
| 工具 | 依赖条件 |
|---|---|
| 智能家居工具 | 环境变量 HASS_TOKEN(Home Assistant 的访问令牌) |
| 计算机操作 | 安装了 cua-driver 驱动,而且只在 macOS 上 |
| 看板工具 | 环境变量 HERMES_KANBAN_TASK,或身份配置显式启用 |
| 浏览器工具 | 安装了对应的浏览器自动化后端 |
核心清单里注释提到这些是「通过 check_fn 控制」的 —— 也就是每个工具可以提供一个检查函数,运行时判断自己是否可用。不可用的工具不会进入模型的工具清单,从而不占用 token、也不会被调用后失败。
4.4 参数强制矫正层
这是 Hermes 特有的、而且非常实用的一层:
def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any] # 845 行
def _schema_accepts_kind(schema: Any, kind: str) -> bool # 953
def _normalize_json_strings_for_schema(value: Any, schema: Any) -> Any # 974
def _coerce_value(value: str, expected_type, schema: dict | None = None) # 1051
def _schema_allows_null(schema: dict | None) -> bool # 1080
def _coerce_json(value: str, expected_python_type: type) # 1104
def _coerce_number(value: str, integer_only: bool = False) # 1135
def _coerce_boolean(value: str) # 1153
def _canonicalize_tool_call_arguments(arg_str: str) -> str # 1293
hermes-agent/model_tools.py
为什么需要这一层
因为 Hermes 是不绑定模型供应商的。
强模型输出的工具参数类型基本可靠 —— 说要数字就给数字。但 Hermes 要支持 Qwen、DeepSeek、以及跑在用户本机的各种小模型。这些模型经常:
| 模型输出的 | 工具期望的 |
|---|---|
"true"(字符串) | true(布尔值) |
"5"(字符串) | 5(数字) |
"{\"a\": 1}"(JSON 字符串) | {"a": 1}(对象) |
"null"(字符串) | None |
| 带 Markdown 代码围栏的 JSON | 纯 JSON |
如果不矫正、直接按格式报错,弱模型上的工具调用成功率会崩塌 —— 而这些弱模型正是「本地部署、不花钱」这个卖点的基础。
这是「模型无关」的隐性成本。它不体现在架构图上,而体现为几百行防御性代码。
矫正是按 schema 进行的,不是盲目转换
注意函数签名:_coerce_value(value, expected_type, schema) —— 它接收目标 schema。所以矫正是有依据的:
_schema_accepts_kind(schema, kind)—— 先问「这个 schema 接受这种类型吗」_schema_allows_null(schema)—— 「允许空值吗」,决定要不要把"null"转成None_normalize_json_strings_for_schema(value, schema)—— 按 schema 递归处理嵌套结构里的 JSON 字符串
如果盲目转换(比如「所有看起来像数字的字符串都转成数字」),会造成新的 bug —— 比如一个本该是字符串的版本号 "1.20" 被转成数字 1.2。按 schema 判断就不会。
4.5 工具错误消息的净化
_TOOL_ERROR_ROLE_TAG_RE = re.compile(...) # 剥离伪造的角色标签
_TOOL_ERROR_FENCE_OPEN_RE = re.compile(r'^\s*```(?:json|xml|html|markdown)?\s*',
re.MULTILINE)
_TOOL_ERROR_FENCE_CLOSE_RE = re.compile(r'\s*```\s*$', re.MULTILINE)
_TOOL_ERROR_CDATA_RE = re.compile(r'<!\[CDATA\[.*?\]\]>', re.DOTALL)
def _sanitize_tool_error(error_msg: str) -> str: ...
这防的是什么攻击
四个正则各自处理一类载体:
| 正则 | 剥离什么 |
|---|---|
_TOOL_ERROR_ROLE_TAG_RE | 伪造的角色标签(</system>、<user> 之类) |
_TOOL_ERROR_FENCE_OPEN/CLOSE_RE | Markdown 代码围栏 —— 攻击者可以用它来「关闭」当前的代码块,让后面的文字被当成正文 |
_TOOL_ERROR_CDATA_RE | XML 的 CDATA 段 —— 另一种可以藏内容的结构 |
这类攻击面的共同特征是:它们走的是异常路径,所以正常的功能测试完全覆盖不到。你的测试会验证「工具成功时行为正确」,但很少验证「工具失败时的报错信息里有什么」。
值得在自己的项目里专门排查一遍:列出所有会把外部数据回灌进模型上下文的路径。工具执行结果、错误消息、日志内容、异常堆栈 —— 每一条都是潜在的注入入口。
4.6 工具结果的观测与钩子
def suppress_post_tool_call_hook() # 上下文管理器:临时禁用钩子
def _tool_result_observer_fields(...)
def _emit_post_tool_call_hook(...)
suppress_post_tool_call_hook()(抑制工具调用后钩子)是一个上下文管理器。它的用途是:某些内部的工具调用不该触发用户的钩子。
比如:智能体内部为了做压缩而调用某个工具,这不是用户任务的一部分,不该触发用户配置的「每次工具调用后记录一下」钩子 —— 否则日志会被内部操作淹没。
4.7 委派上下文识别
def _is_delegated_child_context() -> bool # 我是不是一个被委派的子智能体
def _is_dispatcher_owned_worker() -> bool # 我是不是调度器拥有的工人
def _get_tool_loop() # 获取工具执行的事件循环
def _get_worker_loop() # 获取工人的事件循环
def _run_async(coro) # 在正确的事件循环里跑协程
这一组函数处理的是 Python 异步编程的一个实际问题:子智能体可能跑在不同的事件循环里(甚至不同的线程里)。工具执行时必须找到正确的循环去调度协程,否则会抛「事件循环已关闭」或者死锁。
_is_delegated_child_context() 还有一个业务用途:子智能体需要知道自己是子智能体,从而应用不同的行为(比如自动审批策略,见第 10 章)。
4.8 工具定义的缓存
_TOOL_DEFS_CACHE_MAX = 8
def _clear_tool_defs_cache() -> None
def get_tool_definitions(...) # 第 323 行,带缓存
def _compute_tool_definitions(...) # 第 417 行,实际计算
def _resolve_active_context_length() -> int
工具定义(也就是发给模型的那份工具清单和参数说明)被缓存了,最多 8 份。
为什么需要多份?因为不同场景的工具集不同:主智能体一份、子智能体一份、看板工人一份、webhook 一份……而每份的计算涉及遍历所有工具、解析 schema、检查依赖条件,不便宜。
_resolve_active_context_length() 出现在这里说明:工具定义的生成可能和上下文窗口大小有关 —— 大概是在窗口较小时裁剪掉一些工具或简化描述。
4 · The Tool System
162 tool files, plus a 39 KB exposure-policy file. This chapter is about how tools are organized, how they are exposed, and how call arguments get coerced.
4.1 The Most Important Design Decision: Implementation Separated from Exposure
The tools/ directory holds 157 tools' “how to do it”; toolsets.py holds “in which situations they should be brought out.”
These two things are completely decoupled — this is the one Hermes design most worth lifting straight into your own project.
The core tool list
# The tool list shared by the CLI and every messaging platform.
# Change it here and every platform is updated at once.
_HERMES_CORE_TOOLS = [
# web
"web_search", "web_extract",
# terminal and process management
"terminal", "process",
# Note: desktop-GUI capabilities (read_terminal, open_preview, etc.)
# are deliberately not listed here, for the same reason as the project tools below:
# they only make sense where a graphical renderer can respond. They live in the desktop_ui
# toolset and are enabled solely by the GUI gateway for sessions whose SOURCE is the desktop app —
# never keyed on a process env var, which is blind to "a desktop client talking to a remote backend."
# file operations
"read_file", "write_file", "patch", "search_files",
# vision and image generation
"vision_analyze", "image_generate",
# skills
"skills_list", "skill_view", "skill_manage",
# browser automation
"browser_navigate", "browser_snapshot", "browser_click",
"browser_type", "browser_scroll", "browser_back",
"browser_press", "browser_get_images",
"browser_vision", "browser_console", "browser_cdp", "browser_dialog",
"browser_exec", # replaces the other browser tools when browser.backend is "browser-use"
# text-to-speech
"text_to_speech",
# planning and memory
"todo", "memory",
# session history search
"session_search",
# clarifying questions
"clarify",
# code execution and delegation
"execute_code", "delegate_task",
# scheduled tasks
"cronjob",
# smart home (enabled via check_fn, which looks for HASS_TOKEN)
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
# kanban multi-agent collaboration — only enters the schema when the agent was spawned
# as a kanban worker (HERMES_KANBAN_TASK env var set), or the current profile has explicitly
# enabled the kanban toolset. Controlled via check_fn.
"kanban_show", "kanban_list", "kanban_complete", "kanban_block",
"kanban_request_review", "kanban_request_changes", "kanban_heartbeat",
"kanban_comment", "kanban_create", "kanban_link", "kanban_unblock",
"kanban_attach", "kanban_attach_url", "kanban_attachments",
# computer use (macOS; check_fn verifies cua-driver is installed)
"computer_use",
]
hermes-agent/toolsets.py
The design principles those two comments reveal
That sentence in the comment is worth reading word for word:
“…enabled solely by the GUI gateway for a session whose SOURCE is the desktop app — never keyed on a process env var, which is blind to a desktop client talking to a remote/cloud backend.”
In plain terms: only the GUI gateway turns these on, and only for sessions that originated from the desktop app — never based on a process environment variable, because that can't see the case where “a desktop client is connected to a remote/cloud backend.”
Unpacking the trap: desktop-app tools (such as “open a preview window”) need a graphical UI to respond. The most intuitive check is an environment variable: “am I running in a desktop environment?”
But in Hermes's deployment shapes, the desktop client may be connected to an agent on a cloud server. That server has no GUI; the environment variable says “not a desktop environment” — yet the user really did send the message from the desktop app and really does need those tools.
The correct basis for the decision is “which entry point did this message come in through,” and only the gateway knows that.
Narrowing the trust boundary: the webhook toolset
# Webhook events may originate from untrusted third-party content (for example,
# pull request titles or comments on a public repository). The default webhook toolset is
# deliberately kept narrow so prompt injection can't trigger local file I/O or system command execution.
_HERMES_WEBHOOK_SAFE_TOOLS = [
"web_search", # web search (read-only)
"web_extract", # extract web page content (read-only)
"vision_analyze", # analyze images (read-only)
"clarify", # ask the user a question (no side effects)
]
Compare what got excluded from the core list: terminal (runs commands), write_file (writes files), patch (edits files), execute_code (runs code), delegate_task (spawns subagents) — every one of them has side effects.
What the four that remain have in common: even if the model is completely hijacked, the worst it can do is “search for some irrelevant things.”
This is the pattern of “configuring the tool surface by trust boundary.” Safety isn't achieved by writing “please don't run dangerous commands” in the prompt — the model cannot call a tool it doesn't know exists.
4.2 Composing and Resolving Toolsets
TOOLSETS = {
"web": {
"description": "Web research and content extraction tools",
"tools": ["web_search", "web_extract"],
"includes": [] # ★ can include other toolsets
},
"search": {
"description": "Web search only (no content extraction/scraping)",
"tools": ["web_search"],
"includes": []
},
"vision": { "tools": ["vision_analyze"], ... },
"video": { "description": "…(opt-in, not in the default toolsets)",
"tools": ["video_analyze"], ... },
"image_gen": { "tools": ["image_generate"], ... },
...
}
Each toolset has three fields: a description (for humans), a tool list, and other toolsets it includes.
The resolution functions
def get_toolset(name: str, *, include_registry: bool = True) -> Optional[Dict]
def bundle_non_core_tools(toolset_name: str) -> Set[str]
def resolve_toolset(name: str, visited: Set[str] = None, *,
include_registry: bool = True) -> List[str] # ★ with cycle detection
def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]
def _get_plugin_toolset_names() -> Set[str] # toolsets provided by plugins
def _get_registry_toolset_aliases() -> Dict[str, str] # aliases
def get_all_toolsets() -> Dict[str, Dict[str, Any]]
def get_toolset_names() -> List[str]
def validate_toolset(name: str) -> bool
def create_custom_toolset(...)
def get_toolset_info(name: str) -> Dict[str, Any]
The visited parameter of resolve_toolset is cycle detection: toolset A includes B, B includes A, infinite recursion. A “visited set” prevents it.
The existence of bundle_non_core_tools tells you that the system needs to distinguish “core tools” from “add-on tools” — presumably so they can be treated differently when computing token cost or making exposure decisions.
Toolsets from three sources
The function names show that toolsets come from three places:
- Built-in — hard-coded in the
TOOLSETSdictionary - Plugin-provided —
_get_plugin_toolset_names() - The registry — controlled by the
include_registryparameter, with alias support via_get_registry_toolset_aliases()
4.3 Central Tool Dispatch
Hermes has no Tool class abstraction; every tool call goes through the same function:
def handle_function_call(...) # model_tools.py, line 1240
The registry machinery that goes with it:
TOOL_TO_TOOLSET_MAP: Dict[str, str] = registry.get_tool_to_toolset_map()
TOOLSET_REQUIREMENTS: Dict[str, dict] = registry.get_toolset_requirements()
def get_all_tool_names() -> List[str]
def get_toolset_for_tool(tool_name: str) -> Optional[str]
def get_available_toolsets() -> Dict[str, dict]
def check_toolset_requirements() -> Dict[str, bool] # ★ dependency check
def check_tool_availability(quiet: bool = False) -> Tuple[List[str], List[dict]]
TOOLSET_REQUIREMENTS is an important mechanism: some tools need external conditions to work.
| Tool | Requirement |
|---|---|
| Smart home tools | The HASS_TOKEN environment variable (a Home Assistant access token) |
| Computer use | The cua-driver driver is installed, and only on macOS |
| Kanban tools | The HERMES_KANBAN_TASK environment variable, or explicitly enabled in the profile config |
| Browser tools | The matching browser-automation backend is installed |
The comments in the core list say these are “controlled via check_fn” — meaning each tool can supply a check function that decides at runtime whether it is available. Unavailable tools never enter the model's tool list, so they cost no tokens and can't be called only to fail.
4.4 The Argument Coercion Layer
This layer is unique to Hermes, and extremely practical:
def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any] # line 845
def _schema_accepts_kind(schema: Any, kind: str) -> bool # 953
def _normalize_json_strings_for_schema(value: Any, schema: Any) -> Any # 974
def _coerce_value(value: str, expected_type, schema: dict | None = None) # 1051
def _schema_allows_null(schema: dict | None) -> bool # 1080
def _coerce_json(value: str, expected_python_type: type) # 1104
def _coerce_number(value: str, integer_only: bool = False) # 1135
def _coerce_boolean(value: str) # 1153
def _canonicalize_tool_call_arguments(arg_str: str) -> str # 1293
hermes-agent/model_tools.py
Why this layer is needed
Because Hermes is not tied to a model provider.
Strong models emit tool arguments with mostly reliable types — ask for a number, get a number. But Hermes has to support Qwen, DeepSeek, and all sorts of small models running on the user's own machine. These models frequently produce:
| What the model emits | What the tool expects |
|---|---|
"true" (a string) | true (a boolean) |
"5" (a string) | 5 (a number) |
"{\"a\": 1}" (a JSON string) | {"a": 1} (an object) |
"null" (a string) | None |
| JSON wrapped in Markdown code fences | Bare JSON |
Without coercion — just a format error — the tool-call success rate on weak models collapses, and those weak models are exactly what underpins the “run locally, pay nothing” selling point.
This is the hidden cost of being “model-agnostic.” It doesn't show up on the architecture diagram; it shows up as a few hundred lines of defensive code.
Coercion follows the schema; it is not blind conversion
Look at the signature: _coerce_value(value, expected_type, schema) — it receives the target schema. So coercion has a basis:
_schema_accepts_kind(schema, kind)— first ask “does this schema accept this kind of value?”_schema_allows_null(schema)— “is null allowed?”, which decides whether"null"becomesNone_normalize_json_strings_for_schema(value, schema)— recursively handle JSON strings inside nested structures, guided by the schema
Blind conversion (say, “every string that looks like a number becomes a number”) would introduce new bugs — a version number "1.20" that should stay a string would become the number 1.2. Deciding by schema avoids that.
4.5 Sanitizing Tool Error Messages
_TOOL_ERROR_ROLE_TAG_RE = re.compile(...) # strip forged role tags
_TOOL_ERROR_FENCE_OPEN_RE = re.compile(r'^\s*```(?:json|xml|html|markdown)?\s*',
re.MULTILINE)
_TOOL_ERROR_FENCE_CLOSE_RE = re.compile(r'\s*```\s*$', re.MULTILINE)
_TOOL_ERROR_CDATA_RE = re.compile(r'<!\[CDATA\[.*?\]\]>', re.DOTALL)
def _sanitize_tool_error(error_msg: str) -> str: ...
What attack this defends against
The four regexes each handle one kind of carrier:
| Regex | What it strips |
|---|---|
_TOOL_ERROR_ROLE_TAG_RE | Forged role tags (</system>, <user>, and the like) |
_TOOL_ERROR_FENCE_OPEN/CLOSE_RE | Markdown code fences — an attacker can use one to “close” the current code block so the text that follows is treated as body text |
_TOOL_ERROR_CDATA_RE | XML CDATA sections — another structure content can hide in |
What this class of attack surface has in common: it travels the exception path, so normal functional tests never cover it. Your tests verify “the tool behaves correctly when it succeeds,” but rarely verify “what's in the error message when the tool fails.”
It's worth a dedicated sweep of your own project: list every path that feeds external data back into the model's context. Tool results, error messages, log contents, exception traces — each one is a potential injection entry point.
4.6 Observing Tool Results, and Hooks
def suppress_post_tool_call_hook() # context manager: temporarily disable the hook
def _tool_result_observer_fields(...)
def _emit_post_tool_call_hook(...)
suppress_post_tool_call_hook() is a context manager. Its purpose: certain internal tool calls should not fire the user's hooks.
For example: the agent calls some tool internally in order to do compaction. That isn't part of the user's task, and it shouldn't fire the user-configured “log something after every tool call” hook — otherwise the log drowns in internal operations.
4.7 Recognizing a Delegation Context
def _is_delegated_child_context() -> bool # am I a delegated subagent?
def _is_dispatcher_owned_worker() -> bool # am I a worker owned by the dispatcher?
def _get_tool_loop() # get the event loop for tool execution
def _get_worker_loop() # get the worker's event loop
def _run_async(coro) # run a coroutine on the correct event loop
This group of functions deals with a practical problem in Python async programming: subagents may run on different event loops (even different threads). When a tool executes, it has to find the right loop to schedule its coroutine on, or it throws “event loop is closed” or deadlocks.
_is_delegated_child_context() also has a business purpose: a subagent needs to know it is a subagent, so it can apply different behavior (an auto-approval policy, for instance; see Chapter 10).
4.8 Caching Tool Definitions
_TOOL_DEFS_CACHE_MAX = 8
def _clear_tool_defs_cache() -> None
def get_tool_definitions(...) # line 323, cached
def _compute_tool_definitions(...) # line 417, the actual computation
def _resolve_active_context_length() -> int
Tool definitions (the list of tools and parameter descriptions sent to the model) are cached, up to 8 copies.
Why multiple copies? Because different situations use different toolsets: one for the main agent, one for subagents, one for kanban workers, one for webhooks… and computing each one means walking every tool, parsing schemas, and checking requirements, which isn't cheap.
The appearance of _resolve_active_context_length() here suggests that generating tool definitions may depend on the size of the context window — presumably trimming some tools or shortening descriptions when the window is small.