Skip to main content
Agent 专题

事件流与 Action

Agent Run 通过 Server-Sent Events(SSE)输出公开进度、回复文本、工具输入、积分结算、预览地址和交互请求。

订阅事件

GET /projects/{project_id}/agent/runs/{run_id}/events
所需权限:agent.read
curl --no-buffer \
  --url "${MEOO_BASE_URL}/open/v1/projects/${PROJECT_ID}/agent/runs/${RUN_ID}/events" \
  --header "Authorization: Bearer ${ACCESS_TOKEN_OR_API_KEY}" \
  --header "Accept: text/event-stream"
Token 必须放在 Authorization Header 中;不要放入 URL Query。 Write/Edit 正文随事件流默认发送,不需要额外 Query 参数或 Scope。Read 文件名、Skill 名称及工具正文均使用普通 agent.read 授权;客户端必须忽略未知事件及新增字段。

SSE 帧

每个业务事件帧由 id、event 和 JSON data 组成:
id: 1
event: run.working
data: {"run_id":"run_01JEXAMPLE","status":"working"}
  • id 只在本次连接内从 1 单调递增。
  • 心跳使用 SSE 注释帧,不包含业务数据,也没有 id。
  • 服务端不支持 Last-Event-ID 断点续传。
  • 首个终态事件之后,服务端关闭连接。

事件语义

事件客户端处理
run.working标记 Run 正在处理;只包含 run_id 和 status
tool.call按 tool_call_id 展示工具 started / completed 进度
tool.input.delta按 tool_call_id 和 field 追加 Write/Edit 白名单字段的文本增量
tool.input.snapshot整体替换同一工具调用当前公开输入的全量状态
message.delta把 delta 追加到当前回复末尾
message.snapshot用 content 整段替换当前回复;存在 tool_calls 时同时整体替换当前轮公开工具状态
preview.ready使用短时 Preview Link 打开当前可用预览
run.input_required展示 Action,收集输入后调用回复接口
run.usage按 usage_id 去重后,将本次结算的 credits_used 累加到 Run 总消耗
run.completed正常终态
run.failed失败终态
run.canceled取消终态
run.interrupted中断终态
run.superseded当前 Run 被取代;保留旧 Run 的真实终态,并切换到可选 current_run_id
tool.call 的已知 phase 为 analyzing、building、validating、coordinating、finalizing。未知 phase 仍按进行中展示。 tool.call 的基础字段包含安全的工具名、状态、阶段和展示文案,不包含原始参数、目录路径或原始执行结果。支持以下结构化扩展:
  • Read 可携带 file_name,只含安全 basename,不含目录、读取内容、offset、limit 或 URL 查询信息。
  • Skill 可携带 skill,其中 skill_name 为服务端解析的 canonical 名称,display_name 为可选展示名;展示时使用 display_name ?? skill_name。不公开技能正文、配置、凭证或加载结果。
  • status: "completed" 可携带 outcome(succeeded、failed、canceled)。completed 仅表示调用结束,实际结果以 outcome 为准。

Bash 类型与素材生成

OpenAPI 的 Bash 工具调用仍使用 tool_name: "Bash",通过可选的 sub_type 区分普通命令与素材生成:
sub_type含义成功时可返回的 result
shell普通 Shell 命令不公开原始命令输出
image_generate生成图片{type: "image", images: [HTTPS URL]}
video_generate生成视频{type: "video", video: HTTPS URL}
speech_generate生成语音{type: "audio", audio: HTTPS URL}
music_generate生成音乐{type: "audio", audio: HTTPS URL}
Bash 在参数生成完成后首次发送 status: "started",此时才分类 sub_type;同一次调用的 completed 保留同一分类。started 不表示素材已经生成完成,completed 也只表示调用结束,实际结果以 outcome 为准。 sub_type 为可选字段。客户端遇到缺省或未知值时,应按通用工具进度展示,不应报错或推断生成成功。不要把 sub_type 当作新的工具名,也不要根据展示文案或命令关键词自行分类。 例如,一次生图调用可以产生以下两个事件:
event: tool.call
data: {"run_id":"run_01JEXAMPLE","tool_call_id":"toolu_image","tool_name":"Bash","sub_type":"image_generate","status":"started","phase":"building","message":"正在执行命令"}

