实战 03:简易 OpenCode——TypeScript 编码 Agent

2026-09-01
18627 分钟
...

1、本篇交付物

TypeScript 版采用 Fastify、Zod、SQLite、LangGraph.js 和 Ink/React:Server 负责项目、Session、模型、工具与权限;TUI 只调用 HTTP/SSE。重点不是翻译 Python,而是利用 discriminated union 和 reducer 保证端到端事件一致。

2、Monorepo 结构

packages/
  contracts/    # Zod schema并导出推导类型
  server/       # Fastifyrepositoryruntimetools
  tui/          # Ink React 客户端
  sdk/          #  OpenAPI contracts 生成/封装客户端
tests/

contracts 不依赖 Server/TUI,防止循环依赖。所有网络输入、数据库 JSON、provider/tool 输出在边界处 parse。

3、事件与 Part 的判别联合

import * as z from "zod";

export const Part = z.discriminatedUnion("type", [
  z.object({ id: z.string(), type: z.literal("text"), text: z.string() }),
  z.object({ id: z.string(), type: z.literal("reasoning"), text: z.string(), visibility: z.enum(["hidden", "summary"]) }),
  z.object({
    id: z.string(), type: z.literal("tool"), callId: z.string(), tool: z.string(),
    status: z.enum(["pending", "waiting_permission", "running", "completed", "error"]),
    input: z.unknown(), outputArtifactId: z.string().optional(), errorCode: z.string().optional(),
  }),
]);

export const ServerEvent = z.discriminatedUnion("type", [
  z.object({ seq: z.number().int().positive(), type: z.literal("session.updated"), session: Session }),
  z.object({ seq: z.number().int().positive(), type: z.literal("message.created"), message: Message }),
  z.object({ seq: z.number().int().positive(), type: z.literal("part.updated"), messageId: z.string(), part: Part }),
  z.object({ seq: z.number().int().positive(), type: z.literal("permission.requested"), request: PermissionRequest }),
  z.object({ seq: z.number().int().positive(), type: z.literal("session.error"), code: z.string(), retryable: z.boolean() }),
]);

TUI 的 switch(event.type) 若漏分支,可用 never 让编译失败。新增协议事件时,Server 与客户端都会被迫更新。

4、Repository 的原子写法

function appendEvent(db: Database, sessionId: string, type: string, payload: unknown) {
  return db.transaction(() => {
    const row = db.prepare(
      "UPDATE sessions SET last_event_seq=last_event_seq+1 WHERE id=? RETURNING last_event_seq"
    ).get(sessionId) as { last_event_seq: number } | undefined;
    if (!row) throw new Error("SESSION_NOT_FOUND");
    const event = { sessionId, seq: row.last_event_seq, type, payload, createdAt: new Date().toISOString() };
    db.prepare("INSERT INTO events(session_id,seq,type,payload,created_at) VALUES(?,?,?,?,?)")
      .run(sessionId, event.seq, type, JSON.stringify(payload), event.createdAt);
    return event;
  })();
}

业务状态和对应事件必须同事务提交。内存 EventEmitter 只负责唤醒 SSE,丢通知时客户端仍能从表补读。

5、Provider Adapter 与流式工具参数

