通过工具调用提交结构化结果
用专门的结果提交工具接收函数分析,看清参数校验发生在哪一步、如何回填错误让模型重试,并控制重复接收。
通过工具调用提交结构化结果
从说明文字中解析 JSON 之外,还可以给智能体一个专门的结果提交工具。模型把分析结果放进工具参数,运行时验证参数,程序在执行器里接收数据。
本篇沿用Schema 校验的 schema.ts,以及第一篇的 connection.ts 和 lesson.ts。目的是明确"已提交的结果"在哪里产生,不是让模型执行文件写入或其他外部操作。
模型提交工具调用时,参数校验在哪一步
这是本篇最需要说清楚的问题。答案分两个阶段:
第一阶段——服务端生成时的校验。 模型生成工具参数时,服务端会尽量保证它符合工具声明的 schema。Pi 通过 constrainedSampling 请求这个行为(见下)。但这是服务端行为,不是保证。
第二阶段——执行前的本地校验。 这一步在 Agent Core 里做,位置是 packages/agent/src/agent-loop.ts 的 prepareToolCall():
async function prepareToolCall(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
toolCall: AgentToolCall,
config: ToolCallHooks,
signal: AbortSignal | undefined,
tools: readonly AgentTool<any>[] = currentContext.tools ?? [],
): Promise<PreparedToolCall | ImmediateToolCallOutcome> {
const tool = tools.find((t) => t.name === toolCall.name);
if (!tool) {
return {
kind: "immediate",
result: createErrorToolResult(`Tool ${toolCall.name} not found`),
isError: true,
};
}
try {
const preparedToolCall = prepareToolCallArguments(tool, toolCall);
const validatedArgs = validateToolArguments(tool, preparedToolCall);
if (config.beforeToolCall) {
// ... 权限钩子 ...
}
return { kind: "prepared", toolCall, tool, args: validatedArgs };
} catch (error) {
return {
kind: "immediate",
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
isError: true,
};
}
}这条路径的顺序是固定的:
找到工具 → prepareArguments 垫片 → validateToolArguments 校验 → beforeToolCall 钩子 → 执行三个观察点:
- 找不到工具就直接返回错误结果,不进入执行。错误消息是
Tool xxx not found。 prepareArguments在校验之前。它是"原始参数"到"待校验参数"的转换层。- 校验失败被捕获,转成错误工具结果。
execute()根本不会被调用。
关键结论:execute() 拿到的参数一定是校验(且转换)过的。beforeToolCall 钩子里的 args 同样如此。
prepareArguments 垫片
prepareArguments 是 AgentTool 上的可选钩子:
interface AgentTool<TParameters extends object = object, TDetails = any> extends Tool<TParameters> {
/**
* Optional compatibility shim for raw tool-call arguments before schema validation.
* Must return an object that matches `TParameters`.
*/
prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;
// ...
}它的用途是在 schema 校验之前做参数形状的兼容处理。典型场景是模型还在用旧参数名,或者参数结构需要先规整:
const tool: AgentTool<typeof parameters> = {
name: "read_file",
label: "Read",
description: "读取文件",
parameters: obj({ path: str() }),
// 模型可能仍发旧参数名 filePath
prepareArguments: (args) => {
const raw = (args ?? {}) as Record<string, unknown>;
return { path: (raw.path ?? raw.filePath) as string };
},
async execute(_id, params) {
return { content: [{ type: "text", text: String(params.path) }], details: {} };
},
};实现代码:
function prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {
if (!tool.prepareArguments) {
return toolCall;
}
const preparedArguments = tool.prepareArguments(toolCall.arguments);
if (preparedArguments === toolCall.arguments) {
return toolCall;
}
return {
...toolCall,
arguments: preparedArguments as Record<string, any>,
};
}两个细节:
- 返回同一个引用时直接透传,不做无谓的拷贝。
- 改写后的参数只用于校验和执行,转录里保留模型原始发的内容。可以实测:
转录中的 toolCall 参数: {"filePath":"a.txt"}
工具收到的参数: {"path":"a.txt"}这个差别在审计和排查时很有用——你能看到模型实际发了什么。
prepareArguments 是兼容层,不是校验层。它应该只做形状规整(改名、补默认值),不要在这里做业务规则判断——业务规则应该在 execute() 里,或者干脆用 schema 表达。
把接收动作定义成工具
创建 src/submit-result.ts:
import { Agent, type AgentTool } from "@earendil-works/pi-agent-core"
import { createConnection } from "./connection.js"
import { sourceCode } from "./lesson.js"
import { analysisSchema, type FunctionAnalysis, validateAnalysis } from "./schema.js"
export function createSubmissionTool(onAccepted: (value: FunctionAnalysis) => void) {
let accepted = false
const tool: AgentTool<typeof analysisSchema> = {
name: "submit_analysis",
label: "提交函数分析",
description: "提交当前练习函数的最终分析结果,每次任务只接受一份有效结果。",
parameters: analysisSchema,
constrainedSampling: { type: "json_schema", strict: "prefer" },
async execute(_id, params) {
if (accepted) {
throw new Error("已经接受过一份结果")
}
const analysis = validateAnalysis(params)
onAccepted(analysis)
accepted = true
return {
content: [{ type: "text", text: "结果已通过校验并接收。" }],
details: { accepted: true },
terminate: true,
}
},
}
return tool
}
export async function runSubmission() {
const { models, model } = createConnection()
let submitted: FunctionAnalysis | undefined
const tool = createSubmissionTool((value) => {
submitted = value
})
const agent = new Agent({
initialState: {
systemPrompt: "必须调用 submit_analysis 提交最终结果,不以说明文字代替提交。",
model,
tools: [tool],
},
streamFn: models.streamSimple.bind(models),
toolExecution: "sequential",
})
let turns = 0
agent.finishTurn = async () => {
turns += 1
if (turns >= 4) {
return { action: "end" }
}
}
const timer = setTimeout(() => agent.abort(), 30_000)
try {
await agent.prompt(
`请调用 submit_analysis 提交函数分析,包含源码原文证据,不确定的空输入行为用 null。代码:\n${sourceCode}`,
)
const last = agent.state.messages.findLast((item) => item.role === "assistant")
if (
last?.role === "assistant" &&
(last.stopReason === "error" || last.stopReason === "aborted")
) {
throw new Error(last.errorMessage || last.stopReason)
}
if (!submitted) {
throw new Error("没有收到通过校验的工具提交结果")
}
console.log(JSON.stringify(submitted, null, 2))
} finally {
clearTimeout(timer)
agent.abort()
await agent.waitForIdle()
}
}再创建入口 src/submit-main.ts:
import { runSubmission } from "./submit-result.js"
await runSubmission()运行:
node --env-file=.env --import tsx src/submit-main.ts预期得到的是提交执行器接受的对象,不是助手最终说明里恰好出现的一段 JSON。如果模型只回答文字,没有调用工具,程序会报告没有收到提交。
参数校验失败时,模型会看到什么
校验失败时,prepareToolCall() 把异常消息变成错误工具结果。模型下一轮就能读到它,并有机会自我纠正。
实际的消息长这样:
Validation failed for tool "submit_analysis":
- evidence: expected string, got number
Received arguments:
{
"name": "first",
"parameters": ["items"],
"summary": "返回数组的第一个元素",
"emptyInputBehavior": "返回 undefined",
"evidence": 42
}这个错误结果作为 toolResult 消息进入上下文,isError: true。模型看到自己提交的东西被拒了,通常会改成合法形式重试。
这条自动纠正回路是工具相比纯文本解析的最大优势。纯文本方案里,模型不知道哪里错了,只能盲猜;工具方案里,错误消息本身就是结构化的反馈。
用模拟 Provider 可以稳定复现这个回路——第一次发错类型,第二次发对:
--- assistant toolUse
block: toolCall add({"a":1,"b":"x"})
--- toolResult
content: [{"type":"text","text":"Validation failed for tool \"add\":\n - b: expected number, got string\n\nReceived arguments:\n{\n \"a\": 1,\n \"b\": \"x\"\n}"}] | isError: true
--- assistant toolUse
block: toolCall add({"a":1,"b":2})
--- toolResult
content: [{"type":"text","text":"3"}] | isError: false
--- assistant stop三层约束各自负责什么
parameters 描述工具输入;constrainedSampling 请求服务端尽可能约束生成;validateAnalysis() 在执行时检查字段和源码引用。这三者不能互相替代。
本例 strict: 'prefer' 表示支持时优先使用严格约束,不支持时可能退回普通工具调用。换成 require 则需要服务和具体模型确实支持,否则请求可能失败。不能因为代码中写了 strict 就声称所有模型都会严格生成。
Agent Core 的参数处理包含类型转换:validateToolArguments() 用的是 compile(schema, { coerce: true }),会把数字字符串转数字、单值转数组、丢弃可选属性里的 null。所以执行器拿到的参数不一定等于模型原始 JSON。本例在 execute() 里再次进行严格 Schema 与业务检查,约束的是最终接收的数据。如果业务要求原始类型绝不能转换,还要另行核验参数预处理策略。
为什么接收后可以结束
数据在 onAccepted() 回调中交给宿主。本例只保存在内存,不写入数据库。terminate: true 请求运行时在该批工具结果都同意终止时,不再自动发起后续模型请求。
终止规则的实现是"全部同意"而不是"任一同意":
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}所以批次里只要有一个工具结果没有设 terminate,整批就不会提前终止。
因此,最后的助手消息可能仍是工具调用消息,不能只寻找一段最终自然语言回答来判断成功。本例以通过校验的数据为接收依据,并额外检查运行错误状态。
轮次和时间都有上限。模型反复提交无效内容时,程序最终退出,而不是无限修复。取消或错误也不会被当成正常完成。
防止同一任务重复接收
提交工具每次创建时保存一个 accepted 状态,成功后拒绝第二次提交。工具按顺序执行,避免同一批次并发修改这个标志。
这只是一次内存任务内的去重,不是数据库事务或跨进程幂等保障。把结果存入外部系统时,还需要任务身份、幂等键和提交失败恢复,不能照搬一个布尔变量当作完整事务。
本例回调成功返回后才设置已接收标志。若实际回调会产生外部副作用,再抛出异常,就可能出现"外部已写入但程序认为失败"的状态,需要业务自己处理。
检查是否真正提交
正常样本应该调用执行器并返回经过校验的对象。无效证据不能进入回调;已经接收后再次调用应该失败;没有工具调用的文本回答不能代替提交。
这些路径已经用模拟 Provider 检查。真实服务是否选择工具、是否执行严格约束,仍需根据模型验证。下一篇讨论另一个方向:工具已经执行后,怎样返回给程序使用的数据。