event: tool.call
data: {"run_id":"run_01JEXAMPLE","tool_call_id":"toolu_image","tool_name":"Bash","sub_type":"image_generate","status":"completed","phase":"building","message":"命令执行完成","outcome":"succeeded","result":{"type":"image","images":["https://example.com/generated/image.png"]}}
以上产物 URL 仅为结构示例,实际使用事件返回的完整 HTTPS URL。素材调用成功且有可公开产物时,完成事件可携带 result;失败、取消、没有可公开 URL 或达到投影限制时可能省略。图片最多返回 64 张,过滤或截断时可能带 result.truncated: true。不公开提示词、原始命令、普通 Bash 输出或内部返回体;产物 URL 可能包含签名,不要改写其参数或写入公开日志。

积分结算

Agent 主循环结算,或 Run 内图片、视频、语音、音乐的独立计量成功并返回有效积分时,事件流会发送一条 run.usage:
id: 8
event: run.usage
data: {"run_id":"run_01JEXAMPLE","usage_id":"usage_0123456789abcdef0123456789abcdef","credits_used":"128"}
  • credits_used 是这一次结算的积分,不是整个 Run 的累计值;它使用非负十进制整数字符串,"0" 是有效值。
  • 同一个 Run 在阻塞后恢复、发生 Session 轮换或生成素材时可能有多次结算,因此可能收到多条 run.usage。素材积分可以在 Run 仍在执行时到达,不必等到整个 Run 结束。
  • run.usage 当前不携带 tool_call_id、sub_type 或素材类型。不要按到达顺序把某笔积分直接绑定到某个工具调用;本事件用于累计 Run 消耗。
  • 重连时缓存窗口内的结算事件可能重放。usage_id 对同一次结算保持稳定,客户端必须先按它去重,再对 credits_used 求和。
  • 计量结算失败或没有返回有效积分时不会伪造 run.usage。 未收到 run.usage 不代表零消耗;事件用于本次运行的结算展示,不应把缺失事件伪装为零值。
  • 最终一条 run.usage(若有)会先于 run.completed、run.failed、run.canceled 或 run.interrupted;这些正常终态是该连接最后一个业务事件。
例如,同一 Run 写入代码、生成图片和视频时,可能先收到图片结算 200,再收到视频结算 1050,最后收到主 Agent 结算 23,去重后合计 1273。这些数字仅示意多笔累计,不代表固定价格或必须按此顺序到达;写代码不会保证产生一条独立的工具级积分事件。 结算重放受缓存和 Run 状态限制,不是长期账单查询能力。Run 结束较久后重新订阅,可能只返回终态;接入方应按 Run 保存已处理的 usage_id 与累计值,不能依赖历史消息或重新订阅补齐全部历史积分。 一个简单的累计器可以写成:
const seenUsageIds = new Set<string>();
let totalCreditsUsed = 0n;

function applyUsage(data: { usage_id: string; credits_used: string }): void {
  if (seenUsageIds.has(data.usage_id)) return;
  seenUsageIds.add(data.usage_id);
  totalCreditsUsed += BigInt(data.credits_used);
}

Write/Edit 输入流

默认会收到两类工具输入事件。tool.input.delta 使用追加语义:Write 只产生 field: "content";Edit 只产生 field: "old_string" 或 field: "new_string"。
id: 3
event: tool.input.delta
data: {"run_id":"run_01JEXAMPLE","tool_call_id":"toolu_01","tool_name":"Edit","field":"old_string","delta":"const retries = 1;"}
tool.input.snapshot 是该 tool_call_id 的权威全量状态,必须替换本地工具输入,而不是继续追加:
id: 4
event: tool.input.snapshot
data: {"run_id":"run_01JEXAMPLE","tool_call_id":"toolu_01","tool_name":"Edit","file_name":"config.ts","input":{"old_string":"const retries = 1;","new_string":"const retries = 3;","replace_all":false},"complete":true}
  • Write 的 input 只使用 content;Edit 使用 old_string、new_string,最终快照还可能带 replace_all。
  • Edit 删除内容时 new_string 可以是空字符串,且可能没有对应 delta;以最终 snapshot 为准。
  • file_name 只含 basename,可能在无法安全解析时省略;同名文件仍必须用 tool_call_id 区分。
  • complete: false 表示参数仍在生成;complete: true 只表示参数生成完成,不表示工具执行成功,随后应读取 tool.call.outcome。
  • truncated: true 表示公开投影达到大小或并发限制;Agent 内部仍使用完整参数执行。
  • 不保证 file_name 先于内容 delta 到达;客户端必须容忍字段顺序和事件交错。
