跳转到内容

人机协作

本指南介绍 SDK 基于审批的人工干预流程。当工具调用需要审批时,SDK 会暂停运行,返回 interruptions,并允许您稍后从同一个 RunState 恢复运行。

该审批机制作用于整个运行,而不仅限于当前顶层智能体。无论工具属于当前智能体、通过交接到达的智能体,还是嵌套的 agent.asTool() 执行,都适用同一模式。在嵌套的 agent.asTool() 场景中,中断仍会出现在外层运行中,因此您需要在外层的 result.state 上批准或拒绝该中断,然后恢复原始根运行。

使用 agent.asTool() 时,审批可能发生在两个不同层级:智能体工具本身可以通过 asTool({ needsApproval }) 要求审批,而嵌套智能体中的工具也可以在嵌套运行开始后提出自己的审批请求。两者都通过同一个外层运行中断流程处理。

本页重点介绍通过 interruptions 实现的手动审批流程。如果您的应用可以通过代码作出决定,某些工具类型还支持编程式审批回调,使运行无需暂停即可继续。如果您正在设置 agent.asTool() 本身,请参阅工具指南;本页介绍该运行层级中的任何工具需要审批后会发生什么。

您可以将 needsApproval 选项设置为 true,或设置为返回布尔值的异步函数,以定义需要审批的工具。

工具审批定义
import { tool } from '@openai/agents';
import z from 'zod';
const sensitiveTool = tool({
name: 'cancelOrder',
description: 'Cancel order',
parameters: z.object({
orderId: z.number(),
}),
// always requires approval
needsApproval: true,
execute: async ({ orderId }, args) => {
// prepare order return
},
});
const sendEmail = tool({
name: 'sendEmail',
description: 'Send an email',
parameters: z.object({
to: z.string(),
subject: z.string(),
body: z.string(),
}),
needsApproval: async (_context, { subject }) => {
// check if the email is spam
return subject.includes('spam');
},
execute: async ({ to, subject, body }, args) => {
// send email
},
});
  1. 当工具调用即将执行时,SDK 会评估其审批规则(needsApproval 或对应的托管 MCP 规则)。
  2. 如果需要审批且尚未存储任何决定,工具调用不会执行。相反,运行会记录一个 RunToolApprovalItem。
  3. 在该轮次结束时,运行会暂停,并在执行结果的 interruptions 数组中返回所有待处理审批。这包括嵌套 agent.asTool() 运行中提出的审批请求。
  4. 使用 result.state.approve(interruption) 或 result.state.reject(interruption) 处理每个待处理项。如果同一个工具在本次运行的剩余时间内应始终获得批准或拒绝,请传入 { alwaysApprove: true } 或 { alwaysReject: true }。拒绝时,您还可以传入 { message: '...' },以控制针对该特定工具调用发回模型的拒绝文本。
  5. 将更新后的 result.state 传回 runner.run(agent, state) 以恢复运行,其中 agent 是该运行的原始顶层智能体。SDK 会从中断点继续执行,包括嵌套的智能体工具执行。

默认情况下,函数工具的输入护栏仅在审批通过后、工具执行前立即运行。如果希望在显示待处理审批前,使用相同的输入护栏验证本地函数工具调用,请向 run() 或 Runner 传入 toolExecution: { preApprovalInputGuardrails: true }。当审批前护栏拒绝调用时,SDK 会将护栏消息作为工具输出返回给模型,而不会创建审批中断。当护栏允许调用时,运行仍会暂停以等待审批,并且输入护栏会在审批后再次运行,以防工具调用在等待期间变得不安全。

当 needsApproval 是函数时,SDK 仅在工具参数解析为可检查的对象后调用它。格式错误的 JSON 和非对象值会按安全方式失败:SDK 会请求审批,而不会调用该回调或执行工具。即使批准该调用,工具仍不会执行;调用会继续进入常规的参数解析错误流程。Realtime 函数工具遵循相同规则,并会针对无效调用发出 tool_approval_requested。

