Dark Dwarf Blog background

基于 Vercel AI SDK 设计 Agent2OpenCode 协议

最近实现了一个使用 Opencode 作为 TUI 前端的 CLI 应用,之后会记录一下这期间的一些设计思路和实现细节。

基于 Vercel AI SDK 设计 Agent2OpenCode 协议

1. Vercel AI SDK 简介

和 Langchain、LlamaIndex 等框架不同,Vercel AI SDK 是一个面向前端/UI的框架,它解决了如何将大模型的输出优雅地在前端/UI中渲染出来的问题。

在 Vercel AI SDK 中,所有的模型 Provider(不管是官方的还是自定义 Provider) 都必须实现 LanguageModelV3 接口:

export interface LanguageModelV3 {
  // 1. 元数据 (Metadata)
  readonly specificationVersion: "v1"; // 协议版本
  readonly provider: string; // Provider 名称,例如 'nonoka-opencode-provider'
  readonly modelId: string; // 模型/Agent 标识,例如 'nonoka-agent-v1'

  // 2. 能力声明 (Capabilities)
  // 告诉 SDK 这个 Provider 支持哪些特性(例如是否支持图片输入、工具调用等)
  readonly defaultObjectGenerationMode?: "json" | "tool";

  // 3. 核心方法一:流式输出 (doStream) —— 你的 Provider 最主要实现的方法
  doStream(options: LanguageModelV3CallOptions): PromiseLike<{
    // 返回一个标准的 JavaScript ReadableStream
    stream: ReadableStream<LanguageModelV3StreamPart>;

    // 原始调用的上下文信息,用于调试和日志
    rawCall: {
      rawPrompt: unknown;
      rawSettings: Record<string, unknown>;
    };

    // 格式化后的响应头/元数据
    rawResponse?: {
      headers?: Record<string, string>;
    };
  }>;

  // 4. 核心方法二:非流式一次性生成 (doGenerate)
  doGenerate(options: LanguageModelV3CallOptions): PromiseLike<{
    text?: string;
    toolCalls?: Array<LanguageModelV3FunctionToolCall>;
    finishReason: LanguageModelV3FinishReason;
    usage: LanguageModelV3Usage;
    rawCall: { rawPrompt: unknown; rawSettings: Record<string, unknown> };
  }>;
}

当 SDK 调用你的 Provider 时,会将用户和系统的输入打包成 options 传进来:

// 简化的 LanguageModelV3CallOptions 结构
type LanguageModelV3CallOptions = {
  // 抽象后的消息历史(已标准化为 system / user / assistant / tool 角色)
  prompt: Array<LanguageModelV3Message>;

  // 允许模型调用的工具定义列表
  tools?: Array<LanguageModelV3Tool>;

  // 采样参数
  temperature?: number;
  maxTokens?: number;

  // 控制中断信号
  abortSignal?: AbortSignal;

  // HTTP 请求头等额外参数
  headers?: Record<string, string>;
};

然后 LanguageModelV3StreamPart 就是 doStream 这个输出管道输出的内容,SDK 会有相关的渲染这些内容的方法。对于这个 CLI 应用,我们只需要知道 Opencode、Claude 之类的应用都支持该协议、只需要实现 LanguageModelV3 接口即可。

2. Bridge 协议

a.a. 通信流程与具体设计

完整的通信流程如下:

[ OpenCode TUI ]
       │ (Vercel AI SDK)

[ Custom LanguageModelV3 Provider ]  (TypeScript)

       │ stdio NDJSON (ChatRequest / CancelRequest)

[ nonoka-cli --server ]              (Python Subprocess)

       ├─► [ BridgeServer ] (协议解析 & 握手)
       ├─► [ Orchestrator ] (ReAct 循环引擎)
       └─► [ SQLite Checkpoint ] (.nonoka/sessions.db)

Provider 通过 stdin 写入 JSON 行,Payload 为 ChatRequest

# src/nonoka_cli/bridge/protocol.py
class ChatRequest(BaseModel):
  type: Literal["chat"] = "chat"
  protocol: ProtocolContract | None = None  # 协议合同
  purpose: Literal["chat", "title"] = "chat"
  messages: list[ChatMessage]
  tools: list[ExternalToolDefinition] | None = None
  session_id: str | None = None
  cwd: str = Field(default=".")

  # 运行时限制与 Attestation 约束
  tool_budget: int | None = None
  require_workspace_mutation: bool = False
  verification_enforcement: Literal["strict", "advisory"] = "strict"
  request_id: str | None = None
  ...

Python 侧运行结果通过 stdout 实时发送事件,核心事件包括:

  • protocol_ack:协议版本和能力确认;
  • session_init:通知 provider 当前 session id;
  • text_delta:增量文本;
  • tool_call:模型请求调用工具;
  • tool_result:本地工具执行结果;
  • approval_request:本地工具需要人工审批;
  • finish:turn 结束,携带 finish_reason
  • error:bridge 或运行时错误。