客户端按 tool_call_id 分别维护状态:delta 追加到对应 field,snapshot 整体替换 file_name、input、complete 和 truncated。断线重连时缓存窗口内可能先收到 snapshot,用它覆盖重连前的本地累计值。

最终与重连工具快照

服务端从持久化消息生成的 message.snapshot 会附带 tool_calls,它是当前轮 Read、Write、Edit、Skill 以及图片、视频、语音、音乐素材 Bash 调用公开状态的权威全量快照。普通 Shell 命令不进入 tool_calls。实时文本更新产生的瞬态 message.snapshot 可能省略该字段:字段存在时整体替换本地工具列表,字段缺省时保留已有工具列表。
{
  "run_id": "run_01JEXAMPLE",
  "content": "应用已经更新完成",
  "tool_calls": [
    {
      "tool_call_id": "toolu_write",
      "tool_name": "Write",
      "status": "completed",
      "file_name": "config.ts",
      "input": { "content": "export const retries = 1;" },
      "outcome": "succeeded"
    },
    {
      "tool_call_id": "toolu_edit",
      "tool_name": "Edit",
      "status": "completed",
      "file_name": "config.ts",
      "input": {
        "old_string": "export const retries = 1;",
        "new_string": "export const retries = 3;",
        "replace_all": false
      },
      "outcome": "succeeded"
    },
    {
      "tool_call_id": "toolu_read",
      "tool_name": "Read",
      "status": "completed",
      "file_name": "config.ts",
      "outcome": "succeeded"
    },
    {
      "tool_call_id": "toolu_skill",
      "tool_name": "Skill",
      "status": "completed",
      "skill": { "skill_name": "react-design", "display_name": "React 设计" },
      "outcome": "succeeded"
    },
    {
      "tool_call_id": "toolu_image",
      "tool_name": "Bash",
      "sub_type": "image_generate",
      "status": "completed",
      "outcome": "succeeded",
      "result": { "type": "image", "images": ["https://example.com/generated/image.png"] }
    }
  ]
}
  • 快照只包含当前轮,即最近一条公开用户消息之后的工具调用,并按调用顺序排列。
  • Read 仍然只有 basename 文件名;Write/Edit 使用与实时事件完全相同的白名单正文;Skill 名称仍由服务端目录解析。
  • 素材 Bash 快照保留 sub_type、状态及可公开的 result,用于恢复图片、视频、语音或音乐结果卡;result 的可选性与实时完成事件一致。
  • status: "completed" 表示找到了配对的持久化工具结果,outcome 是安全归一化状态。原始工具结果不会进入快照。
  • 最多返回 64 个工具调用,Write/Edit 正文继续受实时投影相同的字段和 Run 总大小限制;达到限制时可能带 truncated: true。
  • 如果消息存储暂不可用,服务端退化为实时事件或纯文本快照,不会阻塞 Run 终态。

文本 reducer

客户端维护每个 Run 的单一文本缓冲区:
type AgentTextEvent =
  | { event: 'message.delta'; data: { delta: string } }
  | { event: 'message.snapshot'; data: { content: string; tool_calls?: unknown[] } };

function reduceAgentText(current: string, item: AgentTextEvent): string {
  if (item.event === 'message.snapshot') {
    return item.data.content;
  }
  return current + item.data.delta;
}
message.snapshot 可能在首次连接、重连和终态前出现。content 始终是当前完整文本;tool_calls 存在时是当前轮完整公开工具状态。两者使用替换语义;缺省的 tool_calls 不应清空已有工具列表。

预览事件

id: 4
event: preview.ready
data: {"run_id":"run_01JEXAMPLE","url":"https://meoo.com/open/preview/opaque-preview-ticket","expires_at":1787652600000}
只有内部 dev server 状态精确变为 running,并且短时 Preview Link 签发成功时,服务端才发送 preview.ready。starting、无效内部地址或签发失败都不发送 preview.ready,也绝不会向 Open API 客户端暴露 raw Sandbox URL。 url 是调用方应打开的短时 opaque Preview Link,不要依赖 host 或 path 形状;expires_at 是必填的 Unix 毫秒过期时间。 链接过期或需要续期时,第三方后端使用受保护的 POST /projects/{project_id}/agent/preview-links 重新签发;恢复仍在进行时接口返回 202,按 Retry-After 重试。不要等待 SSE 一定再次产生预览事件,也不要把 opaque Ticket 写入日志或长期存储。公开的 GET /open/preview/{preview_ticket} 只是 HTML 壳页面,不接受 API 凭证,也不会恢复 Sandbox。