使用 { alwaysApprove: true } 或 { alwaysReject: true } 创建的持久决策会存储在运行状态中,因此稍后恢复同一个已暂停运行时,这些决策可以在 toString() / fromString() 转换后继续保留。

在 GA 模型上,计算机工具中断可以表示单个 computer_call 中的一批操作。SDK 会在执行前逐个操作评估 needsApproval,因此一个待处理审批可以涵盖移动和点击等一系列操作。如果您通过检查 interruption.rawItem 来呈现 UI,请同时处理 GA 版本的 actions 数组和旧版的单个 action 字段。

序列化的 RunState 还会针对当前的 computer 工具名称和旧版的 computer_use_preview 名称保留计算机操作审批,因此暂停的运行可以在从预览版迁移到 GA 版本期间顺利恢复。

如果您没有提供 message,SDK 会先回退到已配置的 toolErrorFormatter(如有),然后再回退到默认拒绝文本。

您不需要在同一次处理中解决所有待处理审批。如果仅批准或拒绝部分项目后重新运行,已处理的调用可以继续执行,而未处理的调用仍会保留在 interruptions 中,并再次暂停运行。

使用 alwaysApprove: true 或 alwaysReject: true 创建的持久决策会成为以后调用同一工具时的默认决定。针对某个调用 ID 的精确决策优先于该持久默认值:您可以在默认始终批准的情况下拒绝某一次调用,也可以在默认始终拒绝的情况下批准某一次调用,而不改变其他调用的默认决定。如果您稍后替换该精确决策,新决策将应用于该调用,而持久默认值仍适用于其余调用。

当运行暂停期间收到新的用户输入,并且应在下一次恢复后的模型调用前接纳该输入时,请使用 RunState.addInput()。请先添加输入,再处理审批,并将同一状态传回 Runner.run()。暂存的输入属于序列化状态的一部分,因此即使未处理的审批或本地工具工作延迟了该模型调用,它也能在 toString() / fromString() 转换后保留。

读取 state.pendingInput 可检查暂存项的克隆快照,也可以在恢复前调用 state.clearPendingInput() 将其全部移除。addInput() 接受字符串或输入项数组。当状态无法安全到达另一次模型调用时,它会引发 UserError,例如状态已终止、没有剩余轮次,或存在其工具结果可能会结束运行的中断。

接纳后,每个暂存输入实例都会成为运行的 newItems 中的一个 RunInputItem,其原始输入项则会包含在 history 中。使用本地 session 时,SDK 会在开始模型请求前将接纳的输入持久化一次。使用 conversationId 或 previousResponseId 时,该输入会保持待处理状态,直到服务器接受响应,因此如果确定故障发生在接受之前,便可以恢复运行而不会丢失输入。如果提供商报告请求可能已被接受,SDK 会为该输入实例创建检查点并按安全方式失败,而不是在未提示的情况下重放它。有关单独且显式的不安全重放覆盖选项,请参阅模型重试。

手动 interruptions 是最通用的模式,但并不是唯一模式:

  • 本地 shellTool() 和 applyPatchTool() 可以使用 onApproval 直接在代码中批准或拒绝。
  • 托管 MCP 工具可以结合使用 requireApproval 和 onApproval,以作出同类编程式决策。
  • 普通函数工具使用本页介绍的手动中断流程。

当这些回调返回决定时,运行会继续执行,无需暂停以等待人工响应。对于 Realtime 会话 API,请参阅构建语音智能体指南中的审批流程。

同一中断流程也适用于流式运行。流式运行暂停后,请等待 stream.completed,读取 stream.interruptions 并处理这些中断。如果希望恢复后的输出继续采用流式传输,请使用 { stream: true } 再次调用 run()。有关该模式的流式版本,请参阅流式传输中的人工干预。

如果您还在使用 session,从 RunState 恢复时请继续传入同一个 session。恢复后的轮次会追加到会话记忆中,而不会重新准备输入。有关会话生命周期的详细信息,请参阅会话指南。

下面是一个更完整的人工干预流程示例,它会在终端中请求审批,并将状态临时存储到文件中。

