让工具返回可复用的结构化数据
区分给模型的文本与给程序的数据两条通道,理解 structuredContent 不进模型上下文,在消费方显式验证后再消费。
让工具返回可复用的结构化数据
模型提交给工具的参数是一种结构化输入;工具执行完成后返回给其他程序的数据,则是结构化结果。两者流向不同,不应该共用一个含糊的"输出 JSON"概念。
本篇用确定性的教学样本演示工具结果契约,不调用模型、不分析任意代码。沿用Schema 校验中的 schema.ts 和 lesson.ts,可以离线运行。
两条通道
AgentToolResult 是工具执行后的返回值。它的定义在 packages/agent/src/types.ts:
export interface AgentToolResult<T = JsonValue | undefined> {
/** Text or image content returned to the model. */
content: (TextContent | ImageContent)[];
/** Arbitrary structured details for logs or UI rendering. */
details: T;
/**
* Machine-readable result matching the tool's `outputSchema`, for programmatic callers. Not sent
* to the model; `content` remains the model-facing result.
*/
structuredContent?: JsonValue;
usage?: Usage;
/**
* Report a failure without throwing. The model sees `content` as an error result, like a thrown
* error, but `details` and `structuredContent` are kept for the UI and programmatic callers.
*/
isError?: boolean;
terminate?: boolean;
}structuredContent 的注释里那句 "Not sent to the model" 是本篇的核心事实。翻译过来:给模型的和给程序的是两条通道。
outputSchema 定义在 AgentTool 上:
export interface AgentTool<TParameters extends object = object, TDetails = any> extends Tool<TParameters> {
label: string;
prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;
/**
* JSON Schema of `structuredContent` in successful results. Tools that declare it should always
* set `structuredContent`.
*/
outputSchema?: object;
execute: (
toolCallId: string,
params: AgentToolParams<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
replay?: "never" | "safe";
executionMode?: ToolExecutionMode;
}注释说得很清楚:声明了 outputSchema 的工具就应该总是设置 structuredContent。声明是承诺,不是自动检查——框架不会替你验证 structuredContent 是否真的符合 outputSchema。
验证:structuredContent 不进消息
这一点值得实际验证,而不是靠读文档相信。packages/agent/src/agent-loop.ts 里构造工具结果消息的代码:
function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {
return {
role: "toolResult",
toolCallId: finalized.toolCall.id,
toolName: finalized.toolCall.name,
// Untyped tools (JS extensions) can return results without content; normalize
// so the null never enters session history or provider payloads.
content: finalized.result.content ?? [],
details: finalized.result.details,
usage: finalized.result.usage,
isError: finalized.isError,
...(finalized.durationMs === undefined ? {} : { durationMs: finalized.durationMs }),
timestamp: Date.now(),
};
}构造出的 ToolResultMessage 只有 content、details、usage、isError——没有 structuredContent。它停留在 AgentToolResult 这一层,通过 tool_execution_end 事件暴露给订阅者,不进入转录。
对应地,ToolResultMessage 的类型定义里也根本没有 structuredContent 字段:
export type ToolResultMessage<TDetails = JsonValue> = IsJsonCompatible<TDetails> extends true
? {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
details?: JsonRepresentation<TDetails>;
usage?: Usage;
nestedCalls?: NestedToolCalls;
isError: boolean;
timestamp: number;
durationMs?: number;
}
: never;可以写一段代码直接观察到这一点:
import { Agent } from "@earendil-works/pi-agent-core";
import { createModels } from "@earendil-works/pi-ai";
const agent = new Agent({
initialState: {
systemPrompt: "s",
model,
tools: [{
name: "stats",
label: "Stats",
description: "统计数据",
parameters: { type: "object", properties: {}, additionalProperties: false },
outputSchema: {
type: "object",
properties: { count: { type: "number" } },
required: ["count"],
additionalProperties: false,
},
async execute() {
return {
content: [{ type: "text", text: "共 5 条记录" }],
details: { rows: 5 },
structuredContent: { count: 5 },
};
},
}],
},
streamFn: models.streamSimple.bind(models),
getApiKey: () => "k",
});
agent.subscribe((event) => {
if (event.type === "tool_execution_end") {
// 事件里能看到 structuredContent
console.log("事件中的 structuredContent:", event.result.structuredContent);
}
});
await agent.prompt("统计");
for (const message of agent.state.messages) {
if (message.role === "toolResult") {
// 消息里没有
console.log("消息中的 structuredContent:", (message as any).structuredContent); // undefined
console.log("消息中的 content:", JSON.stringify(message.content));
}
}输出:
事件中的 structuredContent: { count: 5 }
消息中的 structuredContent: undefined
消息中的 content: [{"type":"text","text":"共 5 条记录"}]同一份数据,在事件里有,在消息里没有。 这就是两条通道的差别。
编写带结构化结果的工具
创建 src/structured-tool.ts:
import type { AgentTool } from "@earendil-works/pi-agent-core"
import { obj } from "@earendil-works/pi-schema"
import { analysisSchema, validateAnalysis } from "./schema.js"
const emptyParameters = obj({})
export const inspectLesson: AgentTool<typeof emptyParameters> = {
name: "inspect_lesson",
label: "取得练习分析",
description: "返回教学样本的分析数据,用于演示工具输出契约。",
parameters: emptyParameters,
outputSchema: analysisSchema,
async execute() {
// 这是确定性的教学样本,不是声称模型已分析或执行了任意代码。
const value: unknown = {
name: "first",
parameters: ["items"],
summary: "返回数组的第一个元素",
emptyInputBehavior: "返回 undefined",
evidence: "return items[0]",
}
const analysis = validateAnalysis(value)
return {
content: [{ type: "text", text: `函数 ${analysis.name}:${analysis.summary}` }],
details: { origin: "lesson-fixture" },
structuredContent: analysis,
}
},
}这里的对象由程序构造,明确标注为教学样本。它用来演示输出分发,不冒充一次已经发生的模型分析。
validateAnalysis() 在返回前验证数据。outputSchema 声明消费者可以期待的结构,但不能据此省略运行时检查。当前示例版本中的 Agent Core 并未替这个工具的所有返回值自动执行同一份应用校验。
注意 content 里只放了一句摘要。如果模型下一步需要某项具体证据,就要在 content 里提供它——structuredContent 里的完整对象模型看不到。
在消费方再次守住边界
创建 src/consume-tool.ts:
import { validateAnalysis } from "./schema.js"
import { inspectLesson } from "./structured-tool.js"
// 直接调用执行器便于观察结果结构,不代表经过了完整 Agent 参数校验链路。
const result = await inspectLesson.execute("lesson-1", {})
if (result.isError) {
throw new Error("工具报告失败,不能把附带数据当作正常结果")
}
const analysis = validateAnalysis(result.structuredContent)
console.log("给模型的摘要:", result.content)
console.log("给程序的数据:", analysis)
console.log("界面与日志信息:", result.details)运行:
node --import tsx src/consume-tool.ts检查输出:模型摘要不包含完整数据对象,程序拿到带字段的分析结果,界面信息单独保留。这比在下一步重新解析一段自然语言摘要更清楚。
直接调用 execute() 只用于观察结果,不意味着已经经过完整运行时的参数验证、事件与权限流程。本例输入是固定空对象;把它接入实际工具系统时,还需要保持原来的执行链路。
消费方检查 isError 后才验证数据。类型或 Schema 声明来自别人时,不应盲目信任;从外部工具、跨进程消息或历史记录恢复的数据,都可以在消费入口重新检查。
在宿主里读到它
在真实运行时,消费方通常通过订阅事件拿到结构化结果,而不是直接调 execute():
agent.subscribe((event) => {
if (event.type !== "tool_execution_end") return;
const { toolName, result, isError } = event;
if (isError) {
reportFailure(toolName, result.content);
return;
}
if (result.structuredContent !== undefined) {
const parsed = validateAnalysis(result.structuredContent);
saveToDatabase(parsed);
}
});tool_execution_end 事件的类型:
type ToolExecutionEndEvent = {
type: "tool_execution_end";
toolCallId: string;
toolName: string;
result: any;
isError: boolean;
durationMs?: number;
};result 是完整的 AgentToolResult,所以 structuredContent 和 details 都在。事件的 isError 是最终判定——注意它可能与 result.isError 不同,因为 afterToolCall 钩子可以改写它。
失败结果也可能携带数据
某些失败需要保留诊断信息,例如执行失败但已经获得部分统计。工具可以按契约返回 isError: true,同时携带 details 或 structuredContent。消费者应先处理失败状态,再决定是否读取附带数据,不把字段存在当成成功。
AgentToolResult.isError 的注释说明了这一点:
Report a failure without throwing. The model sees
contentas an error result, like a thrown error, butdetailsandstructuredContentare kept for the UI and programmatic callers.
翻译:不抛异常也能报告失败。模型看到的 content 与抛异常时一样是错误结果,但 details 和 structuredContent 仍然留给界面和程序消费方。
这和 Agent Core 的另一条约定一致——AgentTool.execute() 的注释写着:
Execute the tool call. Throw on failure, or return a result with
isError: true; do not only describe the failure incontent.
不要只在 content 里描述失败。抛异常或设 isError: true,两者选一。
错误数据结构通常与成功结构不同,应单独设计或显式声明联合状态。本例只演示成功结构,不能在失败时强塞一个字段齐全的成功对象。
afterToolCall 会改写结构化结果
afterToolCall 钩子可以覆盖工具结果。它的合并语义是逐字段的:
export interface AfterToolCallResult {
content?: (TextContent | ImageContent)[];
details?: unknown;
structuredContent?: JsonValue;
isError?: boolean;
usage?: Usage;
terminate?: boolean;
}有一条容易忽略的规则,源码注释写得很清楚:
structuredContent: if provided, replaces the structured content. Ifcontentis provided without it, the structured content is dropped, because it may no longer match the content. Return it along withcontentto keep it.
翻译:如果只替换了 content 而没同时给 structuredContent,结构化结果会被丢弃——因为它可能已经和新的内容对不上了。要保留就得两个一起返回。
实现代码:
const structuredContent =
afterResult.structuredContent ?? (afterResult.content ? undefined : result.structuredContent);
result = {
...result,
content: afterResult.content ?? result.content,
details: afterResult.details ?? result.details,
usage: afterResult.usage ?? result.usage,
terminate: afterResult.terminate ?? result.terminate,
};
if (structuredContent === undefined) delete result.structuredContent;
else result.structuredContent = structuredContent;用坏数据测试契约
把样本的 summary 临时改成数字,返回前校验应失败。即使一个执行器漏掉校验,消费方仍应拒绝不符合结构的数据。模拟检查覆盖了"工具声明了 Schema,但实际返回错误结构"的情况,避免把文档声明误当成执行保证。
到这里,工具输入和工具输出都有了明确边界。最后一篇处理流式、不完整结果与纠正重试。
本篇的"两条通道"行为用仓库源码实测过:structuredContent 出现在 tool_execution_end 事件里,不出现在 toolResult 消息里。本轮未与真实模型联调。