实战 01:简易 OpenCode——产品边界与架构
1、这一系列最终要做出什么
目标是一个真正可使用的本地编码 Agent,而不只是“模型生成 Patch”的示例。完成后应支持:
- 启动本地 Server,TUI/CLI 作为独立客户端连接。
- 一个项目有多个 Session;Session 中保存用户消息、模型文本、推理/工具 Part、费用和状态。
- Plan 模式默认只读,Build 模式按权限执行读取、搜索、编辑、测试。
- 运行过程通过 SSE 发送增量事件;客户端断开后 Session 仍保留并可重新加载。
- 危险工具产生 Permission Request,用户可以本次允许、记住规则或拒绝。
- 支持多模型 Provider、上下文压缩、代码规则、LSP/MCP 扩展与可撤销改动。
第一版仍不自动推送 Git、不访问生产凭证、不运行宿主任意命令。它是完整的本地产品骨架,不是 OpenCode 的一比一复制。
2、为什么必须是 Client/Server 架构
官方 OpenCode 的 TUI 本质上也是 Server 客户端;Server 暴露 OpenAPI 和事件流,从而允许 TUI、Web、IDE 和 SDK 共用同一运行时。简易版也采用这个边界:
TUI / CLI / Web / IDE
│ HTTP + SSE
▼
Local Agent Server
├─ Project / Session API
├─ Model Runtime
├─ Context Builder / Compactor
├─ Tool Registry / Permission Broker
├─ Workspace / VCS / LSP / MCP
└─ SQLite + Event Log + Artifacts
好处是任务不会依赖终端进程寿命;多个客户端看到同一 Session;权限请求有统一入口;Server 可以生成 SDK;TUI 不拥有额外的文件或 shell 权限。
默认只监听 loopback。需要远程访问时再增加认证、TLS/反向代理与明确 CORS,不能把无认证 Server 绑定到公网地址。
3、领域模型:Session、Message、Part
仅保存一串聊天文本无法表达工具、权限和流式状态。建议使用三层模型:
type Session = {
id: string; projectId: string; parentId?: string;
title: string; mode: "plan" | "build";
status: "idle" | "running" | "waiting_permission" | "failed";
createdAt: string; updatedAt: string;
};
type Message = {
id: string; sessionId: string; role: "user" | "assistant";
model?: string; parentMessageId?: string;
createdAt: string; completedAt?: string;
};
type Part =
| { id: string; type: "text"; text: string }
| { id: string; type: "reasoning"; text: string; visibility: "hidden" | "summary" }
| { id: string; type: "tool"; callId: string; tool: string; status: ToolStatus; input: unknown; output?: unknown }
| { id: string; type: "file"; path: string; mime: string; artifactId?: string }
| { id: string; type: "step"; name: string; status: "started" | "finished"; usage?: Usage };
Part 是 append/update 的最小流式单位:文本 Part 不断追加 delta;工具 Part 从 pending → running → completed/error;权限等待挂在对应 call ID 上。这样重载 Session 时能准确恢复 UI,而不是重新解析 Markdown。
4、Project 与 Workspace
Project 保存规范化根目录、VCS 信息、配置和规则文件版本。每个运行使用 Workspace:
Project root:用户真实仓库,默认只读观察
Session workspace:独立 Git worktree 或隔离副本
Artifacts:大日志、测试报告、图片、生成文件
编辑和测试发生在 Session workspace。所有路径先规范化,必须仍位于允许根目录;外部目录单独权限;.git、密钥、缓存与依赖目录有默认策略。Session 结束时可以保留 worktree、导出 Patch、应用回用户仓库或清理。
5、Model Runtime 不能直接等于某家 SDK
Provider Adapter 统一处理模型标识、认证、消息转换、工具 schema、流式 chunk、usage、停止原因与 provider 错误:
interface ModelProvider {
listModels(): Promise<ModelInfo[]>;
stream(request: ModelRequest, signal: AbortSignal): AsyncIterable<ModelChunk>;
}
type ModelChunk =
| { type: "text.delta"; text: string }
| { type: "tool.start"; callId: string; name: string }
| { type: "tool.args.delta"; callId: string; json: string }
| { type: "finish"; reason: "stop" | "tool_calls" | "length"; usage: Usage };
模型差异不能全泄漏到 Session 层。Provider 切换要经过能力检查:是否支持工具、图片、结构化输出、上下文长度;模型变更后运行同一评测。
6、Agent Loop 的一次完整执行
用户消息入库
→ Context Builder 选择规则、最近消息、文件引用和工具
→ Provider 流式响应,写 Assistant Message/Parts/Event Log
→ 若最终 stop:完成 turn
→ 若 tool call:解析完整 JSON → Tool Registry 查找工具
→ Permission Engine: allow / deny / ask
→ ask:创建 Permission Request,Session 暂停
→ allow:Tool Runner 执行,结果写 Tool Part
→ 回到模型,直到完成、取消或预算耗尽
每个 turn 设置 deadline、最大模型调用、最大工具调用、token/费用上限和 doom-loop 检测。客户端取消通过 AbortSignal 传至模型和可取消工具,但已经完成的文件写入不能假装撤销。
7、工具注册表与风险分类
interface Tool<I, O> {
name: string;
description: string;
schema: ZodType<I>;
risk(input: I): "read" | "edit" | "execute" | "network" | "external";
execute(input: I, ctx: ToolContext): Promise<O>;
}
首批工具:list/glob/grep/read;write/edit/apply_patch;run_suite;可选 lsp。不要先提供任意 bash。需要 shell 时在 Sandbox 中通过细粒度 command policy 控制。
工具输出不能无限回填模型:保留摘要、退出码和 artifact ID;大结果写 artifact。所有工具使用 Session/Project context,不让模型传 root、user 或凭证。
8、Permission Engine 是独立子系统
权限动作有 allow/ask/deny,规则按 agent、mode、tool、路径或命令匹配。优先级要确定并可解释:更具体规则优先;deny 不应被宽泛 allow 覆盖;Plan mode 强制禁止 edit/execute。
type PermissionRequest = {
id: string; sessionId: string; callId: string;
tool: string; risk: string; inputPreview: unknown;
matchedRule?: string; status: "pending" | "allowed" | "denied";
expiresAt: string;
};
“记住允许”生成新的精确规则,例如只允许 npm test,不能把一次批准扩大成所有 bash。审批后重新验证 Session、call ID、输入 hash 和 workspace 版本。
9、事件流与客户端状态
Server 写事件日志并通过 SSE 发布:
type ServerEvent =
| { seq: number; type: "session.updated"; session: Session }
| { seq: number; type: "message.created"; message: Message }
| { seq: number; type: "part.updated"; messageId: string; part: Part }
| { seq: number; type: "permission.requested"; request: PermissionRequest }
| { seq: number; type: "session.error"; code: string; retryable: boolean };
客户端 reducer 先拉 snapshot,再从 after=lastSeq 订阅事件。断线只重连,不重发 prompt;窗口过期则重新拉 snapshot。事件必须能去重且保持单 Session 顺序。
10、VCS、Undo 与 Redo
每个用户 turn 前记录 workspace tree/hash 或 Git snapshot;工具写入产生 Patch artifact;undo 恢复该 turn 的文件变更并回退 UI 指针,不能用破坏用户真实仓库的命令;redo 重新应用保存的 Patch,并重新检查基线 hash。
外部用户修改导致 hash 不一致时返回冲突,要求重新分析,不能强行覆盖。
11、存储建议
SQLite 表至少包括 projects、sessions、messages、parts、events、permission_requests、tool_executions、snapshots、artifacts、model_usage。Event Log 用于重连和审计,不代替规范化当前状态;大内容用 artifact 文件/对象存储,只在数据库保存元数据和 hash。
12、开发里程碑
- Server + SQLite + Session/Message/Part + SSE,模型用 FakeProvider。
- 只读工具与 Plan mode,完成问答/代码解释。
- Permission Broker + Patch workspace + Build mode。
- 固定测试套件、取消、预算、错误恢复、Undo/Redo。
- 真实 Provider、上下文压缩、规则文件。
- LSP、MCP、Skills/插件与 Sandbox。
每个里程碑都有 API 集成测试和客户端重连测试。下一篇用 Python 实现 Server、存储、事件流、Agent Loop 与权限暂停;再下一篇完成 TypeScript Server/TUI。
官方参考:OpenCode Server、Tools、Permissions。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录