人工干预
// This local CLI trusts its saved state. Browser/mobile approval UIs should keep
// snapshots on the server; see human-in-the-loop-server.ts in agent-patterns.
import { z } from 'zod';
import readline from 'node:readline/promises';
import fs from 'node:fs/promises';
import { Agent, run, tool, RunState, RunResult } from '@openai/agents';
const getWeatherTool = tool({
name: 'get_weather',
description: 'Get the weather for a given city',
parameters: z.object({
location: z.string(),
}),
needsApproval: async (_context, { location }) => {
// forces approval to look up the weather in San Francisco
return location === 'San Francisco';
},
execute: async ({ location }) => {
return `The weather in ${location} is sunny`;
},
});
const dataAgentTwo = new Agent({
name: 'Data agent',
instructions: 'You are a data agent',
handoffDescription: 'You know everything about the weather',
tools: [getWeatherTool],
});
const agent = new Agent({
name: 'Basic test agent',
instructions: 'You are a basic agent',
handoffs: [dataAgentTwo],
});
async function confirm(question: string) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await rl.question(`${question} (y/n): `);
const normalizedAnswer = answer.toLowerCase();
rl.close();
return normalizedAnswer === 'y' || normalizedAnswer === 'yes';
}
async function main() {
let result: RunResult<unknown, Agent<unknown, any>> = await run(
agent,
'What is the weather in Oakland and San Francisco?',
);
let hasInterruptions = result.interruptions?.length > 0;
while (hasInterruptions) {
// Store the current run state
await fs.writeFile(
'result.json',
JSON.stringify(result.state, null, 2),
'utf-8',
);
// At this point, another process could review the saved state
// Read the saved state later
const storedState = await fs.readFile('result.json', 'utf-8');
const state = await RunState.fromString(agent, storedState);
for (const interruption of result.interruptions) {
const confirmed = await confirm(
`Agent ${interruption.agent.name} would like to use the tool ${interruption.name} with "${interruption.arguments}". Do you approve?`,
);
if (confirmed) {
state.approve(interruption);
} else {
state.reject(interruption);
}
}
// Resume execution from the restored state
result = await run(agent, state);
hasInterruptions = result.interruptions?.length > 0;
}
console.log(result.finalOutput);
}
main().catch((error) => {
console.dir(error, { depth: null });
});

有关可运行的端到端版本,请参阅完整示例脚本。

人工干预流程的设计支持长时间中断,而无需让服务器持续运行。如果您需要结束请求并在稍后继续,可以序列化状态并在之后恢复。

您可以使用 result.state.toString()(或 JSON.stringify(result.state))序列化状态,之后再将序列化状态传入 RunState.fromString(agent, serializedState) 以恢复运行,其中 agent 是触发整个运行的智能体实例。

当已批准的函数工具结果可能成为运行的最终输出时,输出护栏会施加更严格的恢复边界。当 SDK 能够确认归属时,当前的 RunState 模式会记录当前响应中生成项的确切归属,因此受支持且可能产生输出的部分审批检查点可以通过序列化状态完整往返。SDK 会在产生进一步的模型、工具或会话副作用前验证该归属。对于缺少归属、归属无效或归属不明确的旧版快照和检查点,系统仍会按安全方式失败并引发 UserError。发生这种情况时,请使用安全输入开始新的运行。请重新构建相同的智能体图,并保留原始的 toolUseBehavior;不要通过重放原始项来规避归属错误。

序列化 RunState 时,SDK 会为交接和 Agent.asTool() 图记录稳定的智能体标识。只要恢复运行的进程重新构建了相同的智能体图,即使多个不同的智能体具有相同的 name,已暂停的运行也能恢复。

传给 RunState.fromString(agent, serializedState) 的 agent 是重新构建的智能体图的根节点。反序列化期间,SDK 会遍历该智能体的交接和 Agent.asTool() 引用,然后根据重新构建的图解析状态中的每个序列化智能体引用。这包括当前智能体,以及生成项、已处理的模型响应和排队的后续步骤所持有的嵌套引用。

