04-api.md 6.1 KB

04 对外 API 与事件模型

1. 约定

  • 基础路径:/v1(业务)、/admin/v1(管理,仅后台会话可访问)、/mcp(MCP Gateway,仅 Run Token 可访问)
  • 鉴权:Authorization: Bearer <api-key>;每个上层平台一个 Key,不区分使用者
  • 格式:JSON;时间为 ISO 8601;ID 为带前缀的 ULID(ses_、run_、itx_、file_、prf_)
  • 错误:{ "error": { "code": "session_busy", "message": "...", "details": {} } }

2. 资源接口

会话

方法 路径 说明
POST /v1/sessions 创建:{ profileId?, model?, title?, systemPromptAppend? }。Profile 必须是该 API Key 可用的(省略时用其默认 Profile,否则 403 profile_not_allowed);模型可自选(限可选模型);不能指定权限模式,由 Profile 决定(ADR-0015)
GET /v1/sessions?cursor=&limit= 当前 API Key 的会话列表(?scope=all 可列出所有 Key 的会话,需要 Key 具备该权限)
GET /v1/sessions/:id 详情:元数据 + 消息摘要 + 未决交互
PATCH /v1/sessions/:id 改标题、归档
DELETE /v1/sessions/:id 删除(含工作区)
GET /v1/sessions/:id/events?after=<seq> 拉取历史事件(用于渲染完整时间线)

消息与 Run

方法 路径 说明
POST /v1/sessions/:id/messages { text, attachments?: fileId[] } → 202 { runId };会话忙时返回 409 session_busy
GET /v1/sessions/:id/stream SSE:订阅会话全部事件(推荐,一个会话一条连接)
GET /v1/runs/:id Run 状态与用量
POST /v1/runs/:id/interrupt 中断

交互

方法 路径 说明
GET /v1/interactions?status=pending 未决交互
POST /v1/interactions/:id 回复(见 §4)

文件

方法 路径 说明
POST /v1/sessions/:id/files multipart 上传(数据文件 / 截图),返回 fileId;写入工作区 uploads/
GET /v1/sessions/:id/files 列出工作区文件树(uploads / outputs)
GET /v1/files/:fileId 下载;?inline=1 用于预览
GET /v1/files/:fileId/preview 预览渲染:图片缩略图、CSV/xlsx 前 N 行、docx/pdf 转 PDF/HTML

其他

方法 路径 说明
GET /v1/profiles 当前 API Key 可用的 Profile(isDefault 为该平台的默认 Profile)
GET /v1/providers 引擎可用性与模型列表

3. 统一事件模型

所有事件共享信封:

interface AgentEventEnvelope<T extends string, P> {
  seq: number            // 会话内单调递增;SSE 的 id
  type: T
  sessionId: string
  runId: string
  ts: string
  payload: P
}
type payload 说明
run.queued { position } 排队中
run.started { provider, model }
message.delta { messageId, text } 助手文本增量
message.completed { messageId, text } 助手一段完整文本
reasoning.delta { blockId, text } 思考过程(前端默认折叠)
tool.started { toolCallId, name, title, input } title 是适配层生成的人类可读摘要,如 Read sales.xlsx
tool.progress { toolCallId, output } 命令输出增量(可选)
tool.completed { toolCallId, status: 'ok'\|'error'\|'denied', output, truncated }
file.changed { fileId, path, kind: 'created'\|'modified', size, mime } 工作区产出文件
interaction.required 见 §4 需人工介入,Run 进入 waiting_input
interaction.resolved { interactionId, resolution, by } 已回复/超时/取消
usage { inputTokens, outputTokens, cacheReadTokens, costUsd? } 订阅登录下 costUsd 为估算值
run.completed { durationMs }
run.failed { code, message }
run.interrupted { reason: 'user'\|'restart'\|'timeout' }

用户消息也写入事件流(user.message,payload 含文本与附件),以便事件流单独就能完整重建时间线。

4. 交互协议

// interaction.required payload
type InteractionRequest =
  | { interactionId: string; kind: 'approval'
      tool: string; title: string; input: unknown
      detail?: { command?: string; diff?: string; url?: string; connector?: string }
      options: Array<'allow_once' | 'allow_session' | 'deny'>
      expiresAt: string }
  | { interactionId: string; kind: 'question'
      questions: Array<{ id: string; question: string; header?: string
                         multiSelect: boolean; options: { label: string; description?: string }[] }>
      expiresAt: string }

// POST /v1/interactions/:id
type InteractionResponse =
  | { decision: 'allow_once' | 'allow_session' }
  | { decision: 'deny'; reason?: string }       // reason 回传给 Agent
  | { answers: Record<string, string | string[]> }
  • 同一交互只接受第一次回复,之后的回复返回 409 already_resolved。
  • 多个前端同时打开同一会话时,都会收到 interaction.resolved,用于同步关闭卡片。
  • 超时默认按 deny 处理(reason: "timeout")。

5. SSE 细节

  • GET /v1/sessions/:id/stream,响应头 Content-Type: text/event-stream,每条 id: <seq>、event: <type>、data: <json>
  • 重连时携带 Last-Event-ID,服务端补发 seq > lastId 的事件
  • 每 15s 发一次 : ping 注释帧保活
  • 浏览器原生 EventSource 无法设置 Authorization 头,因此 @agent-server/client 使用 fetch + ReadableStream 实现 SSE(支持自定义头和断线续传)

6. 客户端 SDK 示意

import { AgentClient } from '@agent-server/client'

const client = new AgentClient({ baseUrl, apiKey })
const s = await client.sessions.create({ profileId: 'prf_data_analyst' })
const file = await s.upload(screenshotBlob)
s.on('event', ev => store.apply(ev))                 // 交给 chat-ui 的 store
s.on('interaction', itx => ui.prompt(itx))
await s.send({ text: '分析这张图和附件里的销售数据', attachments: [file.id] })
await client.interactions.reply(itxId, { decision: 'allow_once' })