工具契约与截断防护Tool contracts and truncation defence
先说问题The problem
模型输出被截断,tool call 的 JSON 少了一半。你的框架「好心」把它修成合法 JSON,然后真的执行了。一次半截的 write_file 变成了真实的文件覆盖。这是社区里最危险的事故形状。
The model output is truncated and the tool-call JSON loses half its body. Your framework kindly repairs it into valid JSON and executes it. A half-written write_file becomes a real overwrite. This is the most dangerous failure shape reported in the wild.
先动手Do it first
先在页面上接线——页面只懂规则。然后跑下面那条判题,那才是真会失败的东西。Wire it on the page — the bench only knows the rules. Then run the check below; that is the part that can actually fail.
extra_agent_tools=
AgentToolFactory
选填optional
给服务构造的每个 agent 追加自定义工具,业务能力从这里进来。Adds your own tools to every agent the service builds. This is where business capability enters.
create_app(…)
为什么这样设计Why it is built this way
01工具从 extra_agent_tools 进来Tools enter through extra_agent_tools
服务给每个 agent 装配工具集的入口是 AgentToolFactory。业务能力的边界在这里,不在提示词里。
The service hands tools to every agent through an AgentToolFactory. Your business capability's boundary lives here, not in a prompt.
02参数解析失败必须是数据A bad argument must come back as data
ToolCallBlock 带 state;解析不出合法参数时停在 PENDING,而不是拿一个补全过的 dict 去调用真副作用。
ToolCallBlock carries a state. When arguments will not parse it stays PENDING, instead of calling a real side effect with a completed dict.
03工具名要能审计Tool names have to be auditable
每个工具一个动词 + 一个资源;is_read_only 决定它能不能被权限引擎放行。「万能工具」让所有闸门形同虚设。
One verb and one resource per tool; is_read_only decides whether the permission engine may let it through. A catch-all tool makes every gate decorative.
用到的 APIAPIs in play
代码The code
async def refund_order(order_id: str, amount_cents: int, idempotency_key: str) -> ToolChunk:
"""Refund one order. The idempotency key is required: a replay must not double-pay."""
if not idempotency_key.strip():
return ToolChunk(content=[TextBlock(type="text", text="idempotency_key required")],
state=ToolResultState.ERROR)
...
return ToolChunk(content=[TextBlock(type="text", text="refunded")],
state=ToolResultState.SUCCESS)
toolkit = Toolkit(tools=[FunctionTool(refund_order, is_read_only=False)])
# await toolkit.add_tool(...) is the other entry point — there is no toolkit.register().
# Returning a ToolResponse (or anything that is not a ToolChunk) does not work: the
# toolkit either str()-ifies it into a nested repr or raises
# "The tool function must return a ToolChunk object".
判题The check
python lab.py run c3
它断言了什么What it asserts
schema requires order_id/amount_cents/idempotency_keycomplete call -> one ledger rowtruncated json -> state=error, ledger rows unchangedmissing idempotency_key -> refused, not defaultedthe lenient tool accepted the same shape: ledger now holds 2 rowssame key replayed -> refunded_cents 0, idempotent_replay trueread-only flags separate the readers from the moversfactory binds the tenant: lookup_order/read_policy/refund_order/write_back_note
会踩的坑Where it hurts
在工具里做「宽容解析」(缺字段就补默认值)等于把截断变成 mutation。缺字段要报错回去,让模型重试,而不是自己猜。
Lenient parsing inside a tool — filling defaults for missing fields — converts a truncation into a mutation. Error back so the model retries; never guess.
费曼闸门Say it back
为什么「修好半截 JSON 再执行」比「直接失败」危险得多?用一句能被反驳的话说清代价。
Why is repairing half a JSON and executing far more dangerous than failing outright? State the cost in one falsifiable sentence.
页面只存通行码的哈希。判题没真跑过,这里就解不开。The page stores a hash, not the code. Nothing unlocks until the command really ran.