如果需要使用替换后的图恢复运行,例如智能体的模型或工具已由其他运行时封装,请先使用原始图反序列化状态,再次将其序列化,然后使用替换后的根智能体反序列化该字符串。调用 state.setCurrentAgent(agent) 只会更改活动智能体,不会重写反序列化期间已经解析的嵌套引用。

如果恢复运行的进程需要注入新的上下文对象,请改用 RunState.fromStringWithContext(agent, serializedState, context, { contextStrategy })。

  • contextStrategy: 'merge'(默认)会保留提供的 RunContext,将序列化的审批状态合并到其中,并在新上下文尚未定义 toolInput 时恢复序列化的 toolInput。
  • contextStrategy: 'replace' 会使用提供的 RunContext 原样重新构建运行。

序列化的运行状态包含您的应用上下文,以及由 SDK 管理的运行时元数据,例如审批、用量、嵌套的 toolInput 和待恢复的嵌套智能体工具。如果您计划存储或传输序列化状态,请将 runContext.context 视为持久化数据,并避免在其中放置机密信息,除非您有意让这些信息随状态一起传输。

默认情况下,序列化状态会省略追踪 API 密钥,以免意外持久化机密信息。仅当确实需要随状态一起传输追踪凭据时,才传入 result.state.toString({ includeTracingApiKey: true })。

请将序列化状态存储在由应用控制的存储中,例如服务器端数据库。

序列化的 RunState 包含执行状态,包括审批决策、待处理工具调用、工具参数和应用上下文。RunState.fromString() 会恢复该状态;此方法不会验证快照的真实性,也不会验证提交者的身份。只能反序列化来自可信存储的快照,或者由您的应用验证过完整快照的完整性、所有权和防重放保护后再进行反序列化。模式验证和工具调用指纹无法证明快照可信。SDK 中对智能体或生成项归属的引用也不能用于授权应用用户。

对于浏览器或移动端审批界面,请将完整快照保存在由应用控制的服务器存储中。只向审核者发送其有权查看的显示信息,以及待处理决策的不透明标识符。运行 ID 或决策 ID 并不能证明授权。请将工具名称和参数视为不可信的显示内容:过滤敏感值,并在渲染 HTML 时转义内容。将完整的运行结果和错误保留在服务器上,只向客户端返回由应用选择的输出。

收到决策时,服务器必须:

  1. 使用应用的会话或身份验证中间件验证审核者身份。切勿从审批请求正文中获取经过身份验证的身份。
  2. 根据已存储的运行和选定的待处理调用,检查审核者的授权。
  3. 根据服务器上存储的待处理请求,验证决策标识符和布尔决策。不要接受来自客户端的替代工具调用、参数、审批记录或序列化状态。
  4. 在反序列化并恢复执行前,以原子方式检查所有权并消费待处理请求。并发或重放的提交不得使同一个快照恢复两次。在共享存储中,请使用事务或等效的原子条件转换。
  5. 加载服务器持有的快照,通过 state.getInterruptions() 获取待处理项,并且仅对这些项应用 state.approve() 或 state.reject(),然后再恢复运行。

以下示例要求为一个批次中的每个待处理调用提供一项决策。这是一项应用策略;SDK 也支持上文所述的部分审批。该存储仅限单个进程中的一个事件循环。所有者检查、验证和消费之间没有任何 await。即使反序列化或执行失败,或者执行被取消,请求仍会保持已消费状态。消费操作可以防止重新提交此快照,但不能保证工具副作用恰好执行一次。在开始恢复或另一次运行前,请先核对已完成或状态不确定的工具副作用。

