数据截至 (上游 commit fc74d079a18c)
工具 与集成:@tool、MCP 冻结面与能力清单
30 秒导读: 大模型只会输出文字,顶多输出一段"我想调用
lookup_ticket(...)"的意图。 本章讲 v3 怎把这段意图精确、安全地变成一次真实调用,以及——更重要的——怎么保证 "现在允许调的工具"和"部署时允许调的工具"是同一批。三件套撑起这件事: 内容哈希冻结的工具面、按契约分级的行为约束、deny-by-default 的能力清单。
本章是 全景 里"手脚"那一支。IR 与契约的数据结构见
01;callTool activity 在执行引擎里的位置见
02;在 @flow 里怎么写工具步见
03。
旧版对照(已移除): v1 的四类工具分发(function/integration/api_call/system)、 integrations-service 微服务(16 个 provider 注册表)、
run_llm_with_tools循环、 feature flag 灰度全部不存在。v3 只有两种工具引用 + 一个统一调用面。
1. 这是什么(零基础也能懂)
先建立一个直觉
tool calling(工具调用)的三步老规矩:告诉模 型有哪些工具 → 模型回一段结构化意图 → 你的系统真的去执行。第 3 步看着简单,坑最深:
- 工具的 schema 半夜被上游改了,流程还按老 schema 调,炸。
- "只读"的工具其实有副作用,重试一次重复扣款。
- 模型调了一个你从没授权的工具。
- 密钥散落在每次调用的参数里,进了日志。
v3 对这四个坑各有一个机制:冻结(freeze)、契约(contract)、清单(manifest)、 秘密(secrets)。本章按"一次工具调用的一生"讲它们在哪起作用。
两种工具引用
IR 里 ToolRef 只有两种(julep/ir.py:61/:72):
| 引用 | 是什么 | 怎么调 |
|---|---|---|
NativeTool(name) | 自家的 HTTP 工具(Cloud Run/Lambda) | callTool 直接 POST WorkerContext.tool_urls[name] |
McpTool(server, tool) | 来自某个 MCP 服务器的工具 | callTool 经注入的 MCP caller 调 |
用起来什么样
# 示意,非源码:定义、声明引用、在 @flow 里调用
@tool(effect="read", idempotent=True) # 契约:只读 + 幂等(julep/agent.py:354)
def lookup_ticket(ticket: str) -> dict[str, str]: ...
episodes = mcp_tool("episodes", "get_episode") # MCP 引用(julep/mcp_step.py:33)
@flow
def triage(ticket: str):
hit = lookup_ticket(ticket, retries=2) # native 步
raw = episodes(episode_id=hit["id"]) # MCP 步(handle kwargs 直接构成输入记录)
...
deployment = deploy(triage, tools=[lookup_ticket], ...) # 冻结面(见 §3)
2. 顶层全景:一次工具调用的一生
怎么读这张图: 从左到右是时间轴。上排是编译期(冻结),下排是运行期(每次调用)。 每个菱形是一次校验。
编译期(部署一次)
─────────────────────────────────────────────
@tool / mcp_tool 引用 ─▶ freeze(julep/freeze.py:504)
│ 工具 schema → definition_hash
│ 回填 CallStep.frozen_hash
▼
deploy 五道门(julep/deploy.py:904)
能力执行:引用必须被 manifest 授予
审批闸:dangerous 工具必须被人闸支配
race 准入:竞速分支必须只读/断言幂等
─────────────────────────────────────────────
运行期(每次调用)
interpret ─▶ PRIM(CallStep) ─▶ callTool activity(julep/execution/effects.py:909)
│ MCP:冻结 schema 校验输入 → MCP caller(preflight 已保证面没漂)
│ native:网络出网域校验 → POST {input,cache} + Idempotency-Key: cid
▼
轨迹捕获(尽力而为,不影响结果)
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
@tool 装饰器 | 把函数包成 Tool,声明 effect/idempotent,从类型注解推 schema | julep/agent.py:354(tool)/:228(Tool) |
mcp_tool | 声明一个 MCP 工具引用(schema 由冻结快照提供) | julep/mcp_step.py:33 |
freeze | 快照 → 内容哈希清单,绑定每个 call | julep/freeze.py:504 |
CapabilityManifest | 允许清单:工具/reasoner/作用域/服务器/预算 | julep/capabilities.py:85 |
callTool | 统一调用 activity(MCP 或 HTTP) | julep/execution/effects.py:909 |
| MCP preflight | 起跑前校验冻结面 vs 现场面 | julep/execution/mcp_preflight.py |
mcp_auth | MCP 传输/鉴权,secret:// 头引用 | julep/mcp_auth.py:803 |
| skills | 渐进披露的指令块(SKILL.md) | julep/skills.py:60 |
3. 核心机制一:冻结——工具面按内容哈希钉死
它要解决的小问题: "流程引用的工具"和"实际存在的工具"必须一致,否则部署物是空头支票。
思路: 部署时把每个引用工具的身份 + schema 算成 definition_hash
(julep/contracts.py:148),存进 ToolManifest,并把这个哈希回填到 IR 里每个
CallStep.frozen_hash(julep/freeze.py:504)。运行期 bind(julep/freeze.py:620)
按哈希查回真实工具定义。
快照有两个来源(julep/freeze.py):
McpToolSpec(:70)——真实调用 MCPtools/list拿到的 schema;NativeToolSpec(:89)——本机注册的@tool(schema 从类型注解推导,julep/agent.py:180_schema_from_hints)。
README.md:78 说的"contract comes from the frozen MCP snapshot"就是这条路:
@flow 里写 mcp_tool(server, tool) 时不需要任何网络,部署时才解析、冻结。
冻结时机是显式的缝(julep/deploy.py:1 docstring):默认 deploy_time(一次冻结,
最大确定性);工具面会在部署之间漂移的场景用 per_run + Deployment.refresh
(julep/deploy.py:615)对新鲜快照重冻结。
4. 核心机制二:契约与重试——"未断言即保守"
它要解决的小问题: 这个工具能不能重试?能不能放进 race?没人说得清时怎么办?
思路: 每个工具带一份 ToolContract(julep/contracts.py:22)= effect(对世界做了
什么)+ idempotency(重复安全吗)。拿不到可信信息时一律保守:CONSERVATIVE_DEFAULT
(julep/contracts.py:40)按 write + none 处理——于是:
- 重试:只有"断言过契约"且契约允许的调用才按
Ann.max_attempts重试 (julep/execution/interpreter.py:100起,详见第 02 章 §4); - race:
race/hedge/quorum的分支必须只读或断言幂等(julep/derived.py:324); - 审批:
dangerous/清单点名的工具必须被人闸支配(check_approval_gates,julep/capabilities.py:436)。
断言的唯一受信来源是能力清单。 MCP 工具自己的注释性提示按规范不可信,
contract_from_annotations(julep/contracts.py:111)没看到明说就塌缩到保守默认;
只有人写的 CapabilityManifest.overrides(julep/capabilities.py:214)能把某工具
"断言"成 read/native(julep/contracts.py:1 docstring)。
@tool 装饰器(julep/agent.py:354)让你在源码里就地声明契约:
# 真实签名(julep/agent.py:354 起)
def tool(fn=None, /, *, effect: str = "write", idempotent: bool = False, name=None): ...
注意默认值就是保守面:effect="write"、idempotent=False——不写注解 = 承认自己
不可盲目重试。
5. 核心机制三:能力清单——deny-by-default 的允许面
CapabilityManifest(julep/capabilities.py:85)是人写的合同:"这个部署允许碰什么"。
允许面覆盖五类:工具(ToolGrant,julep/capabilities.py:63,含 maxCalls 次数上限)、
reasoner、上下文作用域、MCP 服务器、总预算(Budget :49)。
语义:缺段 = 不约束;出现段 = 白名单。(julep/capabilities.py:1 docstring)
——不列 reasoners 谁都行;列了两个,第三个被拒。
它在三个缝上被执行(同 docstring):
| 缝 | 检查什么 | 在哪 |
|---|---|---|
| 编译 | 每个引用的工具/reasoner/ 作用域/服务器必须被授予 | deploy() 的能力执行门(julep/deploy.py:904) |
| 调度 | 预算暴露给 agent 守卫与计划估价 | julep/agent_loop.py:585 |
| 运行 | 网络出网域 + 模型允许清单,在 activity 内查 | callTool 里 capabilities.network_allows(julep/capabilities.py:351) |
运行期那道很实在:callTool 对 native 工具先取 URL 域名,network_allows 不放行就
抛 CapabilityDenied(julep/execution/effects.py:909 起)——清单不是纸面约束。
6. 核心机制四:调用落地——MCP caller 与幂等键
callTool(julep/execution/effects.py:909)的运行期逻辑分两路:
MCP 路:先按冻结 schema 校验输入(不合法抛 ToolInputValidation,这一步防"模型
编造的字段"直接打到服务器),再调注入的 _CTX.mcp_call(server, tool, value, cid, principal, secrets, input_schema_validated)。MCP caller 的鉴权由 mcp_auth 处理:
支持 secret://name 形式的整串头引用——值只在加密 Temporal 载荷里旅行,不进存储、
不进投影(julep/mcp_auth.py:31 的 _SECRET_REF 与 README.md 的 "Production secrets
and MCP safety" 节);transport_for_mcp_caller(julep/mcp_auth.py:803)为流式 caller
构造传输。
native 路:查 WorkerContext.tool_urls(julep/execution/effects.py:106)拿 URL →
网络域校验 → HTTP POST。两个值得记住的头:
Idempotency-Key: cid——激活 id 即幂等键。服务端兑现这个键,重试才敢发生; 它和投影事件、人闸等待键共用同一个确定性计数(第 02 章 §9)。- principal 头(可选)——把租户/凭据引用传给工具服务,是引用不是值。
7. 核心机制五:preflight——起跑前防面漂移
它要解决的小问题: 部署时冻结的面,起跑时可能已经变了(上游 MCP 加了字段、删了工具)。 跑一半发现工具没了是灾难;带上 secrets 跑更是灾难。
思路: run 启动时(第一次效果发生前)做一次 MCP preflight,把冻结清单与新鲜的
tools/list 对比。策略三档 McpSurfacePolicy(julep/mcp_surface.py:18):
| 档 | 行为 |
|---|---|
pin(新默认) | 每个工具的 definition_hash 必须逐字节一致 |
names | 只查工具名集合,不比 schema |
off | 跳过(逃生门) |
对比逻辑在 compare_mcp_surface(julep/mcp_surface.py:150),产出
McpSurfaceMismatch 明细(frozen hash vs fresh hash vs diff);assert_mcp_surface
(:213)把 mismatch 变成类型化错误。Temporal 侧包装成 activity preflightMcp
(julep/execution/harness.py:217,30 秒 TTL 的进程内缓存,julep/execution/mcp_preflight.py:28
起)。带 run secrets 的 run 强制要求已完成 preflight 的绑定状态,否则工作流直接拒绝
(julep/execution/harness.py:1496 起)。README.md 的说法:"mid-run tool removal or
post-validation schema rejection fails terminally as typed surface drift"。
8. 核心机制六:agent skills——渐进披露的指令块
Skill(julep/skills.py:60)是 dotctx 包旁边 skills/<name>/SKILL.md 的一块可复用
指令:YAML frontmatter(name/description)+ markdown 正文。设计有三个点:
- 显式激活:reasoner 的
skills:设置点名才生效,目录存在本身不改变任何行为 (julep/skills.py:1docstring)。 - 渐进披露:系统提示只带名字+描述;正文等模型通过保留工具
__load_skill__(SKILL_TOOL,julep/skills.py:39)要的时候才给,且从冻结 release 解析,不读 文件系统——重放确定性。 - 内容寻址:
skill/<name>@v<hash12>,字节级相同的技能跨包收敛为一个注册项和部署物里 的一份拷贝;改一个字就是新的 key——于是Reasoner.skills持有这些 key,改技能 = 改 reasoner 身份(同 docstring)。
9. 把它们串起来:一次带审批的调用
```text
# 示意,非源码:一个 dangerous 工具从定义到落地
@tool(effect="dangerous", idempotent=False)
def refund(charge_id: str) -> dict: ...
CapabilityManifest(tools=[ToolGrant(key="refund", approval=True)])
@flow
def refund_flow(charge_id: str):
ok = human_gate(prompt="approve refund?") # 人闸(部署门要求:dangerous 必须被它支配)
if_side = ...
return refund(charge_id) # 经 cond(ok) 分支后调用
```
部署时:freeze 把 refund 钉到哈希;能力门确认 refund 被授予;审批门确认调用路径上有
__human_gate__ 支配。运行时:gate 挂在 openGates 等人;人点了 submitHuman;
cond 放行;callTool 发 POST,带 Idempotency-Key;效果落轨迹。五道防线各管一段,
没有一道是"事后审计"。
10. 巧妙之处(可借鉴的技术)
-
"未断言即保守"的默认面。 外部注解不可信就全部塌缩成 write+none——宁可让用户 多写一行断言,不给静默危险留门。见
julep/contracts.py:40。 -
cid 一钥三用。 同一个确定性激活 id 兼任投影事件标识、HTTP 幂等键、人闸等待键, 观测/幂等/人机交互用一根线串起。见
julep/execution/effects.py:909。 -
冻结时机做成显式参数。 deploy_time 换确定性、per_run 换新鲜度,由部署者按工具面 是否会漂移来选——不假装两种需求不存在。见
julep/deploy.py:615。 -
preflight 三档策略 + 强制绑定。 默认 pin 到哈希,给 names/off 逃生门;但只要你带 run secrets,就没有"关掉 preflight"这个选项。见
julep/mcp_surface.py:18、julep/execution/harness.py:1496。 -
secret:// 整串头引用。 密钥值只在加密载荷里存在,存储/投影/轨迹一律脱敏; 失败信息也要过 redactor(
scrub_mcp_preflight_failure,julep/execution/mcp_preflight.py:53)。 -
技能内容寻址进 reasoner 身份。 提示词资产的版本管理与代码同等待遇:改一个字, 身份变,部署物变。见
julep/skills.py:1docstring。
11. 边界与局限(诚实)
- 没有 v1 式的"现成集成货架"。 v1 的 integrations-service 内置 16 个 provider (wikipedia/brave/email/browserbase…),v3 主干没有等价物——外部能力一律走 MCP (或自己写 HTTP 工具服务)。想要"开箱即用的浏览器/搜索集成"得另接 MCP 服务器。
- native 工具要求你自己托管 HTTP 服务。
@tool函数在 dry_run/本地跑是真的 Python 调用,但生产部署物里 native 引用指向WorkerContext.tool_urls——工具的 生产形态是一个 HTTP 端点。 - MCP caller 是注 入的,不是内建的。
julep[mcp]extra 提供 SDK 支持,但具体 caller (传输、鉴权、重试栈)由 worker 配置注入(julep/execution/effects.py:106的WorkerContext.mcp_call)。 - 能力清单不约束 MCP 服务器内部。 清单管"能连哪些 server、调哪些工具",工具自己 再调了什么外部系统,框架看不见(那是 server 运维者的责任边界)。
12. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| @tool 装饰器 / Tool | julep/agent.py:354 | tool / Tool(:228) / _schema_from_hints(:180) |
| 类型注解 → JSON Schema | julep/agent.py:137 | python_type_to_schema |
| 工具引用 | julep/ir.py:61 | NativeTool / McpTool(:72) / toolref_key(:94) |
| MCP 引用步 | julep/mcp_step.py:33 | mcp_tool / McpToolStep(:14) |
| 工具契约 | julep/contracts.py:22 | ToolContract / CONSERVATIVE_DEFAULT(:40) |
| 注解 → 契约(保守) | julep/contracts.py:111 | contract_from_annotations |
| 定义哈希 / 冻结工具 | julep/contracts.py:148 | definition_hash / FrozenTool(:201) / execution_hash(:185) |
| 冻结管线 | julep/freeze.py:504 | freeze / McpToolSpec(:70) / NativeToolSpec(:89) / bind(:620) |
| 从工具构快照 | julep/agent.py:383 | snapshot_from_tools |
| 能力清单 | julep/capabilities.py:85 | CapabilityManifest / ToolGrant(:63) / Budget(:49) / overrides(:214) |
| 审批闸 | julep/capabilities.py:436 | check_approval_gates |
| 网络出网白名单 | julep/capabilities.py:351 | network_allows |
| 统一调用 activity | julep/execution/effects.py:909 | callTool |
| worker 上下文(注入面) | julep/execution/effects.py:106 | WorkerContext / configure(:430) |
| MCP preflight(后端中立) | julep/execution/mcp_preflight.py:105 | preflight_mcp / McpPreflightError(:40) |
| preflight activity | julep/execution/harness.py:217 | preflightMcp |
| 面对比/断言 | julep/mcp_surface.py:150 | McpSurfacePolicy(:18) / compare_mcp_surface / assert_mcp_surface(:213) |
| MCP 鉴权/secret 头 | julep/mcp_auth.py:803 | transport_for_mcp_caller / _SECRET_REF(:31) |
| 密钥库/脱敏 | julep/secrets.py:400 | operator_secret_redactor / scrubber_for_values(:343) |
| 技能 | julep/skills.py:60 | Skill / SKILL_TOOL(:39) / load_package_skills(:168) |
| 人闸组合子 | julep/derived.py:221 | human_gate |
| race 准入 | julep/derived.py:324 | check_race_admission |