interface ModelProvider {
  stream(input: 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 };

Runtime 为每个 call ID 累积 JSON delta,只有完整结束后才 ToolSchema.parse(JSON.parse(buffer))。不在半个 JSON 时执行工具。Provider 断线时将未完成 Tool Part 标记 error,并允许安全重试整个模型 step。

6、Tool Registry 与运行上下文

type ToolContext = {
  sessionId: string; projectId: string;
  projectRoot: string; workspaceRoot: string;
  mode: "plan" | "build";
  signal: AbortSignal;
};

interface Tool<I, O> {
  name: string;
  description: string;
  schema: z.ZodType<I>;
  permission: "read" | "edit" | "execute" | "network";
  summarize(input: I): string;
  execute(input: I, context: ToolContext): Promise<O>;
}

Registry 只向模型暴露当前 mode/agent 允许的工具。Plan mode 不仅权限判 deny,最好根本不提供 edit/execute schema,减少模型误选。

7、安全文件工具

async function resolveWorkspacePath(root: string, relative: string) {
  if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes("..")) throw new ToolError("INVALID_PATH");
  const base = await fs.realpath(root);
  const target = path.resolve(base, relative);
  if (!target.startsWith(base + path.sep)) throw new ToolError("PATH_ESCAPE");
  const parentReal = await fs.realpath(path.dirname(target));
  if (!parentReal.startsWith(base + path.sep) && parentReal !== base) throw new ToolError("SYMLINK_ESCAPE");
  return target;
}

read 再限制 size/MIME;grepspawn("rg", args, {shell:false})apply_patch 解析所有路径、校验 before hash、dry-run 后写隔离 worktree;大型 stdout 存 artifact 并返回截断摘要。

8、Permission Broker

调用流程:Tool Part pending → 计算风险和匹配规则 → allow 直接运行;deny 写受控 Tool Result;ask 创建 Permission Request、Part waiting、Session waiting。用户响应:

const PermissionDecision = z.object({
  response: z.enum(["allow_once", "allow_rule", "deny"]),
  rememberPattern: z.string().max(500).optional(),
});

Server 重新校验 Session owner、pending request、call/input hash 和 workspace snapshot。allow_rule 的 pattern 不能比原请求更宽;例如批准 npm test 不能生成 bash:*

9、LangGraph.js Agent Loop

State 保存 session/turn、provider messages、pending call、permission 和预算。节点为 build_context → call_model → permission → run_tool → call_model;finish/预算/取消进入 END。checkpointer 支持 Server 重启恢复;tool executions 对 call ID 建唯一约束。

模型 token delta 不必全部写 Graph state,但必须写 Message Part/Event;Graph state 只保留恢复所需游标和稳定消息引用,避免每个 token 产生巨大 checkpoint。

10、Fastify API 与后台 Turn

app.post("/sessions/:id/messages", async (req, reply) => {
  const actor = await authenticate(req);
  const input = CreateMessage.parse(req.body);
  const turn = await sessions.enqueueMessage(actor, req.params.id, input);
  void runner.start(turn.id); // 实际生产使用持久队列
  return reply.code(202).send({ turnId: turn.id });
});

同一 Session 默认串行;新消息到来时可入队或要求取消当前 turn。生产不能用无持久性的 void 后台任务,应放队列并用唯一 turn ID。

11、Ink TUI

TUI 启动时拉 Session snapshot,随后订阅 SSE。Reducer 按 seq 去重;组件分为消息列表、工具卡、权限对话框、输入框和状态栏。

function reducer(state: UiState, raw: unknown): UiState {
  const event = ServerEvent.parse(raw);
  if (event.seq <= state.lastSeq) return state;
  switch (event.type) {
    case "part.updated": return updatePart(state, event);
    case "permission.requested": return { ...state, permission: event.request, lastSeq: event.seq };
    case "session.updated": return { ...state, session: event.session, lastSeq: event.seq };
    case "message.created": return addMessage(state, event);
    case "session.error": return { ...state, error: event.code, lastSeq: event.seq };
  }
}

快捷键只发 API:Tab 切 Plan/Build;Esc cancel;权限框允许一次/记住/拒绝;/undo 调 Server snapshot API。TUI 绝不直接修改文件。

12、测试

Contracts 做 schema fixture;Repository 测 seq/事务;工具做路径与超时属性测试;Permission 测规则优先级;FakeProvider 跑多轮工具循环;Fastify 注入测试 API;TUI reducer 做乱序/重复事件;E2E 启动 Server 后断开/重连、暂停/批准、重启恢复和 undo。