# src/nonoka_cli/bridge/protocol.py
class SessionInitEvent(BaseModel):
  """Emitted on the first response so the provider can persist the session id."""

  type: Literal["session_init"] = "session_init"
  session_id: str

class ProtocolAckEvent(BaseModel):
  """Confirmation that the bridge accepted the provider contract."""

  type: Literal["protocol_ack"] = "protocol_ack"
  version: str = BRIDGE_PROTOCOL_VERSION
  capabilities: list[str]
  cli_version: str
  framework_version: str

class TextDeltaEvent(BaseModel):
  """Incremental assistant text."""

  type: Literal["text_delta"] = "text_delta"
  text: str

class FinishEvent(BaseModel):
  """A single assistant turn finished."""

  type: Literal["finish"] = "finish"
  finish_reason: Literal["stop", "error", "cancel", "approval_required", "tool_calls"]
  termination: dict[str, Any] | None = None
  runtime: dict[str, Any] | None = None

...

b.b. 协议握手

i.i. 必要性

由于我们的协议是基于 NDJson 自己设计的协议,我们需要引入协议握手机制来确保通信双方语义一致。如果没有握手机制,在协议不兼容时会导致静默失败,例如最初开发时遇到的下面的场景:

  • nonoka-cli 升级后支持了 external_tool_receipts 能力并约定:外部工具执行完后,OpenCode 必须把结果包成 ExternalToolReceipt 回传,nonoka-agent 才能做 workspace attestation。但旧的 provider 直接把原始 tool result 直接塞进 ChatRequest.messages、没有使用规定字段。

在没有实现握手机制前,nonoka-cli 收到请求,看到没有 receipt,会认为”这次工具执行没有产生 attestation”,他不会终止对话、而是继续完成任务;下一轮模型调用基于错误的 workspace 假设继续改文件,导致在运行 Harbor benchmark 时发现 nonoka agent 在修改错误的目录。但如果有握手的话,

ii.ii.握手流程

整个握手流程如下,消息只发了一趟请求、回了一趟响应

┌───────────────────────┐                      ┌───────────────────────┐
│ Provider (TypeScript) │                      │  nonoka-cli (Python)  │
└───────────┬───────────┘                      └───────────┬───────────┘
            │                                              │
            │  1. Send ChatRequest (with protocol)         │
            │─────────────────────────────────────────────►│ ➔ Validate Contract
            │     [One-way Request]                        │   (Version & Capacity)
            │                                              │
            │  2. Return ProtocolAckEvent                  │
            │◄─────────────────────────────────────────────│
            │     [One-way Ack]                            │
            │     (followed by text_delta, tool_call...)   │
            │                                              │
Validate    │                                              │
Ack & Cap   │                                              │
            ▼                                              ▼

首先是单向请求(Provider ➔ nonoka-cli):Provider spawn 出 Python 进程后,写入的第一行 NDJSON 就是 ChatRequest,在 ChatRequest 字段里带了 protocol: { version: "1.1", required_capabilities: [...] }

class ProtocolContract(BaseModel):
  """Capabilities the provider requires from the bridge for this request."""

  version: str
  required_capabilities: list[str] = Field(default_factory=list)
  provider_version: str | None = None

nonoka cli 端收到 Provider 发来的 ChatRequest 后,立即执行 _negotiate_protocol 对协议进行校验:

  1. Provider 传 protocol 字段了吗?

  2. contract.version 的主版本号与 1.1 匹配吗?(我们的协议采用高版本匹配地版本二)

  3. contract.required_capabilities 里要求的特性,Python 端全支持吗?

async def _negotiate_protocol(self, msg: ChatRequest) -> bool:
  """Verify the provider contract before creating or resuming a session."""
  contract = msg.protocol
  if contract is None:
    await self._send(
      ErrorEvent(
        message="Provider did not declare a bridge protocol contract.",
        code="protocol_contract_required",
        retryable=False,
        details={"supported_version": BRIDGE_PROTOCOL_VERSION},
      )
    )
    return False

  requested_major = contract.version.split(".", 1)[0]
  supported_major = BRIDGE_PROTOCOL_VERSION.split(".", 1)[0]
  missing = sorted(set(contract.required_capabilities) - BRIDGE_CAPABILITIES)
  if requested_major != supported_major or missing:
    await self._send(
      ErrorEvent(
        message="Bridge protocol is incompatible with the provider request.",
        code="protocol_incompatible",
        retryable=False,
        details={
          "requested_version": contract.version,
          "supported_version": BRIDGE_PROTOCOL_VERSION,
          "missing_capabilities": missing,
          "supported_capabilities": sorted(BRIDGE_CAPABILITIES),
        },
      )
    )
    return False

  await self._send(
    ProtocolAckEvent(
      capabilities=sorted(BRIDGE_CAPABILITIES),
      cli_version=_package_version("nonoka-cli"),
      framework_version=_package_version("nonoka"),
    )
  )
  return True
  1. 单向确认(nonoka-cli ➔ Provider):nonoka-cli 收到 ChatRequest 后,在返回数据(如文本流、工具调用)之前,必须先在 stdout 的第一行回传一个 ProtocolAckEvent