服务器持有的审批状态
import { randomUUID } from 'node:crypto';
import { Agent, Runner, RunState, type RunResult } from '@openai/agents';
import { z } from 'zod';
export type PendingApproval = {
kind: 'approval';
requestId: string;
prompts: { decisionId: string; toolName: string; arguments: string }[];
};
type StoredRun = {
ownerId: string;
snapshot: string;
decisionIds: string[];
};
// Simulation only: one event loop in one process. Production storage needs an
// atomic owner-checked consume operation and bounded retention. Consumption is
// permanent even after failure/cancellation; reconcile side effects before retry.
export class ApprovalServer {
#agent: Agent;
#runner = new Runner({ tracingDisabled: true });
#pending = new Map<string, StoredRun>();
constructor(agent: Agent) {
this.#agent = agent;
}
// An HTTP adapter must obtain this identity from trusted authentication
// middleware and apply request/CSRF protections. Never read it from the body.
async start(authenticatedUserId: string, message: string) {
const result = await this.#runner.run(this.#agent, message);
return this.#save(authenticatedUserId, result);
}
#save<TContext, TAgent extends Agent<any, any>>(
ownerId: string,
result: RunResult<TContext, TAgent>,
) {
const interruptions = result.state.getInterruptions();
if (interruptions.length === 0) {
// RunResult stays on the server; the app selects what the client may see.
return { kind: 'completed' as const, output: result.finalOutput };
}
const requestId = randomUUID();
const decisionIds = interruptions.map(() => randomUUID());
this.#pending.set(requestId, {
ownerId,
snapshot: result.state.toString(),
decisionIds,
});
const response: PendingApproval = {
kind: 'approval',
requestId,
// Detached display values only. Filter arguments for the reviewer's access
// policy; this demo uses synthetic weather data. Escape HTML in a web UI.
prompts: interruptions.map((item, index) => ({
decisionId: decisionIds[index],
toolName: item.name ?? 'unknown_tool',
arguments: item.arguments ?? '',
})),
};
return response;
}
async decide(
authenticatedUserId: string,
requestId: string,
decisions: unknown,
signal?: AbortSignal,
) {
const stored = this.#pending.get(requestId);
if (!stored || stored.ownerId !== authenticatedUserId) {
throw new Error('Approval request is unavailable.');
}
// Validate JSON request data, not client-supplied calls, state, or identity.
const parsed = z.record(z.string(), z.boolean()).safeParse(decisions);
if (
!parsed.success ||
Object.keys(parsed.data).length !== stored.decisionIds.length ||
!stored.decisionIds.every((id) =>
Object.prototype.hasOwnProperty.call(parsed.data, id),
)
) {
throw new Error(
'Provide one boolean decision for every pending tool call.',
);
}
// No await between the owner check and consume. The parsed decisions are a
// detached copy, so client mutation while deserialization awaits has no effect.
this.#pending.delete(requestId);
const state = await RunState.fromString(this.#agent, stored.snapshot);
const interruptions = state.getInterruptions();
for (const [index, interruption] of interruptions.entries()) {
if (parsed.data[stored.decisionIds[index]]) {
state.approve(interruption);
} else {
state.reject(interruption);
}
}
const result = await this.#runner.run(this.#agent, state, { signal });
return this.#save(authenticatedUserId, result);
}
}

有关完整示例,请参阅 CLI 客户端/服务器模拟。这不是可部署的 HTTP 服务。生产应用必须提供可信的身份验证、针对显示信息和选定调用的授权、适用情况下的请求与 CSRF 防护、有限的存储保留期限、共享存储中的原子消费机制以及恢复策略。该 CLI 使用合成工具数据并禁用追踪;实际应用的快照和诊断信息可能包含敏感数据。

使用 contextStrategy: 'replace' 的 RunState.fromStringWithContext(),或者仅删除序列化的审批记录,都不能使不可信的快照变得安全。其他字段仍然会控制执行。如果客户端传输完整快照,请在反序列化前验证完整快照的完整性,将其绑定到已授权的用户和运行,并防止重放。完整性验证不会加密快照,也不会向客户端隐藏其内容。

如果审批请求需要较长时间,并且您打算对智能体定义进行实质性的版本管理,或升级 Agents SDK 版本,我们目前建议您使用软件包别名并行安装两个版本的 Agents SDK,以实现自己的分支逻辑。

实际操作中,这意味着为您自己的代码分配版本号,将其与序列化状态一起存储,并引导反序列化过程使用正确版本的代码。