最近实现了一个使用 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 协议
通信流程与具体设计
完整的通信流程如下:
[ 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
...
协议握手
必要性
由于我们的协议是基于 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 在修改错误的目录。但如果有握手的话,
握手流程
整个握手流程如下,消息只发了一趟请求、回了一趟响应
┌───────────────────────┐ ┌───────────────────────┐
│ 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 对协议进行校验:
-
Provider 传 protocol 字段了吗?
-
contract.version 的主版本号与 1.1 匹配吗?(我们的协议采用高版本匹配地版本二)
-
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
- 单向确认(nonoka-cli ➔ Provider):nonoka-cli 收到
ChatRequest后,在返回数据(如文本流、工具调用)之前,必须先在 stdout 的第一行回传一个ProtocolAckEvent。
TS Provider 端的校验是为了确认 nonoka-cli 提供的能力和协议版本是否与 Provider 端的要求相匹配,因为前面只校验了 Provider 端的。
3. 核心调用机制
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,而且还有下面的设计考量:
- 生命周期耦合:provider 的
abortSignal和子进程 kill 是一一对应的。如果 bridge 长驻,取消当前生成时就要远程发 cancel,而不是直接杀进程,cancel 语义会变复杂。这里为了简单就这么实现了。 - 沙箱边界:SRT/Docker 沙箱(nonoka cli的另一个组件,之后会简单介绍)包裹的是 OpenCode 进程树。如果 bridge 是独立守护进程,它就不在这个进程树里,需要单独配置沙箱策略。
- 状态隔离:每轮 spawn 新进程天然保证”上一个 turn 的污染不会带到下一个 turn”,内存泄漏、临时文件、signal handler 状态都会被清理。
一个子进程一旦启动,会循环读取 stdin,处理 ChatRequest,并在流式输出过程中响应 CancelRequest,直到 stdout 关闭。它不像传统 HTTP server 那样一直监听端口等待多次独立请求。
针对 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-agent 的 SQLiteCheckpointStore 管理(这个是 nonoka-agent 框架自带的功能,之后会介绍)。每次新进程启动后,Orchestrator 拿到 session_id,会从 checkpoint store 加载 SessionState,只要 session_id 没丢、.nonoka/sessions.db 没丢,会话就能续上。