跳到主要内容

工具系统:自定义工具 / MCP / HITL / 编排

30 秒导读: 模型只会"说话"——它能吐出一句"请调用 get_weather(city='东京')",但它自己碰不到任何真实函数。本章讲的就是 Upsonic 的"手脚":把模型说的那句话,可靠地落到你写的那个 Python 函数上,拿到结果再回填给模型。核心是四步流水线(归一化 → 注册 → 包裹 → 执行)加上三种把控制权交还给人的"暂停"。

本章聚焦工具子系统本身。整条运行管线(24 步)见 02-execution-pipeline.md;安全策略引擎(Policy/Rule/Action)见 05-safety-engine.md;Agent 与 Task 两个主抽象见 01-agent-and-task.md。这里只讲"工具"这一支。


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

一句话定义: 工具系统是一层"翻译 + 传送带"——把你的普通 Python 函数,翻译成模型能理解的 JSON 描述(schema),再在模型决定调用时,把参数传回函数、执行、拿结果。

它解决什么问题: 大语言模型本身是个"纯文本机器",它不能查数据库、不能发邮件、不能读文件。要让 agent 真的"干活",必须给它一组工具,并解决三件事:

  • 模型怎么知道有哪些工具、每个工具要什么参数?(→ 生成 schema)
  • 用户可以用多少种方式"给"工具?一个函数?一整个类?一个 MCP 服务器?另一个 agent?(→ 归一化)
  • 模型说要调用某工具后,谁去真正执行、执行前后要不要缓存/重试/暂停问人?(→ 执行闭环 + HITL)

用起来什么样: 最小的自定义工具就是一个带类型标注和 docstring 的函数,直接丢给 Agent(tools=[...]):

# 示意,非源码 —— 演示"一个函数如何变成 agent 的工具"
from upsonic import Agent, Task

def get_weather(city: str) -> str:
"""查询某个城市的当前天气。

Args:
city: 城市名,例如 "东京"
"""
return f"{city} 现在晴,26°C"

agent = Agent(model="openai/gpt-4o", tools=[get_weather])
agent.do(Task("东京天气怎么样?")) # 模型会自动决定调用 get_weather(city="东京")

你没写任何 schema、没做任何注册——框架从函数签名和 docstring 里把这些都推导出来了。重点看:类型标注 city: str、返回标注 -> str、docstring 三者缺一不可(下文 §3.2 会看到,缺了会直接报错)。

一句话直觉: 把工具系统想成一个"电话总机"。模型是打电话的人,它只知道分机号(工具名)和"该说什么"(参数)。总机(ToolManager)负责:开机时把所有分机登记造册(注册),接到呼叫时接通对应的真人(执行),必要时说一句"请稍等,我去问下负责人"(HITL 暂停)。


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

整个子系统由一个门面类 ToolManager 统领,它组合了 5 个各司其职的协作者。这是 v2 版本从旧 ToolProcessor 上重构出来的清晰分层——每个协作者只干一件事。

部件一句话职责:

部件干什么文件
ToolManager门面,把下面 5 个串起来,对 Agent 只暴露 register_tools / execute_tooltools/__init__.py:322
ToolNormalizer无状态类型分发:把 8 种输入形态统一成 Tool 对象tools/normalizer.py:50
ToolRegistry状态中心:存所有已注册工具、包裹后的可执行体、归属关系;负责级联删除tools/registry.py:24
ToolWrapper给每个 Tool 套上"9 面行为管线"(缓存/重试/超时/钩子/暂停检查)tools/execution.py:31
PauseHandler把三种"暂停异常"翻译成可序列化的 PausedToolCalltools/hitl.py:121
OrchestratorLifecycle管理 plan_and_execute 这个特殊编排工具的生老病死tools/orchestration.py:361

主线走一遍(高层,不进代码):

┌─────────────────── ToolManager ───────────────────┐
│ │
用户给的 tools=[...] │ ①归一化 ②注册 ③包裹 │
(函数/类/MCP/agent…) ──┼─▶ Normalizer ──▶ Registry ──▶ ToolWrapper.wrap │
│ │ 存 registered_tools │
│ │ 存 wrapped_tools ◀────包裹后的 │
│ ▼ async 闭包 │
模型输出一句 │ get_tool_definitions() │
tool call ───────┼─────────────▶ (给模型看的 JSON 描述) │
│ │
agent 决定执行 ─────┼─▶ ④execute_tool(name,args) ──▶ 取 wrapped 闭包 ──▶ 真函数
│ │ │
│ └─ 撞上暂停? ──▶ PauseHandler ──▶ 抛给上层 │
└────────────────────────────────────────────────────┘

