eve 中文教程 · 第 9 章 会话协议
每个 eve 应用都暴露同一套 HTTP API:建会话、发消息、看事件流、取消、清空、压缩、重置。这一章讲清协议本身,以及怎么用 TypeScript 客户端而不是手搓 POST。
9.1 路由表
| 方法 | 路径 | 作用 |
|---|---|---|
GET |
/eve/v1/health |
存活检查,公开 |
GET |
/eve/v1/info |
编译后 agent 的完整快照 |
POST |
/eve/v1/session |
建会话(可带首条消息) |
POST |
/eve/v1/session/:id |
追问 / 回答审批 |
POST |
/eve/v1/session/:id/cancel |
取消进行中的 turn |
POST |
/eve/v1/session/:id/clear |
清空模型消息历史 |
POST |
/eve/v1/session/:id/compact |
压缩上下文 |
POST |
/eve/v1/session/:id/reset |
终止会话 |
GET |
/eve/v1/session/:id/stream |
NDJSON 事件流 |
所有会话操作只用 sessionId 寻址,没有别的句柄。通道(Slack 线程等)有自己的 continuation token,但那不出通道边界。
9.2 建会话
只建不聊:
curl -X POST http://localhost:2000/eve/v1/session
# {"ok":true,"sessionId":"wrun_A","status":"accepted"}
建并说第一句:
curl -X POST http://localhost:2000/eve/v1/session \
-H 'content-type: application/json' \
-d '{"message":"payment-api 现在正常吗?"}'
返回 202 + sessionId(body 和 x-eve-session-id 头都有)。
POST /eve/v1/session 的请求体:
| 字段 | 类型 | 说明 |
|---|---|---|
message |
string \| UserContent |
第一条消息。不带则只建会话 |
context |
string[] |
持久化到历史的用户角色上下文,后续轮次也能看到 |
clientContext |
string \| string[] \| object |
只对本轮生效,不写历史;要求同时带 message |
outputSchema |
JSON Schema | 要求这一轮产出结构化结果;同样要求带 message |
operationId |
string |
幂等键,同一 principal 重试拿到同一个会话;匿名不能用 |
stubs |
ToolStub[] |
工具桩,固定给这个会话 |
追问 POST /eve/v1/session/:id 的请求体接受 message 或 inputResponses 二选一(回答人工输入时用后者),可附带 context / clientContext / outputSchema / turnPolicy。
两个细节:
- 只建会话时,
session.started不会立刻发,要等第一条消息。因为会话级初始化需要消息才能决定第一轮的上下文。 - 立刻追问可能收到
409 session_not_ready,此时用有界退避重试。TypeScript 客户端会自己重试最多 20 秒。
幂等创建(网络重试安全):
curl -X POST http://localhost:2000/eve/v1/session \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"message":"生成周报","operationId":"weekly-2026-10-20"}'
同一个 principal 用同一个 operationId 重试,拿到的是同一个会话,不会重复启动。匿名调用不能用 operationId。
9.3 事件流
一次完整交互里,四个参与者之间的消息是这样走的:
sequenceDiagram
participant C as 客户端
participant E as eve HTTP 通道
participant R as 会话 run
participant M as 模型
C->>E: POST /eve/v1/session { message }
E-->>C: 202 + sessionId
E->>R: 派发第一轮
R->>M: step 0 模型调用
M-->>R: 文本增量 / 工具调用
R-->>C: NDJSON 事件流(message.appended 等)
R->>R: 执行工具,提交 step
R->>M: step 1 模型调用
M-->>R: 最终回复
R-->>C: turn.completed
R-->>C: session.waiting
C->>E: POST /eve/v1/session/:id { message }
注意事件流是单向的:客户端不往流里写东西,追问和应答都走独立的 POST。流只负责把 run 里发生的事按顺序吐出来。
curl http://localhost:2000/eve/v1/session/wrun_A/stream
一行一个 JSON。核心事件:
| 事件 | 含义 |
|---|---|
session.started |
会话建立 |
turn.started |
新 turn 开始 |
message.received |
输入到达(含纯文本和结构化 parts) |
step.started |
一次模型调用开始 |
actions.requested |
模型请求了动作(含工具调用),在执行前就流出 |
action.result |
工具返回 |
message.appended / message.completed |
助手文本增量 / 定稿 |
reasoning.appended / reasoning.completed |
推理增量 / 定稿 |
input.requested |
停下来等人(审批或提问) |
approval.candidate |
有人提交了审批,服务端开始校验(配了 response 策略时才有) |
approval.settled |
审批落定:approved / cancelled |
input.resolved |
人已应答,服务端已接受 |
turn.waiting |
turn 停泊(on: "input" 或 "tasks"),没结束 |
turn.completed / turn.failed / turn.cancelled |
turn 的三种结局 |
session.waiting |
会话空闲,可以接下一句 |
task.started / task.settled |
任务(子代理等)开始 / 结束 |
compaction.requested / compaction.completed |
压缩 |
authorization.required / authorization.completed |
连接需要 OAuth |
完整表见附录 B。
事件信封
{
"type": "message.completed",
"data": {
"message": "payment-api 正常,p99 82ms。",
"finishReason": "stop",
"sequence": 0,
"stepIndex": 0,
"turnId": "turn_0"
},
"meta": { "id": "evt_01KYJBZA88B4M9XN3RTC5FDGHJ", "at": "2026-10-20T01:04:11.912Z" }
}
meta.id 是 evt_ 前缀的 ULID,写进持久流时生成一次,重连和回绕都返回同一个 id。所以它是落库去重的天然主键:
insert into agent_events (id, session_id, type, data, emitted_at)
values ($1, $2, $3, $4, $5)
on conflict (id) do nothing;
但它不覆盖重试:step 被打断重跑时,新一次尝试会发新的 id。所以「去重」和「幂等」是两件事,第 10 章讲。
增量 vs 定稿
message.appended 只带新增的文本(messageDelta),要自己累加。message.completed 带该块的权威全文。重连时如果本地丢了累加状态,就等 completed。
一个 turn 里 message.completed 可能出现多次(模型先说话、再调工具、再说结论)。区分「工具前的话」和「最终答复」看 data.finishReason。
9.4 用 TypeScript 客户端
别手搓 POST 和 NDJSON 循环。
import { Client } from "eve/client";
const client = new Client({ host: "http://127.0.0.1:2000" });
// 先探活
const health = await client.health();
console.log(health.status, health.workflowId);
// 建会话并等这一轮跑完
const { response } = await client.sessions.create({
message: "payment-api 现在正常吗?",
});
const turn = await response.result();
if (turn.status === "failed") throw new Error("这一轮失败了");
if (turn.inputRequests.length > 0) {
console.log("需要人处理:", turn.inputRequests.map((r) => r.prompt));
}
console.log(turn.message);
for (const event of turn.events) {
if (event.type === "action.result") console.log(event.data.result);
}
需要鉴权时:
const client = new Client({
host: "https://opsbot.example.com",
auth: { bearer: async () => await getAccessToken() },
redirect: "manual", // 别把 Authorization 转发到别的域
});
Client 的构造参数:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
host |
string |
是 | agent 的基址。query 参数会带到每个请求 |
auth |
{ bearer } / { basic: { username, password } } / { vercelOidc: { token } } |
否 | 每次请求前解析,token 刷新回调会被反复调用 |
headers |
Record<string, string> \| () => … |
否 | 每个请求都带的头;传函数可每次重新计算 |
redirect |
"follow" \| "manual" \| "error" |
否 | 带凭据的客户端用 "manual" 或 "error",避免把 Authorization 转发到别的域 |
create() / send() 的选项:
| 字段 | 类型 | 说明 |
|---|---|---|
message |
string \| UserContent |
本轮输入。create() 必填 |
outputSchema |
Standard Schema / JSON Schema | 要求这一轮产出结构化结果,落到 result.data |
turnPolicy |
"steer" \| "queue" |
turn 进行中收到新消息时怎么办,默认 "steer" |
clientContext |
string \| string[] \| JsonObject |
只对本轮生效的页面上下文,不写历史 |
stubs |
ToolStub[] |
工具桩,仅 create() 可传,固定给这个会话 |
streamReconnectPolicy |
{ reconnect: boolean } |
自己管游标时关掉自动重连 |
signal |
AbortSignal |
取消这次请求 |
headers |
Record<string, string> |
只对这个请求生效,覆盖客户端级同名头 |
response.result() 返回的 turn:
| 字段 | 类型 | 说明 |
|---|---|---|
status |
"completed" \| "waiting" \| "failed" |
这一轮怎么结束的。被取消的 turn 报 "waiting" |
message |
string \| undefined |
最终助手文本;没有终态 message.completed 时为 undefined |
data |
TOutput \| undefined |
请求了 outputSchema 时的结构化结果 |
events |
MessageStreamEvent[] |
这一轮收到的全部事件 |
inputRequests |
InputRequest[] |
边界处仍待处理的人工输入(更早的可能还开着) |
sessionId |
string |
本轮的会话 ID,总是有值 |
turnId 不在返回值里——从 turn.started 事件里取,取消时要用它。要按工具调用断言,扫 events 里的 actions.requested / action.result 自己配对。
流式消费。create() 返回 { session, response },要迭代的是 response,不是 session:
const { session, response } = await client.sessions.create({
message: "查一下错误率",
});
console.log("会话", session.id);
for await (const event of response) {
switch (event.type) {
case "message.appended":
process.stdout.write(event.data.messageDelta);
break;
case "action.result":
console.log("\n[工具]", event.data.result.toolName);
break;
case "turn.completed":
console.log("\n[done]");
break;
}
}
session.stream() 是另一件事:它按会话游标接上已有的流,用于重连或读历史,不会发起新的一轮:
// 读已记录的历史,不跟随
for await (const event of session.stream({ follow: false })) {
console.log(event.type);
}
// 从第 120 个事件继续
for await (const event of session.stream({ startIndex: 120 })) {
// ...
}
response.result() 等到本轮 settle,返回 { message, data, status, events, inputRequests, ... }。data 是请求了 outputSchema 时的结构化结果,message 是普通文本回复。
9.5 追问:steer 还是 queue
默认 turnPolicy: "steer"。含义:
- 助手还没开始输出时,打断正在进行的模型生成,把你的新消息并进同一个 turn,
turnId不变。 - 正在执行的工具会安全跑完并提交结果,然后才应用你的修正。
- 助手已经开始输出后,steering 等到下一个已提交的 workflow 边界生效,已经流出的文本保留。
await session.send("等一下,只看 payment-api", { turnPolicy: "queue" });
queue 的含义是「等当前 turn 结束再处理」。注意:如果 turn 正在等人审批,来自该 turn 服务对象的消息即使 queue 也会 steering 并撤销审批——因为等人审批的 turn 不可能自己结束。
多个 steering 消息按到达顺序保留,可能在下一个边界合并成一个输入。
9.6 回答审批和提问
curl -X POST http://localhost:2000/eve/v1/session/wrun_A \
-H 'content-type: application/json' \
-d '{"inputResponses":[{"requestId":"req_A","optionId":"approve"}]}'
- 一次请求只能带
message或inputResponses之一。 requestId来自input.requested事件。- 拒绝时带上理由:
{"requestId":"req_A","optionId":"cancel","text":"只批准三件套"},模型会看到这段引用,并标明是拒绝人写的。 - 纯文本追问如果匹配某个选项 ID、标签或序号,也会被当作回答(
approve/cancel/continue)。 - 有多个待答请求时,一条文本只回答第一个。要一次答多个,发多条或用结构化响应。
客户端侧:提交不等于批准。要等 approval.settled 或 input.resolved 才算服务端接受。
9.7 取消、清空、压缩、重置
# 取消(异步,等流里的 turn.cancelled → session.waiting)
curl -X POST http://localhost:2000/eve/v1/session/wrun_A/cancel \
-H 'content-type: application/json' -d '{"turnId":"turn_3"}'
curl -X POST http://localhost:2000/eve/v1/session/wrun_A/clear
curl -X POST http://localhost:2000/eve/v1/session/wrun_A/compact
curl -X POST http://localhost:2000/eve/v1/session/wrun_A/reset \
-H 'content-type: application/json' -d '{"reason":"换个话题"}'
| 操作 | 边界事件 | 说明 |
|---|---|---|
| cancel | turn.cancelled → session.waiting |
不是失败。已流出的内容保留,未完成的助手输出丢弃 |
| clear | context.cleared → session.waiting |
删模型消息历史,保留会话身份、状态、沙箱 |
| compact | compaction.requested → compaction.completed → session.waiting |
摘要,不追加用户消息 |
| reset | 会话终结 | ID 作废,永远不会复用 |
取消会连带取消所有工作中的任务(task.settled 带 status: "cancelled")。带 turnId 可以防止迟到的取消请求误杀新的 turn。
9.8 重连与回绕
# 从第 120 个事件继续
curl "http://localhost:2000/eve/v1/session/wrun_A/stream?startIndex=120"
# 读最后 1 条(通常是 session.waiting)
curl "http://localhost:2000/eve/v1/session/wrun_A/stream?startIndex=-1"
# 只读到当前末尾,不跟随
curl "http://localhost:2000/eve/v1/session/wrun_A/stream?startIndex=0&follow=false"
- 非负
startIndex是绝对事件计数。 - 负数从末尾往前数。尾部相对读取不会自动重连,也不推进已存游标。
follow=false在服务端就在持久末尾截断,适合「读历史再决定」的场景。它对 stranded 会话也能用。- 会话结束后,World 只按保留期留着它的流。超过保留期再读返回
410,code: "session_expired"。历史已经没了,TypeScript 客户端在第一个响应就失败,不会重试几十秒。
9.9 子代理的事件
子代理有自己的会话流。父会话上会出现:
task.started(kind: "agent"):调用返回收据agent.started:子会话打开,带sessionId和streamPathtask.settled:子代理这一轮的回复作为output
要跟子代理的详细过程,用 agent.started 拿 streamPath 再开一条流:
const started = events.find((e) => e.type === "agent.started");
const child = session.agent(started);
for await (const e of child.stream()) { /* 子代理的 step / 工具事件 */ }
子代理的审批和提问会代理到父会话上,回答通过父会话直接路由给子代理,不会触发父级模型。
9.10 在进程内读会话
钩子、工具、定时任务里想读别的会话,不用走 HTTP:
import { sessions } from "eve/server";
for await (const event of sessions.attach(sessionId).stream({
startIndex: -2000,
follow: false,
signal: AbortSignal.timeout(5_000),
})) {
await saveEvent(event.meta.id, event);
}
注意:sessions 读任何 ID 都不做通道鉴权,传进来的 ID 必须先校验归属。
9.11 常见坑
坑:只看 turn.completed 就以为拿到了完整回复。
一个 turn 里可能有多次 message.completed。最终答复要取最后一个 finishReason: "stop" 的块,或直接看 turn.message(客户端已处理)。
坑:重连后重复处理事件。
按 meta.id 去重。不要用「内容相同」判断。
坑:把 turn.waiting 当成结束。
它表示 turn 还开着,在等人或在等任务。收到它就断开连接会丢掉后续事件。
坑:clear 之后以为能重新触发动态指令。
不会重跑指令定义或解析器。
坑:用 reset 想「重开一段对话」。
reset 是终止,ID 作废。想在同一会话里换话题用 clear,想全新会话就新建。
坑:匿名调用带 operationId。
不允许。幂等创建需要已认证的 principal。
9.12 练习
- 用
curl走完「建会话 → 看流 → 追问 → 取消」四步,记录每步的事件顺序。 - 用
Client写一个脚本,发一句话,打印所有工具调用名和耗时。 - 故意在模型输出中途发一条 steering 消息,对比
queue的行为差异。 - 用
follow=false读一个已完成会话的完整历史,统计事件类型分布。
延伸阅读
上一章:08 状态与长期记忆 · 下一章:10 持久化执行