TS Provider 端的校验是为了确认 nonoka-cli 提供的能力和协议版本是否与 Provider 端的要求相匹配,因为前面只校验了 Provider 端的。

3. 核心调用机制

a.a. Spawn Server

我们使用 spawnServer 机制来启动 nonoka cli server:每次调用 LanguageModelV3.doStream(),Provider 都会产生一个全新的 nonoka-cli —server 子进程:

export class NonokaLanguageModel implements LanguageModelV3 {
  async doStream(options: LanguageModelV3CallOptions) {
    // 1. 每次请求 spawn 一个全新的 Python 子进程
    const child = this.spawnServer();

    // 2. 写入 JSON 行请求
    await writeToStdin(child, JSON.stringify(chatRequest) + '\n');

    // 3. 将 stdout 解析为 AI SDK 标准 streamParts
    return {
      stream: this.createOutputStream(child),
      rawCall: { rawPrompt: options.prompt, rawSettings: {} },
    };
  }
}

这是由于 LanguageModelV3 要求每个 doStream() 调用都返回一个 stream,而且还有下面的设计考量:

  1. 生命周期耦合:provider 的 abortSignal 和子进程 kill 是一一对应的。如果 bridge 长驻,取消当前生成时就要远程发 cancel,而不是直接杀进程,cancel 语义会变复杂。这里为了简单就这么实现了。
  2. 沙箱边界:SRT/Docker 沙箱(nonoka cli的另一个组件,之后会简单介绍)包裹的是 OpenCode 进程树。如果 bridge 是独立守护进程,它就不在这个进程树里,需要单独配置沙箱策略。
  3. 状态隔离:每轮 spawn 新进程天然保证”上一个 turn 的污染不会带到下一个 turn”,内存泄漏、临时文件、signal handler 状态都会被清理。

一个子进程一旦启动,会循环读取 stdin,处理 ChatRequest,并在流式输出过程中响应 CancelRequest,直到 stdout 关闭。它不像传统 HTTP server 那样一直监听端口等待多次独立请求。

b.b. 针对 Spawn 的持久化机制

如果没有 session 持久化,每次 spawn 都是全新的 Python 进程,全新的 Orchestrator,全新的内存状态,会有下面的问题:

  • 第一轮:nonoka 让 OpenCode 改了 foo.py
  • 第二轮:新进程不知道 foo.py 被改过,重新读取后以为还是旧内容;
  • 或者新进程看到 ChatRequest.messages 末尾有 tool result,但自己的 memory/checkpoint 里没有对应的 assistant tool_call,直接报错:An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'

因此我们需要将每个 spawn 的执行状态持久化。而进程是不能长驻的,因此我们需要在进程外做这些事情。nonoka-cli是这样实现的:

首先 provider 在 TypeScript 实例里维护 chatSessionId,并把同一个工作目录的 session id 写到本地文件:

// packages/nonoka-opencode-provider/src/nonoka-language-model.ts:564-590
export function getChatSessionIdFile(cwd: string): string {
  return path.join(runtimePaths(cwd).root, 'provider-session.id');
}

export function loadChatSessionId(cwd: string): string | undefined {
  try {
    const file = getChatSessionIdFile(cwd);
    return fs.readFileSync(file, 'utf-8').trim();
  } catch {
    return undefined;
  }
}

export function saveChatSessionId(cwd: string, sessionId: string | undefined): void {
  ...
  fs.mkdirSync(path.dirname(file), { recursive: true });
  writeFileSync(file, sessionId, 'utf-8');
}

初始化时从文件中读取:

this.chatSessionId = settings.sessionId ?? loadChatSessionId(config.cwd);

收到 bridge 发来的 session_init 事件后写回文件:

this.chatSessionId = sessionId;
saveChatSessionId(this.config.cwd, sessionId);

provider 只是记住了 session_id,真正的会话状态(memory、budget、pending tool calls、step updates)存在 .nonoka/sessions.db 里,由 nonoka-agentSQLiteCheckpointStore 管理(这个是 nonoka-agent 框架自带的功能,之后会介绍)。每次新进程启动后,Orchestrator 拿到 session_id,会从 checkpoint store 加载 SessionState,只要 session_id 没丢、.nonoka/sessions.db 没丢,会话就能续上。