怎么读这张图:左边是"开机建表"(①②③,在 Agent.__init__ 里就跑完),右边是"运行时接线"(④,每次模型要调用工具时跑一次)。注意 registered_tools(原始 Tool)和 wrapped_tools(套了行为管线的 async 闭包)是两份——给模型看的 schema 从前者来,真正执行走后者。

Agent 侧的入口方法很薄,基本都在转发给 ToolManager:

Agent 方法干什么位置
_register_agent_tools__init__ 时把 agent 级工具注册进 ToolManageragent/agent.py:1832
_setup_task_tools为单次 run 的 task 建一个独立 ToolManager(task 级工具)agent/agent.py:2036
get_tool_defs拿到所有工具的 ToolDefinition(喂给模型)agent/agent.py:2027
_resolve_tool_manager一个工具名到底归 agent 的还是 task 的 manageragent/agent.py:2118
_execute_tool_calls模型给的一批 tool call 的执行总控(串行/并行)agent/agent.py:2311

3. 核心原理(逐个机制,由浅入深)

3.1 Tool 数据模型与 @tool 装饰器

它要解决的小问题: 系统内部千奇百怪的工具(函数、MCP、agent)得有一个统一的内部表示,否则注册表和执行器就得到处写 if 类型

四个核心数据类,都在 tools/base.py:

角色位置
Tool所有工具的基类,持有 name/description/schema/metadata,抽象方法 execute()base.py:53
ToolKit"工具集"基类:用户继承它、用 @tool 标方法,就能批量提供多个工具base.py:169
ToolDefinition发给模型的那份描述(名字 + JSON schema + 描述),不含实现base.py:289
ToolResult一次执行的内部结果(内容 + 成功标志 + 耗时)base.py:321

ToolToolDefinition 的分工是关键:Tool 是"活的"(能执行、带 metrics),ToolDefinition 是"死的描述"(只给模型看)。ToolDefinition.defer 这个属性还标记了"这类工具的调用要被延迟/交还给外部"——当 kindexternalunapproved 时为真(base.py:314),这正是 HITL 的伏笔。

@tool 装饰器做什么? 出人意料地简单——它不做任何注册,只在函数对象上贴两个属性:

# tools/config.py:126 — _ToolDecorator.__call__ 的真实逻辑(节选)
setattr(func, '_upsonic_tool_config', self.config) # 挂上 ToolConfig
setattr(func, '_upsonic_is_tool', True) # 标记"这是工具"
return func

也就是说 @tool 只是把一份 ToolConfig(见下)"别"在函数上,真正的发现和注册留给后面的 ToolNormalizer。装饰器支持两种写法:裸用 @tool(默认配置)或 @tool(requires_confirmation=True)(带配置),由 tool() 工厂函数根据参数形态分派(config.py:141)。

ToolConfig 是行为开关的集散地(config.py:17),常用字段:

字段默认作用
requires_confirmationFalse执行前暂停,等用户点"同意"
requires_user_inputFalse暂停,向用户要某些字段的值
external_executionFalse这个工具由外部进程执行,框架只负责暂停
cache_results / cache_ttlFalse / None按参数缓存结果到 ~/.upsonic/cache
max_retries5失败重试次数(指数退避)
timeout30.0单次执行超时(秒)
stop_after_tool_callFalse这次调用后直接结束整个 run

有一条硬约束用 pydantic 校验器守着:三种 HITL 模式互斥,一个工具上不能同时开两个,否则实例化 ToolConfig 就抛错(config.py:110 _validate_hitl_mutual_exclusivity)。

3.2 从函数到 JSON schema

它要解决的小问题: 模型看不懂 Python 函数,它只认 JSON schema。谁把 def get_weather(city: str) -> str 变成 {"type":"object","properties":{"city":{"type":"string"}}, "required":["city"]}?