断线重连

网络断开时:
  1. 保留当前 run_id、已展示文本和 UI 状态。
  2. 使用有限次数、带抖动的指数退避重新 GET 同一事件端点。
  3. 不发送 Last-Event-ID,也不使用重复 start 代替重连。
  4. 收到 message.snapshot 后整段替换已有文本;若存在 tool_calls,同时整体替换当前轮工具状态及素材结果卡,缺省则保留已有工具状态。素材调用以 tool_call_id 区分,使用 sub_type 选择展示类型。
  5. 对重放的 run.usage 按 usage_id 去重,再累计字符串形式的 credits_used,不要重复计费展示。
  6. 收到终态后停止重连。
该端点当前使用 60 秒固定限频窗口。收到 429 时,Retry-After: 60 是固定窗口的安全等待上界,不是剩余 TTL;等待后增加少量随机抖动再重连。 页面刷新且不知道当前 Run 时,先调用 GET .../agent/runs/current,再决定是否订阅,详见 会话与恢复。

run.input_required

可回复的交互事件包含 action:
id: 5
event: run.input_required
data: {"run_id":"run_01JEXAMPLE","status":"input_required","action":{"action_id":"signed-action-token","kind":"answers","prompt":"请选择目标平台","input_schema":{"type":"object","required":["answers"]},"expires_at":1785900000000}}
字段说明
action_id短期签名 Token;原样回传,不得解析或修改
kindanswers 或 confirmation
prompt向用户展示的问题或确认说明
input_schemaresponse 必须满足的 JSON Schema
expires_atAction 过期时间,Unix 毫秒
如果事件没有 action,而是 detail: "unsupported_action",表示当前阻塞工具不支持通过 OpenAPI 回复。客户端应停止自动提交,并引导用户到 Meoo Web 处理。 只要阻塞工具仍有效,每次建连或重连都会签发新的 action_id 和 expires_at;长连接存续时,服务端会在当前 Token 半 TTL 时续签。客户端必须用最新事件替换旧 Token。

回复 Action

POST /projects/{project_id}/agent/action-responses
所需权限:agent.run action_id 与项目和 Run 绑定,且只能在有效期和正确状态下使用。

问答回复

kind=answers 时,answers 包含 1~4 项;键长 1~1000,值为 1~10000 字符的非空字符串。服务端会先 trim 每个 key 和 value,再校验并传给 Agent;trim 后为空或产生重复 key 会返回 400。
curl --request POST \
  --url "${MEOO_BASE_URL}/open/v1/projects/${PROJECT_ID}/agent/action-responses" \
  --header "Authorization: Bearer ${ACCESS_TOKEN_OR_API_KEY}" \
  --header "Content-Type: application/json" \
  --data "$(jq -n --arg action_id "${ACTION_ID}" '{
    action_id: $action_id,
    response: {
      answers: {
        target_platform: "Web"
      }
    }
  }')"

确认回复

kind=confirmation 时,decision 为 approve 或 reject:
{
  "action_id": "signed-action-token",
  "response": {
    "decision": "approve"
  }
}
只有 action.input_schema 声明允许 comment 时才能附带非空 comment:
{
  "action_id": "signed-action-token",
  "response": {
    "decision": "reject",
    "comment": "请先移除高风险操作"
  }
}
成功响应是恢复后的 Run 对象。继续消费原 Run 的 SSE;如果原连接已关闭,则重新订阅该 run_id。

Action 错误处理

HTTP场景处理
400Token 或 response 形状不合法按 input_schema 修正输入
404Action 与项目不匹配,或资源不可访问不枚举项目/Run,刷新当前状态
409 action_expiredAction Token 已过期重新订阅 Run,等待新的 Action Token
409 action_already_respondedAction 已回复恢复 Run 状态,不重复提交
409 run_conflictRun generation 或状态已变化查询 current 或重新订阅
Action 回复没有通用 Idempotency-Key。请求结果不确定时,先恢复 Run 状态;不要用新的 start 请求模拟 Action 回复。