eve 中文教程 · 第 11 章 人机协同
两种停顿:工具要人批准(approval),模型要人回答(question)。两者共用同一套「暂停 → 停泊 → 恢复」协议,turn 不会断,
turnId不变。
11.1 两种停顿,一套协议
| approval | question | |
|---|---|---|
| 谁发起 | 工具定义里的策略(在 execute 之前) |
模型(调用 ask_question 或 workflow 工具里的 ctx.ask) |
| 停在哪 | 工具还没开始跑 | 工具跑到一半 |
| 人看到什么 | 批准 / 拒绝 | 选项或自由文本 |
| 拒绝的后果 | 工具不执行,模型收到拒绝理由 | 模型拿到 cancelled 或新消息 |
共同点:都发 input.requested,都让 turn 进入 turn.waiting(on: "input"),都由 input.resolved 恢复,turnId 不变。
11.2 approval 只有两种形态
这是全章最容易看晕的地方。approval 的类型只有两个分支:
// eve/tools/approval 的类型(简化)
type ToolApproval<T> =
| ApprovalPolicy<T> // 形态一:函数
| {
request: ApprovalPolicy<T>; // 形态二:对象
response?: ApprovalResponsePolicy<T>;
prompt?: ApprovalPrompt<T>; // ({ input, toolName, callId }) => string
};
| 形态 | 长什么样 | 能表达什么 |
|---|---|---|
| 函数 | approval: always() 或 approval: myPolicy |
只决定「要不要停」 |
| 对象 | approval: { request, prompt?, response? } |
要不要停 + 给人看什么 + 谁有资格批 |
关键点:always() / once() / never() / auto() 本身就是一个函数(返回 ApprovalPolicy),所以它们既能直接当 approval,也能放进对象的 request 字段。这不是两种写法,是同一个东西的两个位置。
本章的写法约定
为了避免同一份代码里混用,本章统一按这三条来:
// ① 只需要「要不要停」→ 直接放 helper 调用
approval: always()
// ② 需要文案或审批人 → 对象形态,request 里放 helper
approval: {
request: always(),
prompt: ({ input }) => `重启 ${input.service}?`,
}
// ③ 需要按输入/调用者判断 → 具名策略函数,再放进去
const costBasedApproval: ApprovalPolicy<{ sql: string }> = ({ toolInput }) => /* … */;
approval: costBasedApproval
// ③ 同时还要文案 → 具名函数进 request
approval: { request: costBasedApproval, prompt: describeCost }
另外两条格式约定:approval 字段固定放在 inputSchema 之后、execute 之前;策略函数一律具名(const xxx: ApprovalPolicy<T> = ...),不写内联箭头——内联箭头在需要类型标注或复用时没法处理。
同一个工具的三种写法
// 版本 A:只 gate
export default defineTool({
description: "重启一个服务的所有实例。",
inputSchema: z.object({ service: z.string() }),
approval: always(),
async execute({ service }) { return restart(service); },
});
// 版本 B:加文案(形态从函数升级为对象)
export default defineTool({
description: "重启一个服务的所有实例。",
inputSchema: z.object({ service: z.string() }),
approval: {
request: always(),
prompt: ({ input }) => `重启 ${input.service}?约 10-30 秒不可用。`,
},
async execute({ service }) { return restart(service); },
});
// 版本 C:按输入决定,再加文案
const restartApproval: ApprovalPolicy<{ service: string }> = ({ toolInput }) =>
toolInput?.service.startsWith("payment-") ? "user-approval" : "not-applicable";
export default defineTool({
description: "重启一个服务的所有实例。",
inputSchema: z.object({ service: z.string() }),
approval: {
request: restartApproval,
prompt: ({ input }) => `重启 ${input.service}?约 10-30 秒不可用。`,
},
async execute({ service }) { return restart(service); },
});
三个版本是递进关系,不是三选一:需要哪一层就停在哪一层。
11.3 四个现成策略
| Helper | 行为 | 返回 |
|---|---|---|
never() |
从不要求审批 | "not-applicable" |
once() |
同一会话内第一次要求,之后自动放行 | 首次 "user-approval",之后 "not-applicable" |
always() |
每次调用都要 | "user-approval" |
auto() |
用决策模型判断这次调用的风险 | "approved" 或 "user-approval" |
默认值是最危险的设定
不写 approval 就等于 never()。源码里就是这一句:
if (definition.approval === undefined) return; // 没有策略 = 不拦
涉及资金、删除、对外发消息、改变生产状态的工具,必须显式挂策略。
once() 记的是什么
once() 判断的是「这个工具名是否已经在本会话的 approvedTools 集合里」。所以:
- 同一会话内第二次调用同一个工具,自动放行。
- 同一个 step 里并发发起三个调用,三个都会弹——因为批准记录要等已存在的待批请求全部解决后才写入。
- 想按输入粒度记忆(比如「这个服务批过一次,换个服务还要再批」),用工具定义上的
approvalKey:
approvalKey: ({ service }) => `restart:${service}`,
auto() 的细节
approval: auto({
model: "typesafe-ai/jev", // 默认值
instructions: "判断这笔退款是否需要财务审批。",
criteria: {
clear: "可以自动执行。",
caution: "必须人工审核。",
},
}),
实现上的几个硬约束:
- 决策模型只看工具名和输入,输入序列化后超过 64 KiB 直接要求人工(不截断后判断)。
- 决策调用有 30 秒超时;超时、抛错、返回不合法,一律按
user-approval处理(fail closed)。 clear→"approved"(自动执行),caution→"user-approval"。- 工具输入会发给决策模型的供应商。敏感数据要么别用
auto(),要么先把输入裁剪干净。
11.4 自定义策略:函数形态
能拿到什么
ApprovalContext 在 ToolContext 的基础上,多了判断所需的字段:
| 字段 | 用途 |
|---|---|
session.auth.current |
当前 turn 的调用者 |
session.auth.initiator |
创建会话的人 |
toolName |
最终运行时工具名(连接工具带 <connection>__ 前缀) |
toolInput |
校验过的输入;可能是 undefined |
approvedTools |
本会话已批准的工具集合(once() 靠它) |
callId |
本次调用的 ID |
abortSignal |
turn 被取消时触发,异步判断要用它 |
toolAnnotations |
仅连接工具:服务端声明的 readOnlyHint 等提示 |
能返回什么
| 返回 | 含义 |
|---|---|
"user-approval" |
停下来等人 |
"not-applicable" |
直接执行 |
"approved" |
自动批准(不弹窗) |
"denied" |
自动拒绝 |
{ type: "denied", reason } |
带理由的拒绝,模型能看到理由 |
true / false |
兼容旧写法:true = 要审批,false = 直接执行 |
一个完整的策略
// agent/tools/refund_charge.ts
import { defineTool } from "eve/tools";
import { type ApprovalPolicy } from "eve/tools/approval";
import { z } from "zod";
type RefundInput = { tenantId: string; chargeId: string; amount: number };
// 跨租户直接拒绝;同租户超过 1000 才要人批
const refundApproval: ApprovalPolicy<RefundInput> = ({ session, toolInput }) => {
const callerTenant = session.auth.current?.attributes.tenantId;
if (callerTenant === undefined || callerTenant !== toolInput?.tenantId) {
return { type: "denied", reason: "调用者无权访问该租户。" };
}
return (toolInput.amount ?? 0) > 1000 ? "user-approval" : "not-applicable";
};
export default defineTool({
description: "给一笔收费退款。",
inputSchema: z.object({
tenantId: z.string(),
chargeId: z.string(),
amount: z.number().positive(),
}),
approval: refundApproval,
async execute({ chargeId, amount }) {
return refund(chargeId, amount);
},
});
身份只从 session.auth 取,绝不从 toolInput 取。 上面用 toolInput.tenantId 只是为了和调用者身份做比对,不是把它当身份。
current 和 initiator 的区别:共享 Slack 线程里,张三创建了会话(initiator),李四接着说话(current)。按「谁在操作」判断用 current,按「会话归谁」判断用 initiator。
11.5 提示文案与审批人:对象形态
对象形态只多两个可选字段,其余和函数形态一样。
prompt:审批人看到什么
approval: {
request: always(),
prompt: ({ input, toolName, callId }) =>
`重启 ${input.service}?\n影响:全部实例滚动重启,约 10-30 秒不可用。`,
},
prompt 收到三个东西:校验过的 input、最终的 toolName、callId。默认文案是「Approve <工具标签>?」,信息量太低——审批人需要知道自己在批什么,尤其是影响范围。
Slack 会把完整工具输入折叠成 Tool input 块,审批人还能展开看原始结构。
response:谁有资格批
默认只有发起这个 turn 的人能批准或取消。要改:
import { type ApprovalResponsePolicy } from "eve/tools/approval";
// 只有 on-call 组能批;其他人提交会被拒绝,请求保持待处理
const onCallOnly: ApprovalResponsePolicy<RefundInput> = ({ response }) => {
const roles = response.principal.attributes.roles;
const isOnCall = Array.isArray(roles) && roles.includes("oncall");
return isOnCall
? { status: "allowed" }
: { status: "rejected", reason: "只有 on-call 可以批准退款。" };
};
response 策略收到的字段:
| 字段 | 含义 |
|---|---|
request.requestId / callId / toolName / toolInput |
待批的调用 |
request.principal |
发起这次调用的人(未认证时为 null) |
response.decision |
"approve" 或 "cancel" |
response.principal |
提交这次应答的人 |
session |
会话身份与血缘(id、initiator、parent、turn) |
auth |
绑定到应答者的 getToken / requireAuth |
四条行为:
{ status: "allowed" }接受这个决定。{ status: "rejected", reason }让请求保持待处理,其他合格审批人还能处理。- 策略对
approve和cancel都会跑。只想限制批准,就对cancel直接放行。 - 被拒绝时不会发
session.waiting,turn 继续挂着。
只允许发起人自己批
import type { SessionAuthContext } from "eve/context";
function samePrincipal(a: SessionAuthContext, b: SessionAuthContext): boolean {
return (
a.authenticator === b.authenticator &&
a.issuer === b.issuer &&
a.principalType === b.principalType &&
a.principalId === b.principalId
);
}
const requesterOnly: ApprovalResponsePolicy = ({ request, response }) =>
request.principal !== null && samePrincipal(response.principal, request.principal)
? { status: "allowed" }
: { status: "rejected", reason: "只有发起人可以批准。" };
比较四个字段,不能只比 principalId——两个认证系统里可能都有 u_42。
11.6 审批 ≠ 借用身份
这是设计上最值得注意的一点:
审批只是同意。批准后的工具调用仍然以发起人的身份、连接和凭据运行,审批人的权限不会被借出去。
// 只有明确需要「以审批人身份操作」时才用 ctx.approval
async execute({ repo }, ctx) {
const admin = await ctx.approval?.responder.getToken(github);
if (admin === undefined) throw new Error("需要 IT 管理员批准。");
return grantAccess(repo, ctx.session.auth.current, admin.token);
}
ctx.approval只存在于经过审批的defineTool调用里。不需要审批的调用没有它,workflow 工具也没有。ctx.session.auth.current始终是发起人,不会变成审批人。ctx.approval.responder.getToken()不会发起登录。审批人没 token 时抛ConnectionAuthorizationFailedError,reason: "approver_authorization_required"。
这个字段从 eve@0.79.1 起可用。更早的版本上写 ctx.approval 会类型报错,只能退回到在 approval.response 里把审批人记到自己的存储,工具执行时再查。
11.7 defineWorkflowTool:能挂起的工具
前面所有例子都是 defineTool。它的 execute 在一次 step 里跑完,跑完之前 turn 一直等着。需要在工具内部持久等待(等人、等 webhook、等定时),或者让对话先继续、结果稍后送到时,用 defineWorkflowTool。
和 defineTool 的差别
defineTool |
defineWorkflowTool |
|
|---|---|---|
| 执行位置 | 发起它的那个 step 内 | 独立的 durable workflow run |
| 能否持久等待 | 否,只能阻塞 turn | 是,ctx.ask / sleep / webhook |
| 等待时占算力 | 占(turn 卡在这里) | 不占(workflow 挂起,进程可退) |
| 对话能否继续 | 不能 | task 入口可以 |
可用 ctx.getSandbox() |
可以 | 不可以 |
可用 ctx.ask / ctx.agent |
不可以 | 可以 |
一个判断口诀:工具里需要「等」,而且等的可能是几小时甚至几天 → workflow 工具;否则用 defineTool。
三个入口点,只能选一个
// ① execute:阻塞 turn,跑完才回话
async execute(input, ctx) { "use workflow"; /* … */ return result; }
// ② task:立刻返回收据,结果稍后以 task.result 消息送达
async task(input, ctx) { "use workflow"; /* … */ return result; }
// ③ serve:可续接的任务,模型可以按 taskId 继续给它发消息
async serve(receive, ctx) { "use workflow"; /* … */ }
defineWorkflowTool 的字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
description |
string |
是 | 模型靠它决定调不调 |
inputSchema |
Standard Schema / JSON Schema | 是 | 入参。必须是 JSON 对象,不能是数组或标量 |
execute |
(input, ctx) => Promise<T> \| AsyncIterable<T> |
三选一 | 阻塞 turn,跑完才回话 |
task |
同上 | 三选一 | 立刻返回收据,结果稍后以 task.result 送达 |
serve |
(receive, ctx) => Promise<T> |
三选一 | 可续接任务,模型可按 taskId 追加消息 |
outputSchema |
JSON Schema | 否 | 返回值类型 |
approval |
ApprovalPolicy \| { request, response?, prompt? } |
否 | 审批。在 workflow 启动之前生效 |
approvalKey |
(input) => string |
否 | 审批记忆的键 |
toModelOutput |
(output) => ToolModelOutput |
否 | 模型看到的投影(task 投影的是结果,不是收据) |
deferred |
boolean,默认 false |
否 | 不进工具列表 |
availableInSubagents |
boolean,默认 true |
否 | false 时委派会话拿不到 |
label |
{ start, delta?, complete? } |
否 | UI 活动文案 |
没有 endsTurn:workflow 工具在构建期拒绝它(和 execution 一样,后者会提示改用 task)。
| 入口 | 模型看到什么 | 什么时候用 |
|---|---|---|
execute |
普通工具调用,等结果 | 流程必须拿到结果才能继续(比如「部署完再告诉我成功没」) |
task |
立刻收到 Started task deploy-4hd8sa. Its result will arrive in a <task_result> message. |
长任务,模型可以先干别的 |
serve |
收据里带 taskId,之后可以继续发消息 |
需要来回改(比如「草案 → 按意见修改 → 再改」) |
task 和 serve 的调用都会变成 task:turn 在任务跑完之前不能结束;模型用 eve__task_wait 等结果、eve__task_cancel 停任务;取消时 run 最多等 30 秒让 body 收尾。
serve 长什么样
serve 不接收输入,而是从 receive() 一个个取调用:
type Plan = { summary: string };
export default defineWorkflowTool({
description: "起草发布计划,并按意见修改。",
inputSchema: z.object({ request: z.string() }),
async serve(receive, ctx) {
"use workflow";
let plan: Plan | undefined;
for (;;) {
const { input, abortSignal } = await receive(); // 第一次是发起调用,之后是按 taskId 续接
try {
plan = await revise(plan, input.request, { signal: abortSignal });
ctx.reply(plan); // 交回一次结果,任务回到空闲
} catch (error) {
if (!abortSignal.aborted) throw error; // 被取消就等下一次调用
}
}
},
});
三条 serve 特有的规则:
receive()的第一次调用立刻返回(就是启动任务的那次),不产生等待。之后每次按taskId到达的调用按顺序返回。- eve 会自动往模型输入里加一个可选的
taskId,并在工具描述里补一句说明。所以inputSchema里不能自己声明taskId,构建会失败。 taskId必须指向这个工具启动过、且还没结束的任务,否则调用失败(UNKNOWN_TASK)。任务结束后用旧taskId会失败;不带taskId才是开新任务。
五条硬规则
export default defineWorkflowTool({
description: "…",
inputSchema: z.object({ service: z.string() }),
async execute({ service }, ctx) {
"use workflow"; // ① 必须是第一句
const plan = await buildPlan(service); // ② 副作用放 step
await sleep("10m"); // ③ 持久等待
return applyPlan(plan);
},
});
async function buildPlan(service: string) {
"use step"; // ④ 标记为持久步骤
// process.env、时钟、随机、Node API 都在这里
}
"use workflow"必须是执行体的第一条语句。 缺了是构建错误,即使同模块别的函数有。"use step"标记顶层async function。 副作用、process.env、时钟、随机数、Node API 只能放这里。- workflow 主体会被重放,必须确定性。 等待之后还要用的值放局部变量——重放会重建它们,但不会重跑已完成的 step。
- 只能定义一个入口点。
execute/task/serve三选一,多写或都不写会在定义时抛错。 - 输入必须是 JSON 对象,且只能静态定义。 不能从
defineDynamic返回;defineWorkflowTool也不接受endsTurn和execution(后者会提示改用task)。
从 workflow 导入的是 sleep、createHook、createWebhook、FatalError;start、getRun、resumeHook 属于 workflow/api,只能在 step 里用。类型提示要在 tsconfig.json 的 types 里加 eve/workflow-modules。
上下文:workflow 里能拿到什么
| 字段 | 说明 |
|---|---|
ctx.session |
会话身份、turn、auth |
ctx.callId / ctx.toolName |
本次调用 |
ctx.abortSignal |
调用被取消 / steering 时触发 |
ctx.ask(request, options?) |
问人,返回 { status, optionId?, text? } |
ctx.agent(name) |
开一个子代理会话(.send() → .result()) |
ctx.agents |
可调用的 agent 注册表 |
ctx.getToken(provider) / requireAuth |
连接凭据 |
serve 的 ctx 没有 callId 和 abortSignal——这两个随每次 receive() 拿到的调用传入,因为一个 serve 任务会服务很多次调用。
没有 ctx.getSandbox()。 workflow 主体和 "use step" 函数都不在沙箱上下文里。需要跑命令就把它写成一个普通工具,让模型去调;或者在 step 里调外部服务。
"use step" 函数如果直接收 ctx,拿到的是更窄的 WorkflowStepToolContext:只有 session、callId、toolName、abortSignal、getToken、requireAuth。ctx.ask、ctx.agent、ctx.agents 只能在 workflow 主体里用。
进度与返回值
execute / task 可以写成 async generator:
| 写法 | 行为 |
|---|---|
yield value |
发 action.partial 快照,不进模型历史 |
return value |
工具结果 |
没有 return |
用最后一次 yield 当结果;一次都没 yield 就是 null |
return null |
同样回退到最后一次 yield |
async *execute({ note, delay }, ctx) {
"use workflow";
yield { status: "准备提醒" }; // 进度
await sleep(delay); // 持久等待
return { reminder: note }; // 结果
}
注意:yield 不是等待。它只是把快照交出去然后继续执行。要挂起必须 await 一个工作流操作(ctx.ask、sleep、hook、webhook)。
错误与取消
- 抛错 → 这个调用失败,模型收到错误;run 按 step 重试策略处理(第 10 章)。
FatalError(从workflow导入)→ 标记为不可重试的失败:跳过 step 重试,直接让这次调用失败。ctx.abortSignal在取消、steering、turn 失败、会话结束时触发。清理放try/finally:
async execute({ projectId }, ctx) {
"use workflow";
const jobId = await submitRender(projectId);
try {
return await waitForRender(jobId, ctx.abortSignal);
} finally {
await cancelRender(jobId); // 取消时也要收尾
}
}
steering 只影响 execute 调用,不会中断 task。
审批挂在哪一层
这是最容易设计错的地方。defineWorkflowTool 也接受 approval,而且审批在 workflow 启动之前:
export default defineWorkflowTool({
description: "把服务部署到生产环境。",
inputSchema: z.object({ service: z.string(), version: z.string() }),
approval: {
request: always(),
prompt: ({ input }) => `部署 ${input.service}@${input.version} 到生产?`,
},
async execute({ service, version }, ctx) {
"use workflow"; // 批准之后才走到这里
return deploy(service, version);
},
});
对比两种「确认」:
approval(工具级) |
ctx.ask(流程内) |
|
|---|---|---|
| 时机 | 调用之前,workflow 还没启动 | workflow 跑到中途 |
| 被拒绝 | 工具完全不执行 | 由 body 自己决定怎么处理 |
| 适合 | 「这个动作能不能做」 | 「做到这一步了,下一步选哪个」 |
一个部署工具通常两个都要:approval 管「要不要部署」,ctx.ask 管「部署到一半发现指标异常,继续还是回滚」。
完整例子:带审批、进度、两步确认的部署
// agent/tools/deploy_service.ts
import { defineWorkflowTool } from "eve/tools";
import { always } from "eve/tools/approval";
import { sleep } from "workflow";
import { z } from "zod";
type Plan = { id: string; summary: string };
export default defineWorkflowTool({
description: "把服务部署到指定环境。部署前需要人工批准,过程中可能再次确认。",
inputSchema: z.object({
service: z.string(),
environment: z.enum(["preview", "production"]),
version: z.string(),
}),
// 第一道关:动作本身要不要做
approval: {
request: always(),
prompt: ({ input }) =>
`部署 ${input.service}@${input.version} 到 ${input.environment}?`,
},
async *execute({ service, environment, version }, ctx) {
"use workflow";
yield { phase: "生成部署计划" };
const plan: Plan = await buildPlan(service, version);
// 第二道关:计划没问题吗
const ok = await ctx.ask({
prompt: `部署计划:\n${plan.summary}\n\n开始执行?`,
display: "confirmation",
options: [
{ id: "go", label: "开始部署", style: "primary" },
{ id: "stop", label: "取消", style: "danger" },
],
});
if (ok.status !== "answered" || ok.optionId !== "go") {
return { deployed: false, reason: "operator cancelled" };
}
yield { phase: "部署中" };
const result = await applyPlan(plan); // "use step"
yield { phase: "观察指标" };
await sleep("5m"); // 持久等待,不占算力
const health = await checkHealth(service); // "use step"
if (!health.ok) {
const rollback = await ctx.ask({
prompt: `部署后错误率 ${health.errorRate}%,回滚?`,
display: "confirmation",
options: [
{ id: "rollback", label: "回滚", style: "danger" },
{ id: "keep", label: "保持观察" },
],
});
if (rollback.status === "answered" && rollback.optionId === "rollback") {
await rollbackDeploy(plan); // "use step"
return { deployed: false, rolledBack: true, health };
}
}
return { deployed: true, environment, health };
},
});
async function buildPlan(service: string, version: string): Promise<Plan> {
"use step";
return computePlan(service, version);
}
async function applyPlan(plan: Plan): Promise<{ url: string }> {
"use step";
return runDeploy(plan); // 幂等:用 plan.id 做上游幂等键
}
async function checkHealth(service: string) {
"use step";
return fetchHealth(service);
}
async function rollbackDeploy(plan: Plan): Promise<void> {
"use step";
return rollback(plan);
}
这个例子里,三个机制各司其职:
approval拦住「不该部署」。ctx.ask处理「部署到一半的判断」。sleep+yield让流程能挂 5 分钟观察指标,同时给用户看进度。
11.8 提问:ask_question 与 ctx.ask
两个东西经常被混:
ask_question(工具) |
ctx.ask()(workflow 内) |
|
|---|---|---|
| 谁调用 | 模型 | 你的 workflow body |
| 返回给谁 | 模型,{ status: "answered", answer } |
代码,{ status: "answered", optionId?, text? } |
| 返回字段 | answer(选项 label 或用户原话) |
optionId / text |
| 适合 | 模型自己判断需要澄清 | 流程需要人做个决定 |
装 ask_question:
eve add tool/ask_question
// agent/tools/ask_question.ts
import { askQuestion } from "eve/tools/ask_question";
export default askQuestion();
模型调用时传 { question, options? }:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
question |
string |
是 | 问题本身,要带足够上下文 |
options |
{ label: string; description: string }[] |
否 | 两到三个互斥选项;不传就是开放式问题 |
选项不带 id——ask_question 内部把 label 当标识,模型拿到的是 answer(选项 label 或用户原话)。用户永远可以自己打字回答,所以不需要「其他」选项。
ctx.ask 的完整签名:
const answer = await ctx.ask(
{
prompt: string,
display?: "confirmation" | "select" | "text",
options?: { id: string; label: string; description?: string; style?: "primary" | "danger" | "default" }[],
allowFreeform?: boolean,
},
{ signal?: AbortSignal },
);
// { status: "answered"; optionId?: string; text?: string }
// | { status: "cancelled" }
// | { status: "unavailable" }
请求对象的字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
prompt |
string |
是 | 展示给人看的完整问题 |
display |
"confirmation" \| "select" \| "text" |
否 | 渲染提示:确认框 / 下拉 / 文本框 |
options |
{ id, label, description?, style? }[] |
否 | 选项。id 是回传值,label 是人看到的 |
allowFreeform |
boolean |
否 | 允许不选选项直接打字 |
选项的 style 取值 "primary" / "danger" / "default",只影响按钮样式。id 要能单独读得通(approve 而不是 opt1),因为回传只带 id;有人在旧界面上答晚了,模型会把这个 id 当成一条新消息收到。
第二个参数目前只有 signal。
三个返回值的含义:
answered:人选了选项(optionId)或打了字(text)。cancelled:问题被撤回(signal 中止、调用被取消、serve 已经 reply)。unavailable:会话无法请求输入(定时任务)。这时不要重试,按默认路径继续。
11.9 暂停与恢复协议
sequenceDiagram
participant M as 模型
participant E as eve
participant C as 客户端 / 通道
participant R as 会话 run
M->>E: 调用 restart_service
E->>E: approval 策略返回 user-approval
E->>R: 把这次调用交给会话 run
R->>R: 写检查点,进入停泊
R-->>C: input.requested
R-->>C: turn.waiting(on: "input")
Note over R: 释放算力,可以过三天
C->>E: respond({ requestId, optionId: "approve" })
E->>R: 投递应答
R-->>C: approval.candidate(pending)
R-->>C: approval.settled(approved)
R-->>C: input.resolved
R->>R: 重放到停泊点,继续执行工具
R-->>M: action.result
M-->>R: 最终回复
第 3 步和第 4 步之间的时间可以是任意长。这中间没有任何进程在等——run 的状态在存储里,进程可以退出、重启、换部署。恢复时不是「接着内存里的调用栈」,而是从检查点重放到停泊点(第 10 章)。
补充规则:
- 每个请求有
kind:tool-approval、question、session-limit。客户端按kind决定怎么渲染。 - 审批是异步确认:提交后要等
approval.settled或input.resolved才算数。 - 第一个答案决定结果(
eve@0.81起)。重复提交被丢弃,不会因为双击而把已取消的审批改回批准:先 Cancel 再 Approve,结果仍是取消。随决定一起发的备注(比如为什么取消)照常进模型。 - 带
requester的调用,未认证的应答会被拒绝并给出反馈,不能抢在发起人前面替他决定。调用没有requester时,未认证应答按none()给匿名调用者的身份记录。 - 一个 turn 可以同时等多个请求。普通文本消息只回答第一个未答的请求。
- 审批请求时,来自该 turn 服务对象的不匹配消息会 steering 并撤销审批(
input.resolved的outcome: "ignored")。同一批里已经批准的调用仍会执行。 - 取消 turn 会撤回它的审批(
outcome: "cancelled"),之后再回答那个请求不会批准任何东西。 - 卡片上的按钮所属会话已经失效(例如 run 在 eve 之外被取消)时,Slack / Teams / Telegram / Discord 一致地把按钮换成
No longer active,并私下告诉点击的人重新发一条消息。
11.10 客户端怎么接
浏览器里用 eve/react 的 openConversationInputs(),它已经帮你过滤掉 settled 的请求:
import { openConversationInputs } from "eve/react";
const pending = openConversationInputs(agent.data).map((input) => input.request);
await agent.respond([
{ requestId: pending[0].requestId, optionId: "approve" },
]);
用 Client 的话,从 turn 结果的 inputRequests 里拿 requestId,respond 收的是数组:
const { session, response } = await client.sessions.create({
message: "把 payment-api 重启一下",
});
const turn = await response.result();
const request = turn.inputRequests[0];
if (request) {
const after = await session.respond([
{ requestId: request.requestId, optionId: "approve" },
]);
await after.result();
}
拒绝时带理由:
await session.respond([
{ requestId: request.requestId, optionId: "cancel", text: "只批准三件套,别全量重启" },
]);
用原生 HTTP 就是:
curl -X POST http://localhost:2000/eve/v1/session/wrun_A \
-H 'content-type: application/json' \
-d '{"inputResponses":[{"requestId":"req_A","optionId":"approve"}]}'
拒绝时:
{"inputResponses":[{"requestId":"req_A","optionId":"cancel","text":"只批准三件套,别全量重启"}]}
理由会引用给模型,并标明是拒绝人写的。
11.11 定时任务里的审批
定时任务用应用身份(authenticator: "app"、principalId: "eve:app"、principalType: "runtime")跑。想让自动化跳过审批、但人来调时仍然要批:
const skipForSchedules: ApprovalPolicy = ({ session }) => {
const auth = session.auth.current;
return auth?.authenticator === "app" &&
auth.principalId === "eve:app" &&
auth.principalType === "runtime"
? "not-applicable"
: "user-approval";
};
危险:跳过审批意味着非幂等的副作用会在 step 重放时再触发一次。配幂等键一起用。
反过来,markdown 形式的定时任务根本不能请求输入。如果它调到需要审批的工具,会话直接失败,不会挂起等待。这是有意的:没人看着的任务不应该无限期挂着。
11.12 完整例子:三道关
// agent/tools/rollback_deploy.ts
import { defineTool } from "eve/tools";
import { type ApprovalPolicy, type ApprovalResponsePolicy } from "eve/tools/approval";
import { z } from "zod";
type RollbackInput = { service: string; version: string; reason: string };
// 第二道:谁能批
const onCallOnly: ApprovalResponsePolicy<RollbackInput> = ({ response }) => {
const roles = response.principal.attributes.roles;
return Array.isArray(roles) && roles.includes("oncall")
? { status: "allowed" }
: { status: "rejected", reason: "只有 on-call 可以批准回滚。" };
};
// 第一道:要不要批(这里恒为要,留出按服务细分的扩展点)
const rollbackApproval: ApprovalPolicy<RollbackInput> = () => "user-approval";
export default defineTool({
description: "把服务回滚到指定版本。会重启全部实例。",
inputSchema: z.object({
service: z.string(),
version: z.string().regex(/^v\d+\.\d+\.\d+$/),
reason: z.string().min(10),
}),
approval: {
request: rollbackApproval,
prompt: ({ input }) =>
`回滚 ${input.service} 到 ${input.version}?\n原因:${input.reason}\n` +
`影响:滚动重启,约 30 秒部分不可用。`,
response: onCallOnly,
},
label: { start: ({ service, version }) => `回滚 ${service} 到 ${version}` },
async execute({ service, version, reason }, ctx) {
const res = await fetch(`${process.env.OPS_API}/deployments/rollback`, {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": `rollback:${ctx.callId}`, // 第三道:防重放
},
body: JSON.stringify({
service,
version,
reason,
requestedBy: ctx.session.auth.current?.principalId,
}),
});
if (!res.ok) throw new Error(`回滚失败:${res.status} ${await res.text()}`);
return { service, version, rolledBack: true };
},
});
三个设计点:
prompt写清影响范围——审批人需要知道自己在批什么。response限制谁能批——不能只靠「发起人」。execute传幂等键——审批不能防重放。
11.13 常见坑
坑:以为不写 approval 就是安全的。
默认 never()。危险工具必须显式声明。
坑:把 approval 写成对象却忘了 request。
对象形态里 request 是必填的。只想要文案?request: always() 也得写。
坑:审批之后模型「忘了」之前的上下文。
不会。审批是停泊不是重启,turnId 和上下文都保留。如果你看到上下文丢失,多半是别的地方 clear 或压缩了。
坑:在共享 Slack 线程里,任何人都能批准。
默认只有发起人能批。如果自定义了 response 策略又一律放行,就变成谁都能批。
坑:用审批代替幂等。 重放时审批已通过,工具会再跑。两者都要。
坑:ctx.approval 在 workflow 工具里取。
workflow 工具不接收 ctx.approval。需要审批人身份就用 defineTool。
坑:once() 以为只弹一次。
同一个 step 里并发发起三个调用时,三个请求都会弹。授权要等所有已存在的待批请求都解决之后才生效。
坑:在 workflow body 里调 ctx.getSandbox()。
workflow 上下文没有沙箱。要跑命令就写普通工具。
坑:用 yield 当等待。
yield 只发进度快照,不会挂起。要挂起就 await sleep(...) 或 await ctx.ask(...)。
坑:在 "use step" 里调 ctx.ask。
step 拿到的是窄上下文,没有 ask。提问要写在 workflow 主体里。
坑:定时任务调用了要审批的工具,任务一直挂着。 markdown 定时任务会直接失败。handler 形式的任务看它发到哪个通道。
坑:ask_question 的选项写成四个以上。
协议建议两到三个。太多选项在文本通道上无法渲染成可用 UI。
11.14 练习
- 给
restart_service依次改成三个版本(只 gate → 加文案 → 按服务名判断),观察每次的行为差异。 - 把
refundApproval改成具名策略,加一条「跨租户直接 denied」,用两个租户的假鉴权验证。 - 加一个
response策略,只允许roles含oncall的人批准,并验证其他人提交后请求仍然待处理。 - 写一个
defineWorkflowTool,用approval做入口关、ctx.ask做流程内确认、sleep做持久等待,然后用Ctrl+C重启验证能续上。 - 把同一个部署工具改成
task入口,观察模型收到的收据和后续的task.result消息。