答案是 tools/schema.pyfunction_schema()(schema.py:121)。它借道 Pydantic 的内部 API,把函数签名当成一个"隐形 TypedDict"来生成校验器和 JSON schema。核心步骤是5 道校验,任何一道不过就抛 SchemaGenerationError:

函数 ──▶ ①能取到签名吗 ──▶ ②有 docstring 吗 ──▶ ③有返回标注吗
──▶ ④每个参数都有类型标注吗 ──▶ ⑤(可选)每个参数都有描述吗 ──▶ 生成 JSON schema

这解释了 §1 里"三件套缺一不可"的原因:docstring 缺失撞 ②,-> str 缺失撞 ③,city 没标类型撞 ④。docstring 还被 doc_descriptions() 解析(支持 google/numpy/sphinx 三种风格),把参数说明填进 schema 的 description 字段(schema.py:181)——所以你写的 Args: city: 城市名 会原样传给模型。

产物是一个 FunctionSchema 对象(schema.py:64),里面既有给模型的 json_schema,也有一个能真正校验入参的 validator,还有一个 call() 方法负责最终调用(同步函数会丢进 executor 避免阻塞事件循环,schema.py:91)。

一个易忽略的定制点:GenerateToolJsonSchema(schema.py:31)重写了 Pydantic 的 schema 生成器,顺手删掉了每个属性上没用的 title 字段(_named_required_fields_schema),让发给模型的 schema 更干净、省 token。

3.3 归一化:8 种输入 → 统一的 Tool

它要解决的小问题: Upsonic 允许用户用 8 种完全不同的东西当"工具"。执行器不想关心这些差异,所以要先有一步把它们全部碾平成 Tool

ToolNormalizer.normalize()(normalizer.py:59)是无状态的类型分发。它先按对象 id 去重(避免同一个对象注册两次,normalizer.py:78),再逐个 isinstance / inspect 判断类型,派给对应的 _process_*:

输入形态判断依据处理
已经是 Tool 子类isinstance(_, Tool)直接收下
内置工具AbstractBuiltinTool 实例跳过(走另一条路,见 §4.4)
MCP 配置/handlerurl/command 或是 MCPHandler_process_mcp_tool
普通函数 / 绑定方法inspect.isfunction/ismethod_process_function_tool
ToolKit 类或实例issubclass/isinstance(ToolKit)_process_toolkit
工具提供者get_tools() 且不是 ToolKit_process_tool_provider
agent 实例name 且有 do/do_async/agent_id_process_agent_tool
普通类实例兜底_process_class_tools(取所有公开方法)

产物是一个 NormalizationResult(normalizer.py:32)——它不光装工具,还记录了"谁拥有这些工具"的归属映射(哪个 MCP handler、哪个类实例产生了哪些工具名),供后面级联删除用。归一化器绝不碰注册表,它只描述"该加什么",由 ToolRegistry.add() 做原子合并。

_process_function_tool(normalizer.py:197)是最常走的路径,它调 §3.2 的 function_schema 生成 schema,包成 FunctionTool。这里还藏着一个 HITL 的巧思:如果工具开了 requires_confirmation,它会自动往描述里追加一段话,告诉模型"你直接调用就行、不要自己在文本里问用户确认,框架会暂停"(normalizer.py:225)。这解决了一个真实痛点——否则模型往往会"礼貌地"用文字问"我可以帮你删除吗?"而不是发起工具调用。

ToolKit 的处理(_process_toolkit,normalizer.py:277)是两阶段:先发现候选方法(默认取所有 @tool 标注的方法;若 use_async=True 则改取所有 async 方法),再套用配置优先级——toolkit 实例化时的默认值 > @tool 装饰器上的配置(_apply_toolkit_config_overrides,normalizer.py:454)。include_tools 是加法、exclude_tools 是最高优先级的减法。

3.4 注册与包裹:两份表 + 9 面行为管线

注册的入口是 ToolManager.register_tools()(__init__.py:338),它把 §3.3 的结果落进注册表,然后给每个新工具套一层包裹:

# tools/__init__.py:348 — register_tools 的核心三行(节选)
result = self.normalizer.normalize(tools, self.registry.raw_object_ids) # 归一化
new_tools = self.registry.add(result) # 原子合并进注册表
for name, tool_obj in new_tools.items():
self.registry.store_wrapped(name, self.wrapper.wrap(tool_obj)) # 逐个包裹