这一版完成后,已经具备与官方 OpenCode 相似的关键形态:独立 Server、多客户端、Session API、事件流、工具与权限。下一篇切换到 OpenClaw 的单渠道 Gateway。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
AI2026-09-01
Deep Agents 01:何为 Agent Harness,以及如何开始
1、本篇任务:完成一份多步骤、带证据的技术调研 普通客服 Agent 的问题短、工具少、输出即时。技术调研或编码任务会持续很久,产生计划、搜索结果、文件和中间结论。Deep Agents 在 LangChain/LangGraph 之上预装规划、虚拟文件系统、上下文压缩和子 Agent,适合这类开放任务。 本课让 Agent 比较两种向量数据库,并交付一份可验证报告。 2、什么时候需要 Deep Agent 满足以下两项以上再考虑:任务...
学习
AI2026-09-01
Deep Agents 02:子 Agent、虚拟文件系统与长期记忆
1、本篇任务:让主管只看结论,让子 Agent 处理细节 技术调研会产生几十次搜索和大量文件。如果全部进入主管上下文,真正的目标会被噪音淹没。本课用两个子 Agent: 收集证据, 检查结论;主管负责计划与最终合成。 2、什么时候委派,什么时候直接调用工具 适合委派:子任务有多步;需要专门提示或工具;会产生大量中间结果;只需返回有限结论。不适合:一步查询;主管需要全部中间上下文;协调成本超过任务本身。 3、配置专门子 Agent Pyt...
学习
AI2026-09-01
Deep Agents 03:生产化、Sandbox、权限与上线验收
1、本篇任务:让 Deep Agent 在隔离环境中分析代码 只读研究 Agent 风险有限;编码 Agent 需要读写文件、安装依赖和执行测试。本课不讲如何让模型写更漂亮的代码,只讲执行环境、权限、恢复和上线验收。 2、先做威胁模型 资产包括源代码、用户文件、云凭证、生产网络和发布权限;攻击入口包括用户消息、仓库内容、网页、依赖包、MCP 返回和命令输出。 Prompt injection 不是靠一句 system prompt 解决...
学习
AI2026-09-01
LangChain 01:全景、原理与学习路线
1、本篇学完要得到什么 这一篇只解决三个问题:LangChain 到底负责什么;它与 LangGraph、Deep Agents、LangSmith 是什么关系;后面应按什么顺序学习。 贯穿整套课程的项目是“退款政策与订单助手”。它最终能够:回答知识库中的退款规则;查询当前用户的订单;生成结构化答复;对真正的退款操作进行人工审批;断线后恢复;通过评测后发布。 先记住一句话: 模型负责理解与生成,应用负责数据、权限、状态和副作用。 如果把...
学习
AI2026-09-01
LangChain 02:模型、消息与结构化输出
1、本篇任务:让模型输出成为程序可以依赖的合同 上一课只证明 Agent 能运行。本课暂时不接业务工具,只完成一个“客服分诊器”:输入用户问题,输出意图、紧急程度、是否需要人工和给用户的答复。 本课的核心不是学更多模型参数,而是理解三层合同:消息决定模型看到了什么;schema 决定程序期待什么;业务校验决定结果是否真的可用。 2、消息不是一段字符串,而是一条执行记录 一次工具型对话通常包含四种消息: | 类型 | 由谁产生 | 作用...
学习
AI2026-09-01
LangChain 03:工具与 Agent——从函数到可控行动
1、本篇任务:让 Agent 安全地读取订单 上一课得到结构化分诊结果,但模型不知道真实订单。本课增加一个只读工具 ,走通完整 Agent 循环,并把模型、工具包装和领域服务的责任分开。 完成后,用户问“我的 A100 发货了吗”,Agent 会选择工具;工具只按当前登录用户查询;模型基于工具结果回答。它仍然不能退款,因为我们没有提供写工具。 2、工具的本质是受 schema 约束的应用函数 一个好工具需要:稳定名称、清楚描述、窄输入...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录