于是注册表里有两份东西:registered_tools(原始 Tool,用来出 schema)和 wrapped_tools(包裹后的 async 闭包,用来真执行)。

"9 面行为管线"是本节精华。 ToolWrapper.wrap()(execution.py:43)返回一个 async 闭包,它把一次工具执行包在 9 个"切面"里,顺序严格:

调用 wrapped(**args)

├─① KB 预热 若该工具属于某个 KnowledgeBase,先 setup_async()
├─② before 钩子 跑 config.tool_hooks.before
├─③ 暂停检查 requires_confirmation → 抛 ConfirmationPause
│ requires_user_input → 抛 UserInputPause ← 三选一,直接中断
│ external_execution → 抛 ExternalExecutionPause
├─④ 缓存命中? 命中就直接返回缓存,不执行
├─⑤ 重试循环 for attempt in range(max_retries+1):
│ asyncio.wait_for(tool.execute(), timeout) ← 超时/失败指数退避重试
├─⑥ 记录 metrics
├─⑦ 写缓存
├─⑧ show_result / after 钩子
└─⑨ stop_after_tool_call → 在结果里塞 _stop_execution 标记

注意③在④⑤之前:暂停是通过"抛异常"实现的,它在真正执行前就打断了流水线。这三种暂停异常(ConfirmationPause/UserInputPause/ExternalExecutionPause)会一路穿过重试循环(循环里专门 except 了它们并 raise,不当失败重试,execution.py:134),最终由 ToolManager.execute_tool 捕获。

还有一个容易看漏的细节:包裹后的闭包返回的不是裸结果,而是一个"信封字典" func_dict,真结果放在 func_dict["func"] 键下(execution.py:179),缓存命中放 func_cache,停机标记放 _stop_execution。这个信封会原样成为 ToolReturnPart 的 content,下游在 agent.py:2875 / agent.py:2888 检查 _stop_execution、并用 .get('func', ...) 取真值。

3.5 一次 tool call 的执行闭环

这是本章最该看懂的一条端到端路径:模型吐出一句 tool call,到结果回填给模型,中间到底发生了什么。

模型响应里有 ToolCallPart(s)


Agent._execute_tool_calls(tool_calls) agent.py:2311
│ ① 检查取消 / tool_call_limit
│ ② 按 ToolDefinition.sequential 分成 串行组 / 并行组
│ ③ 每个 call:_handle_output_tool_call 先看是不是结构化输出工具(final_result)
│ ④ 过 tool_policy_post 安全校验(见第5章)

_resolve_tool_manager(name) → 找到归属的 ToolManager agent.py:2118

ToolManager.execute_tool(name, args) __init__.py:370
│ ⑤ _validate_required_args:必填参数缺失?→ 直接返回错误串(常因模型被截断)
│ ⑥ 取 wrapped 闭包,await wrapped(**args) ← 进入 §3.4 的 9 面管线
│ ⑦ 撞上暂停异常 → PauseHandler.attach_paused_call → 重新抛出

返回 ToolResult(content=func_dict, success=…)

Agent 把它包成 ToolReturnPart,append 进 messages,回给模型下一轮

串行 vs 并行由每个工具的 ToolDefinition.sequential 决定(agent.py:2357):标了 sequential=True 的一个个来,其余的用 asyncio.gather 并发跑(agent.py:2594)。并行组里如果有任何一个抛了暂停异常,会把同类暂停合并成一个大异常再抛(agent.py:2627),这样"这一批里三个工具都要确认"能被一次性收集给用户。

必填参数校验(_validate_required_args,__init__.py:455)有个很实际的用意:当模型因为 max_tokens 太小被截断、漏掉了必填参数时,与其让函数抛 TypeError,不如返回一句人话——提示"响应可能被截断,请带齐参数重试",模型下一轮往往就能自愈。


4. 三类特殊工具

前面 §3 讲的是"普通函数工具"的主干。还有三类工具走的是特殊路径,它们是这套系统里工程含量最高的部分。

4.1 MCP 外部工具

MCP(Model Context Protocol)是什么: 一个开放协议,让你连上别人写好的"工具服务器"(文件系统、GitHub、数据库……),把它们的工具当成自己 agent 的工具。Upsonic 内建了 MCP 客户端。

用法就是把一个配置类丢进 tools:

# 示意,非源码 —— 连一个 stdio MCP 服务器
class FilesystemMCP:
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]

agent = Agent(model="openai/gpt-4o", tools=[FilesystemMCP])

连接的生命周期(MCPHandler,mcp.py:255)分两拍:

  • get_tools()(同步,mcp.py:617)在 agent 循环开始前调用——它开一个临时连接发现有哪些工具、缓存元数据、然后关掉。因为可能在已有事件循环里被调,它甚至会起一个后台线程跑异步发现(mcp.py:640)。
  • call_tool()(异步,mcp.py:671)在 agent 的事件循环里真正调用工具时,惰性重连并复用持久 session。

每个发现到的工具被包成 MCPTool(mcp.py:183)——它是个 Tool 子类,execute() 就是转发给 handler.call_tool()(mcp.py:250)。注意 MCP 工具的 schema 直接用服务器给的 inputSchema,不走 §3.2 的 Python 反射(因为函数根本不在本地)。

两道安全闸值得单独点出:

  • prepare_command()(mcp.py:83)在启动 stdio MCP 服务器前,拒绝命令里的 shell 元字符(& | ; \ $ ( )),并把可执行文件限制在一个白名单(python/node/uv/docker`…)。这是防命令注入。
  • 首次创建任何 MCPHandler 时会打一条一次性安全警告(_emit_mcp_security_warning,mcp.py:68):"只连你信任的 MCP 服务器,stdio 服务器会在你机器上跑任意进程"。

反向能力:把自己的 agent 变成 MCP 服务器。 Agent.as_mcp()(agent.py:4214)用 fastmcp 起一个服务器,暴露一个 do(task) 工具,把任务转发给这个 agent。于是你的 agent 可以被别的 MCP 客户端(比如 Claude Desktop)当工具调用——工具系统在这里是双向的。

4.2 Human-in-the-loop(HITL):让 agent 暂停等人

它要解决的小问题: 有些工具很危险(删库、转账)或缺信息(要用户填地址)。我们希望 agent 跑到这一步时停下来等人,而不是硬闯。

Upsonic 的实现优雅得反直觉:用抛异常来"暂停"。三种模式对应三种异常(都在 hitl.py):

模式触发配置异常语义
确认requires_confirmation=TrueConfirmationPause (hitl.py:100)停下,等用户点同意/拒绝
用户输入requires_user_input=TrueUserInputPause (hitl.py:108)停下,向用户要某些字段
外部执行external_execution=TrueExternalExecutionPause (hitl.py:92)停下,工具由外部系统执行、把结果塞回来

暂停流程(把 §3.4 的③和 §3.5 的⑦连起来看):

9面管线③检查到 requires_confirmation
│ raise ConfirmationPause() (execution.py:83,此时还是空异常)

ToolManager.execute_tool 捕获 (__init__.py:427)
│ PauseHandler.attach_paused_call(...) 给异常挂上一个 PausedToolCall
│ └─ 记下 tool_name / args / tool_call_id / 需要用户填的字段 schema
▼ re-raise
Agent._execute_tool_calls 捕获并向上抛 (agent.py:2472)

run 管线把它落成 RunStatus.paused + RunRequirement,返回给调用方

【 程序在这里真的停住,把控制权还给你的代码 】

PauseHandler.attach_paused_call()(hitl.py:126)是"翻译官":空的暂停异常进来,它按类型补齐一个 PausedToolCall(hitl.py:23)——一个可 to_dict()/from_dict() 序列化的对象,这样"暂停状态"能存进 storage、跨进程恢复。对 UserInputPause,它还会从函数签名反推出用户要填哪些字段(_build_user_input_schema_from_tool,user_input.py:42)。

续跑: 用户做完决定后,调 continue_run()(agent.py:4946)恢复。它内部走 _inject_hitl_results()(agent.py:4772),按 requirement 类型分别处理:

  • 确认通过 → _execute_confirmed_tool()(agent.py:4894)真正执行那个被暂停的工具。
  • 确认拒绝 → 直接注入一句"用户拒绝执行"当结果。
  • 用户输入 → _execute_user_input_tool()(agent.py:4903)把用户填的值和 agent 原来给的参数合并后执行。

这里有个精细处理:续跑时执行工具走的是 _execute_hitl_tool_directly()(agent.py:4847),它直接调底层函数、绕过 9 面管线的暂停检查,否则会又一次撞上 requires_confirmation 无限循环。

动态要输入: 除了静态在 @tool 上声明,还有 UserControlFlowTools(user_input.py:114)——它给 agent 一个 get_user_input 工具,让模型自己在运行时决定"我信息不够,得问用户"。它的实现就是被调用时抛 UserInputPause(user_input.py:204),复用同一套暂停机制。配套的一大段 _DEFAULT_INSTRUCTIONS 会注入系统提示,教模型"别在文本里问,直接调这个工具"。

4.3 工具编排:plan_and_execute

它要解决的小问题: 复杂任务需要"先想计划、再一步步执行、执行中根据结果调整"。与其让主 agent 循环里塞这套逻辑,不如做成一个特殊工具。

plan_and_execute(orchestration.py:104)是个"伪工具"——它本体只返回一句占位串,真正的活由 Orchestrator(orchestration.py:117)干。它的参数是一个结构化的 Thought(orchestration.py:49):包含 reasoning(推理)、plan(一串 PlanStep)、criticism(自我批评)。模型被要求先产出这个结构化思考,框架再照着 plan 逐步执行。

Orchestrator.execute()(orchestration.py:151)是一个带程序计数器的执行循环:逐个 PlanStep 调用对应工具;若开了 enable_reasoning_tool,每步后还会做一次分析,决定 continue_plan/revise_plan/final_answer(AnalysisResult,orchestration.py:33),支持中途改计划。

它的注册很特殊,由 OrchestratorLifecycle(orchestration.py:361)接管:当 plan_and_execute 被注册且 agent 开了 enable_thinking_tool 时,maybe_create()(orchestration.py:378)会wrapped_tools['plan_and_execute'] 这个键偷偷换成 orchestrator 的执行闭包,覆盖掉 §3.4 装的普通行为包裹。同时把注册表里所有其它工具的引用喂给 orchestrator,让它能在计划里调它们。工具增删时,update_context / maybe_discard 保持这份工具映射同步。

4.4 内置工具(builtin tools):不进 ToolManager 的一支

还有一类"内置工具"走完全不同的路。AbstractBuiltinTool(builtin_tools.py:64)家族(WebSearchToolCodeExecutionToolUrlContextToolImageGenerationTool…)描述的是模型提供商原生支持的工具——比如 Anthropic/OpenAI 自带的联网搜索。它们不需要本地执行,所以:

  • _register_agent_tools 里被单独挑出来,存进 agent_builtin_tools,不进 ToolManager(agent.py:1860)。
  • 作为 ModelRequestParameters.builtin_tools 直接透传给模型层,由 provider 自己执行(agent.py:2219)。

区分它们的是 _process_class_tools 之前的 _is_builtin_tool 判断(normalizer.py:150)。别把这些和 §4.1 的 MCP 混了:MCP 是你连的外部服务器,builtin 是模型厂商内建的能力。注意还有两个同名但不同物的东西——小写 WebSearch/WebRead(builtin_tools.py:510/545)是普通本地函数工具,走 §3 的正常管线。


5. 巧妙之处(可借鉴的技术)

  • 用"抛异常"实现"暂停"。 HITL 不需要在管线里塞状态机或回调地狱,一个 raise ConfirmationPause() 就能让执行流干净地一路冒泡到最外层、把控制权还给调用方(execution.py:82)。异常天然就是"逐层退栈",正好对应"暂停整条调用链"。

  • 两份表分离关注点。 registered_tools(给模型看)与 wrapped_tools(拿来执行)分开(registry.py:30-31),让"schema 生成"和"行为增强"互不干扰。编排器要偷换执行体时,只改 wrapped_tools 那一份就行(orchestration.py:421),模型看到的 schema 纹丝不动。

  • 归一化器无状态、注册表原子合并。 Normalizer 只产出一个描述性的 NormalizationResult,不碰任何全局状态(normalizer.py:71);Registry.add() 一次性合并(registry.py:89)。这让"注册"这件事可测试、可回滚、并发安全。

  • 级联删除的归属映射。 注册表记着"哪个 MCP handler / 哪个类实例产生了哪些工具"(registry.py:105-120),所以你 remove_tools(某个 ToolKit 实例) 时能把它带出来的所有工具一并清掉,还能正确处理"1 对多"部分删除(registry.py:171)。

  • 确认工具自动改写描述。 给工具开 requires_confirmation 会自动往描述里加"你直接调、别用文字问用户"(normalizer.py:225),从 prompt 层面纠正模型"过度礼貌"的坏习惯——这是行为工程,不是代码工程。

  • MCP 命令白名单 + 元字符黑名单。 prepare_command(mcp.py:83)用"白名单可执行文件 + 黑名单 shell 字符"双保险防注入,而不是简单地 shell=True 跑字符串。

6. 边界与局限

  • schema 生成强依赖 Pydantic 内部 API。 schema.py 开头就注明"用了大量 Pydantic 内部 API,对 Pydantic 版本变化很脆弱"(schema.py:1)。Pydantic 大版本升级可能直接打断工具 schema 生成。

  • 同步工具跑在线程池。 同步函数经 run_in_executor 丢进默认线程池执行(wrappers.py:99schema.py:91)。CPU 密集型同步工具会占用线程池,且不享受真正的并行(GIL)。

  • get_tools() 的事件循环体操。 MCP 同步发现在"已有运行中的事件循环"里要靠起后台线程绕过(mcp.py:634),这条路径脆弱,发现失败时直接返回空工具列表(mcp.py:662)——工具会"静默消失"。

  • HITL 三模式互斥。 一个工具不能既要确认又要用户输入(config.py:110)。要组合语义得拆成两个工具或用动态 get_user_input

  • func_dict 信封是隐式约定。 包裹后返回的是 {"func": 真值, ...} 字典而非裸值(execution.py:179),下游多处靠 .get('func') 取值。这个约定没有类型保护,新写消费方的人容易踩坑。

7. 横向对比

同一货架里,工具子系统的取舍各有不同:讲清"模型说的话如何落到真实目标"是这类框架的共同暗线。Upsonic 的特点是把"暂停"做成一等公民(三种 HITL 异常 + 可序列化续跑),并用一个统一的门面 ToolManager 把归一化/注册/执行/暂停/编排五件事拆成可替换的协作者。读者可对照本货架 index 里其它子库,看它们如何处理"工具注册与执行闭环"这一核心关切。

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

主题文件关键符号
工具数据模型src/upsonic/tools/base.pyToolToolKitToolDefinitionToolResultToolMetadata
@tool 装饰器与配置src/upsonic/tools/config.pytoolToolConfig_ToolDecorator_validate_hitl_mutual_exclusivity
函数 → JSON schemasrc/upsonic/tools/schema.pyfunction_schemaFunctionSchemaGenerateToolJsonSchema
8 种输入归一化src/upsonic/tools/normalizer.pyToolNormalizer.normalize_process_function_tool_process_toolkitNormalizationResult
注册表 / 级联删除src/upsonic/tools/registry.pyToolRegistryaddremoveall_definitionscollect_instructions
9 面行为管线src/upsonic/tools/execution.pyToolWrapper.wrap
门面 / 执行入口src/upsonic/tools/__init__.pyToolManagerregister_toolsexecute_tool_validate_required_args
函数/agent 工具包裹src/upsonic/tools/wrappers.pyFunctionToolAgentTool
HITL 暂停机制src/upsonic/tools/hitl.pyConfirmationPauseUserInputPauseExternalExecutionPausePausedToolCallPauseHandler.attach_paused_call
动态用户输入src/upsonic/tools/user_input.pyUserControlFlowToolsget_user_input_build_user_input_schema_from_tool
MCP 客户端/服务端src/upsonic/tools/mcp.pyMCPToolMCPHandlerget_toolscall_toolprepare_command
工具编排src/upsonic/tools/orchestration.pyplan_and_executeOrchestratorOrchestratorLifecycle
内置(厂商)工具src/upsonic/tools/builtin_tools.pyAbstractBuiltinToolWebSearchToolCodeExecutionTool
Agent 侧接线src/upsonic/agent/agent.py_register_agent_tools_setup_task_tools_resolve_tool_manager_execute_tool_calls_inject_hitl_resultscontinue_runas_mcp