从 OpenCode 反推 mini coding agent
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
14-mini-coding-agent - 章节摘要:把 CLI、session、LLM、tool、permission 与 processor 的最小机制重组为一个可实现、可观察、可设安全边界的 mini coding agent。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/cli/cmd/run.tspackages/opencode/src/session/prompt.tspackages/opencode/src/session/llm.tspackages/opencode/src/tool/tool.tspackages/opencode/src/session/tools.tspackages/opencode/src/permission/index.tspackages/opencode/src/tool/read.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/shell.tspackages/opencode/src/session/processor.tspackages/opencode/src/server/routes/instance/httpapi/handlers/event.ts
源码基线:
eec0843ce422。本章的 mini agent 是教学性设计;OpenCode 源码负责证明机制,但示例代码不是 OpenCode 的可直接运行摘录。
0. 本章学习目标
Section titled “0. 本章学习目标”读完本章,你应该能:
- 从成熟 OpenCode 中剥离出 coding agent 的最小闭环。
- 画出消息账本、LLM、tool registry、permission 与 processor 的边界。
- 沿着“读取
package.json再回答”走完一轮 tool call。 - 写出有停止条件、参数校验、审批和错误回填的 loop 伪代码。
- 排出一个可交付 mini agent 的实现与测试顺序。
1. 一句话讲明白
Section titled “1. 一句话讲明白”一个最小 coding agent 是一台有账本的循环机:模型从消息决定下一步,工具把外部世界的结果写回账本,循环根据明确条件继续或停止,权限闸门则阻止不该自动发生的动作。
中心问题是:从 OpenCode 删除 UI、MCP、插件、LSP、多 provider 和分布式接口之后,哪些机制再删一个,agent 就不再是真的?
2. 先做减法:内核与产品层
Section titled “2. 先做减法:内核与产品层”必须保留的内核
User Input -> Message Ledger -> LLM(messages, tool schemas) -> text OR tool calls -> validate + permission + execute -> tool results back to ledger -> continue / stop
OpenCode 的成熟产品层
多入口、持久化数据库、多 provider、插件、MCP、LSP、compaction、subagent、结构化输出、事件兼容迁移、成本统计、分享、桌面 UI……“mini”不等于写成一次 llm(prompt)。只调用一次模型的是聊天封装;coding agent 必须能让模型观察工具结果后继续决策。
3. 最小架构地图
Section titled “3. 最小架构地图”| 模块 | 最小职责 | OpenCode 证据 |
|---|---|---|
| CLI | 解析输入,调用 session service | packages/opencode/src/cli/cmd/run.ts:768-803 |
packages/opencode/src/cli/cmd/run.ts
packages/opencode/src/cli/cmd/run.ts:768-803
768 if (!args.interactive) {区分交互与非交互。769 const events = await client.event.subscribe()订阅运行时事件。770 loop(client, events).catch((e) => {771 console.error(e)772 process.exit(1)773 })774775 if (args.command) {处理命令执行。776 const result = await client.session.command({注册 CLI 子命令。777 sessionID,778 agent,779 model: args.model,选择模型或 provider。780 command: args.command,处理命令执行。781 arguments: message,782 variant: args.variant,783 })784 if (result.error) {按条件进入分支。785 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。786 process.exitCode = 1787 }788 return返回给上一层。789 }790791 const model = pick(args.model)792 const result = await client.session.prompt({把输入交给会话主流程。793 sessionID,794 agent,795 model,796 variant: args.variant,797 parts: [...files, { type: "text", text: message }],798 })799 if (result.error) {按条件进入分支。800 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。801 process.exitCode = 1802 }803 return返回给上一层。
| Session | 保存 user/assistant/tool 消息 | packages/opencode/src/session/prompt.ts:1211-1230 |
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1211-1230
1211 const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(会话消息片段结构。1212 "SessionPrompt.prompt",把输入交给会话主流程。1213 )(function* (input: PromptInput) {1214 const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。1215 yield* revert.cleanup(session)等待 Effect 结果。1216 const message = yield* createUserMessage(input)等待 Effect 结果。1217 yield* sessions.touch(input.sessionID)等待 Effect 结果。12181219 const permissions: Permission.Ruleset = []1220 for (const [t, enabled] of Object.entries(input.tools ?? {})) {遍历集合。1221 permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })1222 }1223 if (permissions.length > 0) {按条件进入分支。1224 session.permission = permissions1225 yield* sessions.setPermission({ sessionID: session.id, permission: permissions })等待 Effect 结果。1226 }12271228 if (input.noReply === true) return message按条件进入分支。1229 return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。1230 })
| Agent loop | 选择继续、停止或进入工具轮 | packages/opencode/src/session/prompt.ts:1248-1276 |
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1248-1276
1248 while (true) {agent 核心循环。1249 yield* status.set(sessionID, { type: "busy" })等待 Effect 结果。1250 yield* slog.info("loop", { step })等待 Effect 结果。12511252 let msgs = yield* MessageV2.filterCompactedEffect(sessionID)会话消息片段结构。12531254 const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)会话消息片段结构。12551256 if (!lastUser) throw new Error("No user message found in stream. This should never happen.")按条件进入分支。12571258 const lastAssistantMsg = msgs.findLast(1259 (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,1260 )1261 // Some providers return "stop" even when the assistant message contains tool calls.1262 // Keep the loop running so tool results can be sent back to the model.1263 // Skip provider-executed tool parts — those were fully handled within the1264 // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.1265 const hasToolCalls =1266 lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false选择模型或 provider。12671268 if (按条件进入分支。1269 lastAssistant?.finish &&1270 !["tool-calls"].includes(lastAssistant.finish) &&1271 !hasToolCalls &&1272 lastUser.id < lastAssistant.id1273 ) {1274 yield* slog.info("exiting loop")等待 Effect 结果。1275 break1276 }
| LLM gateway | 接收 messages、system、tools 并流式返回 | packages/opencode/src/session/llm.ts |
| Tool registry | 暴露 schema 与 execute | packages/opencode/src/tool/tool.ts:16-45 |
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:16-45
16export type Context<M extends Metadata = Metadata> = {定义数据结构约束。17 sessionID: SessionID18 messageID: MessageID19 agent: string20 abort: AbortSignal用于中断运行任务。21 callID?: string22 extra?: { [key: string]: unknown }23 messages: MessageV2.WithParts[]会话消息片段结构。24 metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>Effect 异步工作流。25 ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>Effect 异步工作流。26}2728export interface ExecuteResult<M extends Metadata = Metadata> {定义数据结构约束。29 title: string30 metadata: M31 output: string32 attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]会话消息片段结构。33}3435export interface Def<定义数据结构约束。36 Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。37 M extends Metadata = Metadata,38> {39 id: string40 description: string41 parameters: Parameters42 jsonSchema?: JSONSchema743 execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>工具真正执行入口。44 formatValidationError?(error: unknown): string45}
| Permission | allow/deny/ask,等待用户回复 | packages/opencode/src/permission/index.ts:161-195 |
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:161-195
161 const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {进入权限审批。162 const { approved, pending } = yield* InstanceState.get(state)等待 Effect 结果。163 const { ruleset, ...request } = input164 let needsAsk = false165166 for (const pattern of request.patterns) {遍历集合。167 const rule = evaluate(request.permission, pattern, ruleset, approved)168 log.info("evaluated", { permission: request.permission, pattern, action: rule })169 if (rule.action === "deny") {按条件进入分支。170 return yield* new DeniedError({等待 Effect 结果。171 ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),172 })173 }174 if (rule.action === "allow") continue按条件进入分支。175 needsAsk = true176 }177178 if (!needsAsk) return按条件进入分支。179180 const id = request.id ?? PermissionID.ascending()181 const info = Schema.decodeUnknownSync(Request)({定义并校验数据形状。182 id,183 ...request,184 })185 log.info("asking", { id, permission: info.permission, patterns: info.patterns })186187 const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()等待 Effect 结果。188 pending.set(id, { info, deferred })189 yield* bus.publish(Event.Asked, info)广播状态变化。190 return yield* Effect.ensuring(Effect 异步工作流。191 Deferred.await(deferred),192 Effect.sync(() => {Effect 异步工作流。193 pending.delete(id)194 }),195 )
| Processor | 把流事件收敛成 message/tool 状态 | packages/opencode/src/session/processor.ts:376-520 |
packages/opencode/src/session/processor.ts
packages/opencode/src/session/processor.ts:376-520
376 case "tool-call": {377 if (ctx.assistantMessage.summary) {按条件进入分支。378 throw new Error(`Tool call not allowed while generating summary: ${value.name}`)失败时抛出错误。379 }380 const toolCall = yield* ensureToolCall(value)等待 Effect 结果。381 const input = toolInput(value.input)382 if (!toolCall.call.inputEnded) {按条件进入分支。383 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.384 if (flags.experimentalEventSystem) {按条件进入分支。385 yield* events.publish(SessionEvent.Tool.Input.Ended, {广播状态变化。386 sessionID: ctx.sessionID,387 callID: value.id,388 text: "",389 timestamp: DateTime.makeUnsafe(Date.now()),390 })391 }392 }393 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.394 if (flags.experimentalEventSystem) {按条件进入分支。395 yield* events.publish(SessionEvent.Tool.Called, {广播状态变化。396 sessionID: ctx.sessionID,397 callID: value.id,398 tool: value.name,399 input,400 provider: {选择模型或 provider。401 executed: toolCall.part.metadata?.providerExecuted === true,选择模型或 provider。402 ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),选择模型或 provider。403 },404 timestamp: DateTime.makeUnsafe(Date.now()),405 })406 }407 yield* updateToolCall(value.id, (match) => ({等待 Effect 结果。408 ...match,409 tool: value.name,410 state:411 match.state.status === "running"412 ? { ...match.state, input }413 : {414 status: "running",415 input,416 time: { start: Date.now() },417 },418 metadata: match.metadata?.providerExecuted选择模型或 provider。419 ? { ...value.providerMetadata, providerExecuted: true }选择模型或 provider。420 : value.providerMetadata,选择模型或 provider。421 }))422423 const parts = MessageV2.parts(ctx.assistantMessage.id)把流事件写回消息。424 const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)把流事件写回消息。425426 if (按条件进入分支。427 recentParts.length !== DOOM_LOOP_THRESHOLD ||428 !recentParts.every(429 (part) =>430 part.type === "tool" &&431 part.tool === value.name &&432 part.state.status !== "pending" &&433 JSON.stringify(part.state.input) === JSON.stringify(input),434 )435 ) {436 return返回给上一层。437 }438439 const agent = yield* agents.get(ctx.assistantMessage.agent)等待 Effect 结果。440 yield* permission.ask({进入权限审批。441 permission: "doom_loop",442 patterns: [value.name],443 sessionID: ctx.assistantMessage.sessionID,444 metadata: { tool: value.name, input },445 always: [value.name],446 ruleset: agent.permission,447 })448 return返回给上一层。449 }450451 case "tool-result": {452 const toolCall = yield* readToolCall(value.id)等待 Effect 结果。453 const rawOutput = toolResultOutput(value)454 const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>Effect 异步工作流。455 attachment.mime.startsWith("image/")456 ? image.normalize(attachment).pipe(457 Effect.catchIf(Effect 异步工作流。458 (error) => error instanceof Image.ResizerUnavailableError,459 () => Effect.succeed(attachment),Effect 异步工作流。460 ),461 Effect.exit,Effect 异步工作流。462 )463 : Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),会话消息片段结构。464 )465 const omitted = normalized.filter(Exit.isFailure).length466 const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)467 const output = {468 ...rawOutput,469 output:470 omitted === 0471 ? rawOutput.output472 : `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,473 attachments: attachments.length ? attachments : undefined,474 }475 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.476 if (flags.experimentalEventSystem) {按条件进入分支。477 yield* events.publish(SessionEvent.Tool.Success, {广播状态变化。478 sessionID: ctx.sessionID,479 callID: value.id,480 structured: output.metadata,481 content: [482 {483 type: "text",484 text: output.output,485 },486 ...(output.attachments?.map((item: MessageV2.FilePart) => ({会话消息片段结构。487 type: "file" as const,488 uri: item.url,489 mime: item.mime,490 name: item.filename,491 })) ?? []),492 ],493 provider: {选择模型或 provider。494 executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,选择模型或 provider。495 },496 timestamp: DateTime.makeUnsafe(Date.now()),497 })498 }499 yield* completeToolCall(value.id, output)等待 Effect 结果。500 return返回给上一层。501 }502503 case "tool-error": {504 const toolCall = yield* readToolCall(value.id)等待 Effect 结果。505 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.506 if (flags.experimentalEventSystem) {按条件进入分支。507 yield* events.publish(SessionEvent.Tool.Failed, {广播状态变化。508 sessionID: ctx.sessionID,509 callID: value.id,510 error: {511 type: "unknown",512 message: value.message,把流事件写回消息。513 },514 provider: {选择模型或 provider。515 executed: toolCall?.part.metadata?.providerExecuted === true,选择模型或 provider。516 },517 timestamp: DateTime.makeUnsafe(Date.now()),518 })519 }520 yield* failToolCall(value.id, value.error ?? new Error(value.message))把流事件写回消息。
Java 类比可以帮助定位:CLI 像 Controller,Session 像 aggregate repository,LLM/Tool 像 outbound ports,loop 像状态机。但模型输出是流,tool call 会把控制权在模型与程序之间往返,不能简单等同一次 service transaction。
4. 最小机制:先写对循环,再加框架
Section titled “4. 最小机制:先写对循环,再加框架”append(userMessage)
for step in 1..maxSteps: response = llm(messages, toolSchemas) append(response.text and toolCalls)
if response has no tool calls: return finalAnswer
for call in response.toolCalls: tool = registry.require(call.name) args = tool.validate(call.args) permission.check(tool, args) result = tool.execute(args) append(toolResult(call.id, result))
return stoppedBecauseMaxSteps这个骨架故意没有 streaming UI、MCP 和 plugin。它仍然保留四个不能省的约束:
- tool call 与 result 用
call.id对应; - 参数先校验再执行;
- 工具结果进入下一轮 messages;
- 循环有确定的停止上限。
OpenCode 的 loop 更复杂,但 while (true)、完成条件、step 与 tool resolution 清晰可见于 packages/opencode/src/session/prompt.ts:1248-1386。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1248-1386
1248 while (true) {agent 核心循环。1249 yield* status.set(sessionID, { type: "busy" })等待 Effect 结果。1250 yield* slog.info("loop", { step })等待 Effect 结果。12511252 let msgs = yield* MessageV2.filterCompactedEffect(sessionID)会话消息片段结构。12531254 const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)会话消息片段结构。12551256 if (!lastUser) throw new Error("No user message found in stream. This should never happen.")按条件进入分支。12571258 const lastAssistantMsg = msgs.findLast(1259 (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,1260 )1261 // Some providers return "stop" even when the assistant message contains tool calls.1262 // Keep the loop running so tool results can be sent back to the model.1263 // Skip provider-executed tool parts — those were fully handled within the1264 // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.1265 const hasToolCalls =1266 lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false选择模型或 provider。12671268 if (按条件进入分支。1269 lastAssistant?.finish &&1270 !["tool-calls"].includes(lastAssistant.finish) &&1271 !hasToolCalls &&1272 lastUser.id < lastAssistant.id1273 ) {1274 yield* slog.info("exiting loop")等待 Effect 结果。1275 break1276 }12771278 step++1279 if (step === 1)按条件进入分支。1280 yield* title({等待 Effect 结果。1281 session,1282 modelID: lastUser.model.modelID,选择模型或 provider。1283 providerID: lastUser.model.providerID,选择模型或 provider。1284 history: msgs,1285 }).pipe(Effect.ignore, Effect.forkIn(scope))Effect 异步工作流。12861287 const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)选择模型或 provider。1288 const task = tasks.pop()12891290 if (task?.type === "subtask") {按条件进入分支。1291 yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })等待 Effect 结果。1292 continue1293 }12941295 if (task?.type === "compaction") {按条件进入分支。1296 const result = yield* compaction.process({等待 Effect 结果。1297 messages: msgs,1298 parentID: lastUser.id,1299 sessionID,1300 auto: task.auto,1301 overflow: task.overflow,1302 })1303 if (result === "stop") break按条件进入分支。1304 continue1305 }13061307 if (按条件进入分支。1308 lastFinished &&1309 lastFinished.summary !== true &&1310 (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))等待 Effect 结果。1311 ) {1312 yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })选择模型或 provider。1313 continue1314 }13151316 const agent = yield* agents.get(lastUser.agent)等待 Effect 结果。1317 if (!agent) {按条件进入分支。1318 const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。1319 const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""1320 const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })1321 yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })广播状态变化。1322 throw error失败时抛出错误。1323 }1324 const maxSteps = agent.steps ?? Infinity1325 const isLastStep = step >= maxSteps1326 msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(等待 Effect 结果。1327 Effect.provideService(RuntimeFlags.Service, flags),Effect 异步工作流。1328 Effect.provideService(AppFileSystem.Service, fsys),读写本地文件。1329 Effect.provideService(Session.Service, sessions),Effect 异步工作流。1330 )13311332 const msg: MessageV2.Assistant = {会话消息片段结构。1333 id: MessageID.ascending(),1334 parentID: lastUser.id,1335 role: "assistant",1336 mode: agent.name,1337 agent: agent.name,1338 variant: lastUser.model.variant,1339 path: { cwd: ctx.directory, root: ctx.worktree },1340 cost: 0,1341 tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },1342 modelID: model.id,选择模型或 provider。1343 providerID: model.providerID,选择模型或 provider。1344 time: { created: Date.now() },1345 sessionID,1346 }1347 yield* sessions.updateMessage(msg)等待 Effect 结果。13481349 const finalizeInterruptedAssistant = Effect.gen(function* () {Effect 异步工作流。1350 if (msg.time.completed) return按条件进入分支。1351 msg.error ??= MessageV2.fromError(new DOMException("Aborted", "AbortError"), {会话消息片段结构。1352 providerID: msg.providerID,选择模型或 provider。1353 aborted: true,1354 })1355 msg.time.completed = Date.now()1356 yield* sessions.updateMessage(msg)等待 Effect 结果。1357 })13581359 const handle = yield* processor等待 Effect 结果。1360 .create({1361 assistantMessage: msg,1362 sessionID,1363 model,1364 })1365 .pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant))Effect 异步工作流。13661367 const outcome: "break" | "continue" = yield* Effect.gen(function* () {Effect 异步工作流。1368 const lastUserMsg = msgs.findLast((m) => m.info.role === "user")1369 const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false1370 const promptOps = yield* ops()等待 Effect 结果。13711372 const tools = yield* SessionTools.resolve({等待 Effect 结果。1373 agent,1374 session,1375 model,1376 processor: handle,1377 bypassAgentCheck,1378 messages: msgs,1379 promptOps,1380 }).pipe(1381 Effect.provideService(Plugin.Service, plugin),调用插件扩展点。1382 Effect.provideService(Permission.Service, permission),Effect 异步工作流。1383 Effect.provideService(ToolRegistry.Service, registry),Effect 异步工作流。1384 Effect.provideService(MCP.Service, mcp),Effect 异步工作流。1385 Effect.provideService(Truncate.Service, truncate),Effect 异步工作流。1386 )
5. 先定义稳定的数据形状
Section titled “5. 先定义稳定的数据形状”教学版不必复制 OpenCode 的全部 MessageV2,可以从下面开始:
1type Message =定义数据结构约束。2 | { role: "user"; content: string }3 | { role: "assistant"; content: string; toolCalls?: ToolCall[] }4 | { role: "tool"; callID: string; name: string; output: string; isError?: boolean }56type ToolCall = {定义数据结构约束。7 id: string8 name: string9 args: unknown10}1112type Tool = {定义数据结构约束。13 name: string14 description: string15 validate(args: unknown): Record<string, unknown>16 execute(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>工具真正执行入口。17}
OpenCode 的真实工具接口还带 session/message/agent、AbortSignal、metadata 更新和 ask(...),并要求返回 title、metadata、output 与可选 attachments,见 packages/opencode/src/tool/tool.ts:16-45。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:16-45
16export type Context<M extends Metadata = Metadata> = {定义数据结构约束。17 sessionID: SessionID18 messageID: MessageID19 agent: string20 abort: AbortSignal用于中断运行任务。21 callID?: string22 extra?: { [key: string]: unknown }23 messages: MessageV2.WithParts[]会话消息片段结构。24 metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>Effect 异步工作流。25 ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>Effect 异步工作流。26}2728export interface ExecuteResult<M extends Metadata = Metadata> {定义数据结构约束。29 title: string30 metadata: M31 output: string32 attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]会话消息片段结构。33}3435export interface Def<定义数据结构约束。36 Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。37 M extends Metadata = Metadata,38> {39 id: string40 description: string41 parameters: Parameters42 jsonSchema?: JSONSchema743 execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>工具真正执行入口。44 formatValidationError?(error: unknown): string45}
为什么 context 不能只有 cwd?因为一次工具执行还必须能被取消、归属某个 call/message、发起权限请求,并把进度写回正确位置。
6. 一条具体旅程:读取 package.json 再回答
Section titled “6. 一条具体旅程:读取 package.json 再回答”用户输入:
读取 package.json,告诉我项目使用什么包管理器。下面是根据 OpenCode 机制整理的典型路径,不代表本章真的向模型发过这条请求。
第一步:CLI 把输入交给 session
Section titled “第一步:CLI 把输入交给 session”OpenCode 非交互 CLI 组装 text/file parts,然后调用 client.session.prompt(...),见 packages/opencode/src/cli/cmd/run.ts:768-803。
packages/opencode/src/cli/cmd/run.ts
packages/opencode/src/cli/cmd/run.ts:768-803
768 if (!args.interactive) {区分交互与非交互。769 const events = await client.event.subscribe()订阅运行时事件。770 loop(client, events).catch((e) => {771 console.error(e)772 process.exit(1)773 })774775 if (args.command) {处理命令执行。776 const result = await client.session.command({注册 CLI 子命令。777 sessionID,778 agent,779 model: args.model,选择模型或 provider。780 command: args.command,处理命令执行。781 arguments: message,782 variant: args.variant,783 })784 if (result.error) {按条件进入分支。785 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。786 process.exitCode = 1787 }788 return返回给上一层。789 }790791 const model = pick(args.model)792 const result = await client.session.prompt({把输入交给会话主流程。793 sessionID,794 agent,795 model,796 variant: args.variant,797 parts: [...files, { type: "text", text: message }],798 })799 if (result.error) {按条件进入分支。800 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。801 process.exitCode = 1802 }803 return返回给上一层。
mini agent 的 CLI 同样应保持薄:
1const session = await sessions.create()2const answer = await agent.prompt(session.id, userText)3process.stdout.write(answer)
不要在 CLI 里解析模型 tool call,否则未来 Web/API 入口只能复制这段循环。
第二步:先落 user message,再进入 loop
Section titled “第二步:先落 user message,再进入 loop”SessionPrompt.prompt 读取 session、清理 revert 状态、创建 user message、更新 session,然后才调用 loop,见 packages/opencode/src/session/prompt.ts:1211-1230。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1211-1230
1211 const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(会话消息片段结构。1212 "SessionPrompt.prompt",把输入交给会话主流程。1213 )(function* (input: PromptInput) {1214 const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。1215 yield* revert.cleanup(session)等待 Effect 结果。1216 const message = yield* createUserMessage(input)等待 Effect 结果。1217 yield* sessions.touch(input.sessionID)等待 Effect 结果。12181219 const permissions: Permission.Ruleset = []1220 for (const [t, enabled] of Object.entries(input.tools ?? {})) {遍历集合。1221 permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })1222 }1223 if (permissions.length > 0) {按条件进入分支。1224 session.permission = permissions1225 yield* sessions.setPermission({ sessionID: session.id, permission: permissions })等待 Effect 结果。1226 }12271228 if (input.noReply === true) return message按条件进入分支。1229 return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。1230 })
顺序保证:即使模型失败,用户请求仍在账本里,诊断与重试有依据。
第三步:loop 选择模型、agent 与工具
Section titled “第三步:loop 选择模型、agent 与工具”OpenCode 每轮读取压缩后的消息,判断上一条 assistant 是否真的完成。源码特别处理 provider 声称 stop、但 message 中仍有本地 tool call 的情况,见 packages/opencode/src/session/prompt.ts:1252-1276。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1252-1276
1252 let msgs = yield* MessageV2.filterCompactedEffect(sessionID)会话消息片段结构。12531254 const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)会话消息片段结构。12551256 if (!lastUser) throw new Error("No user message found in stream. This should never happen.")按条件进入分支。12571258 const lastAssistantMsg = msgs.findLast(1259 (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,1260 )1261 // Some providers return "stop" even when the assistant message contains tool calls.1262 // Keep the loop running so tool results can be sent back to the model.1263 // Skip provider-executed tool parts — those were fully handled within the1264 // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.1265 const hasToolCalls =1266 lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false选择模型或 provider。12671268 if (按条件进入分支。1269 lastAssistant?.finish &&1270 !["tool-calls"].includes(lastAssistant.finish) &&1271 !hasToolCalls &&1272 lastUser.id < lastAssistant.id1273 ) {1274 yield* slog.info("exiting loop")等待 Effect 结果。1275 break1276 }
这是一个重要停止条件:不能只信 finish 字符串,还要检查有没有尚需回填的 tool call。
随后创建 assistant message、processor handle,并调用 SessionTools.resolve(...),见 packages/opencode/src/session/prompt.ts:1332-1386。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1332-1386
1332 const msg: MessageV2.Assistant = {会话消息片段结构。1333 id: MessageID.ascending(),1334 parentID: lastUser.id,1335 role: "assistant",1336 mode: agent.name,1337 agent: agent.name,1338 variant: lastUser.model.variant,1339 path: { cwd: ctx.directory, root: ctx.worktree },1340 cost: 0,1341 tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },1342 modelID: model.id,选择模型或 provider。1343 providerID: model.providerID,选择模型或 provider。1344 time: { created: Date.now() },1345 sessionID,1346 }1347 yield* sessions.updateMessage(msg)等待 Effect 结果。13481349 const finalizeInterruptedAssistant = Effect.gen(function* () {Effect 异步工作流。1350 if (msg.time.completed) return按条件进入分支。1351 msg.error ??= MessageV2.fromError(new DOMException("Aborted", "AbortError"), {会话消息片段结构。1352 providerID: msg.providerID,选择模型或 provider。1353 aborted: true,1354 })1355 msg.time.completed = Date.now()1356 yield* sessions.updateMessage(msg)等待 Effect 结果。1357 })13581359 const handle = yield* processor等待 Effect 结果。1360 .create({1361 assistantMessage: msg,1362 sessionID,1363 model,1364 })1365 .pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant))Effect 异步工作流。13661367 const outcome: "break" | "continue" = yield* Effect.gen(function* () {Effect 异步工作流。1368 const lastUserMsg = msgs.findLast((m) => m.info.role === "user")1369 const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false1370 const promptOps = yield* ops()等待 Effect 结果。13711372 const tools = yield* SessionTools.resolve({等待 Effect 结果。1373 agent,1374 session,1375 model,1376 processor: handle,1377 bypassAgentCheck,1378 messages: msgs,1379 promptOps,1380 }).pipe(1381 Effect.provideService(Plugin.Service, plugin),调用插件扩展点。1382 Effect.provideService(Permission.Service, permission),Effect 异步工作流。1383 Effect.provideService(ToolRegistry.Service, registry),Effect 异步工作流。1384 Effect.provideService(MCP.Service, mcp),Effect 异步工作流。1385 Effect.provideService(Truncate.Service, truncate),Effect 异步工作流。1386 )
第四步:模型看到 read 工具 schema
Section titled “第四步:模型看到 read 工具 schema”Tool definition 提供 id、description、parameters schema 与 execute,见 packages/opencode/src/tool/tool.ts:35-45。OpenCode wrapper 会在执行前 decode args,失败时给出“重写输入以满足 schema”的错误,见 packages/opencode/src/tool/tool.ts:79-126。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:35-45
35export interface Def<定义数据结构约束。36 Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。37 M extends Metadata = Metadata,38> {39 id: string40 description: string41 parameters: Parameters42 jsonSchema?: JSONSchema743 execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>工具真正执行入口。44 formatValidationError?(error: unknown): string45}
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:79-126
79function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(定义并校验数据形状。80 id: string,81 init: Init<Parameters, Result>,82 truncate: Truncate.Interface,83 agents: Agent.Interface,84) {85 return () =>返回给上一层。86 Effect.gen(function* () {Effect 异步工作流。87 const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }等待 Effect 结果。88 // Compile the parser closure once per tool init; `decodeUnknownEffect`89 // allocates a new closure per call, so hoisting avoids re-closing it for90 // every LLM tool invocation.91 const decode = Schema.decodeUnknownEffect(toolInfo.parameters)定义并校验数据形状。92 const execute = toolInfo.execute93 toolInfo.execute = (args, ctx) => {94 const attrs = {95 "tool.name": id,96 "session.id": ctx.sessionID,97 "message.id": ctx.messageID,98 ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),99 }100 return Effect.gen(function* () {Effect 异步工作流。101 const decoded = yield* decode(args).pipe(等待 Effect 结果。102 Effect.mapError((error) =>Effect 异步工作流。103 toolInfo.formatValidationError104 ? new Error(toolInfo.formatValidationError(error), { cause: error })105 : new Error(106 `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,107 { cause: error },108 ),109 ),110 )111 const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。112 if (result.metadata.truncated !== undefined) {按条件进入分支。113 return result返回给上一层。114 }115 const agent = yield* agents.get(ctx.agent)等待 Effect 结果。116 const truncated = yield* truncate.output(result.output, {}, agent)等待 Effect 结果。117 return {返回给上一层。118 ...result,119 output: truncated.content,120 metadata: {121 ...result.metadata,122 truncated: truncated.truncated,123 ...(truncated.truncated && { outputPath: truncated.outputPath }),124 },125 }126 }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))Effect 异步工作流。
模型可能返回:
1{2 "id": "call_1",3 "name": "read",4 "args": { "filePath": "package.json" }5}
这只是教学示例。真正字段名和可用 schema 应以 packages/opencode/src/tool/read.ts 当前定义为准。
第五步:权限在执行路径内,而不是 UI 提示框里
Section titled “第五步:权限在执行路径内,而不是 UI 提示框里”SessionTools.resolve 创建的 tool context 把 agent 与 session ruleset 合并,并通过 Permission service 执行 ask,见 packages/opencode/src/session/tools.ts:42-72。
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:42-72
42 const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43 sessionID: input.session.id,44 abort: options.abortSignal!,45 messageID: input.processor.message.id,46 callID: options.toolCallId,47 extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48 agent: input.agent.name,49 messages: input.messages,50 metadata: (val) =>51 input.processor.updateToolCall(options.toolCallId, (match) => {52 if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53 return {返回给上一层。54 ...match,55 state: {56 title: val.title,57 metadata: val.metadata,58 status: "running",59 input: args,60 time: { start: Date.now() },61 },62 }63 }),64 ask: (req) =>65 permission66 .ask({67 ...req,68 sessionID: input.session.id,69 tool: { messageID: input.processor.message.id, callID: options.toolCallId },70 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71 })72 .pipe(Effect.orDie),Effect 异步工作流。
Permission service 对每个 pattern 求值:
- 任一
deny立即失败; - 全部
allow直接返回; - 存在
ask就发布事件并等待 Deferred reply。
见 packages/opencode/src/permission/index.ts:161-195。
packages/opencode/src/permission/index.ts
packages/opencode/src/permission/index.ts:161-195
161 const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {进入权限审批。162 const { approved, pending } = yield* InstanceState.get(state)等待 Effect 结果。163 const { ruleset, ...request } = input164 let needsAsk = false165166 for (const pattern of request.patterns) {遍历集合。167 const rule = evaluate(request.permission, pattern, ruleset, approved)168 log.info("evaluated", { permission: request.permission, pattern, action: rule })169 if (rule.action === "deny") {按条件进入分支。170 return yield* new DeniedError({等待 Effect 结果。171 ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),172 })173 }174 if (rule.action === "allow") continue按条件进入分支。175 needsAsk = true176 }177178 if (!needsAsk) return按条件进入分支。179180 const id = request.id ?? PermissionID.ascending()181 const info = Schema.decodeUnknownSync(Request)({定义并校验数据形状。182 id,183 ...request,184 })185 log.info("asking", { id, permission: info.permission, patterns: info.patterns })186187 const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()等待 Effect 结果。188 pending.set(id, { info, deferred })189 yield* bus.publish(Event.Asked, info)广播状态变化。190 return yield* Effect.ensuring(Effect 异步工作流。191 Deferred.await(deferred),192 Effect.sync(() => {Effect 异步工作流。193 pending.delete(id)194 }),195 )
因此,UI 只是审批的一个呈现者。真正闸门必须在工具执行路径里,否则 CLI/API 可绕过安全提示。
第六步:执行 read,并把结果绑定到 call id
Section titled “第六步:执行 read,并把结果绑定到 call id”OpenCode 为 registry tool 包装 plugin before/after hooks,再调用 item.execute(args, ctx),见 packages/opencode/src/session/tools.ts:75-115。
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:75-115
75 for (const item of yield* registry.tools({等待 Effect 结果。76 modelID: ModelID.make(input.model.api.id),选择模型或 provider。77 providerID: input.model.providerID,选择模型或 provider。78 agent: input.agent,79 })) {80 const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81 tools[item.id] = tool({82 description: item.description,83 inputSchema: jsonSchema(schema),84 execute(args, options) {工具真正执行入口。85 return run.promise(返回给上一层。86 Effect.gen(function* () {Effect 异步工作流。87 const ctx = context(args, options)88 yield* plugin.trigger(调用插件扩展点。89 "tool.execute.before",90 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91 { args },92 )93 const result = yield* item.execute(args, ctx)工具真正执行入口。94 const output = {95 ...result,96 attachments: result.attachments?.map((attachment) => ({97 ...attachment,98 id: PartID.ascending(),99 sessionID: ctx.sessionID,100 messageID: input.processor.message.id,101 })),102 }103 yield* plugin.trigger(调用插件扩展点。104 "tool.execute.after",105 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106 output,107 )108 if (options.abortSignal?.aborted) {按条件进入分支。109 yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110 }111 return output返回给上一层。112 }),113 )114 },115 })
mini agent 第一版可以不做 hooks,但必须保留:
validate(call.args) -> permission.check -> read.execute -> ToolResult(call.id, output)不要把 read 输出拼进一段 system prompt;它应成为与 call_1 对应的 tool message,让模型和日志都知道这是谁的结果。
第七步:processor 把 tool 流事件收敛成账本状态
Section titled “第七步:processor 把 tool 流事件收敛成账本状态”OpenCode 在 tool-call 事件把 part 设为 running,并检测重复同一工具/输入的 doom loop;在 tool-result 事件规范化附件并完成对应 call,见 packages/opencode/src/session/processor.ts:376-500。
packages/opencode/src/session/processor.ts
packages/opencode/src/session/processor.ts:376-500
376 case "tool-call": {377 if (ctx.assistantMessage.summary) {按条件进入分支。378 throw new Error(`Tool call not allowed while generating summary: ${value.name}`)失败时抛出错误。379 }380 const toolCall = yield* ensureToolCall(value)等待 Effect 结果。381 const input = toolInput(value.input)382 if (!toolCall.call.inputEnded) {按条件进入分支。383 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.384 if (flags.experimentalEventSystem) {按条件进入分支。385 yield* events.publish(SessionEvent.Tool.Input.Ended, {广播状态变化。386 sessionID: ctx.sessionID,387 callID: value.id,388 text: "",389 timestamp: DateTime.makeUnsafe(Date.now()),390 })391 }392 }393 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.394 if (flags.experimentalEventSystem) {按条件进入分支。395 yield* events.publish(SessionEvent.Tool.Called, {广播状态变化。396 sessionID: ctx.sessionID,397 callID: value.id,398 tool: value.name,399 input,400 provider: {选择模型或 provider。401 executed: toolCall.part.metadata?.providerExecuted === true,选择模型或 provider。402 ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),选择模型或 provider。403 },404 timestamp: DateTime.makeUnsafe(Date.now()),405 })406 }407 yield* updateToolCall(value.id, (match) => ({等待 Effect 结果。408 ...match,409 tool: value.name,410 state:411 match.state.status === "running"412 ? { ...match.state, input }413 : {414 status: "running",415 input,416 time: { start: Date.now() },417 },418 metadata: match.metadata?.providerExecuted选择模型或 provider。419 ? { ...value.providerMetadata, providerExecuted: true }选择模型或 provider。420 : value.providerMetadata,选择模型或 provider。421 }))422423 const parts = MessageV2.parts(ctx.assistantMessage.id)把流事件写回消息。424 const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)把流事件写回消息。425426 if (按条件进入分支。427 recentParts.length !== DOOM_LOOP_THRESHOLD ||428 !recentParts.every(429 (part) =>430 part.type === "tool" &&431 part.tool === value.name &&432 part.state.status !== "pending" &&433 JSON.stringify(part.state.input) === JSON.stringify(input),434 )435 ) {436 return返回给上一层。437 }438439 const agent = yield* agents.get(ctx.assistantMessage.agent)等待 Effect 结果。440 yield* permission.ask({进入权限审批。441 permission: "doom_loop",442 patterns: [value.name],443 sessionID: ctx.assistantMessage.sessionID,444 metadata: { tool: value.name, input },445 always: [value.name],446 ruleset: agent.permission,447 })448 return返回给上一层。449 }450451 case "tool-result": {452 const toolCall = yield* readToolCall(value.id)等待 Effect 结果。453 const rawOutput = toolResultOutput(value)454 const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>Effect 异步工作流。455 attachment.mime.startsWith("image/")456 ? image.normalize(attachment).pipe(457 Effect.catchIf(Effect 异步工作流。458 (error) => error instanceof Image.ResizerUnavailableError,459 () => Effect.succeed(attachment),Effect 异步工作流。460 ),461 Effect.exit,Effect 异步工作流。462 )463 : Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),会话消息片段结构。464 )465 const omitted = normalized.filter(Exit.isFailure).length466 const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)467 const output = {468 ...rawOutput,469 output:470 omitted === 0471 ? rawOutput.output472 : `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,473 attachments: attachments.length ? attachments : undefined,474 }475 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.476 if (flags.experimentalEventSystem) {按条件进入分支。477 yield* events.publish(SessionEvent.Tool.Success, {广播状态变化。478 sessionID: ctx.sessionID,479 callID: value.id,480 structured: output.metadata,481 content: [482 {483 type: "text",484 text: output.output,485 },486 ...(output.attachments?.map((item: MessageV2.FilePart) => ({会话消息片段结构。487 type: "file" as const,488 uri: item.url,489 mime: item.mime,490 name: item.filename,491 })) ?? []),492 ],493 provider: {选择模型或 provider。494 executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,选择模型或 provider。495 },496 timestamp: DateTime.makeUnsafe(Date.now()),497 })498 }499 yield* completeToolCall(value.id, output)等待 Effect 结果。500 return返回给上一层。
mini agent 即使不做 token streaming,也应该让 tool call 经历:
pending -> running -> completed | error否则取消、重试、UI 展示和崩溃恢复都没有可靠状态。
第八步:结果回到模型,模型才生成答案
Section titled “第八步:结果回到模型,模型才生成答案”加入 tool result 后重新调用 LLM。第二轮模型看到 package.json 内容,才回答包管理器。没有这次回环,工具执行只是旁路脚本,不是 agent observation。
如果第二轮不再产生 tool call,loop 返回 final answer;如果继续调用工具,就重复以上过程,直到完成、失败、取消或达到 max steps。
7. 停止与失败:最小 agent 也必须认真处理
Section titled “7. 停止与失败:最小 agent 也必须认真处理”7.1 正常完成
Section titled “7.1 正常完成”没有本地待处理 tool call,模型给出非 tool-calls finish。OpenCode 的判断见 packages/opencode/src/session/prompt.ts:1261-1276。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1261-1276
1261 // Some providers return "stop" even when the assistant message contains tool calls.1262 // Keep the loop running so tool results can be sent back to the model.1263 // Skip provider-executed tool parts — those were fully handled within the1264 // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.1265 const hasToolCalls =1266 lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false选择模型或 provider。12671268 if (按条件进入分支。1269 lastAssistant?.finish &&1270 !["tool-calls"].includes(lastAssistant.finish) &&1271 !hasToolCalls &&1272 lastUser.id < lastAssistant.id1273 ) {1274 yield* slog.info("exiting loop")等待 Effect 结果。1275 break1276 }
7.2 达到步数上限
Section titled “7.2 达到步数上限”OpenCode 读取 agent.steps,在最后一步向模型追加限制提示,见 packages/opencode/src/session/prompt.ts:1324-1326、:1435-1439。mini agent 可更简单:达到上限就停止并返回结构化原因,不能无限循环。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1324-1326
1324 const maxSteps = agent.steps ?? Infinity1325 const isLastStep = step >= maxSteps1326 msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(等待 Effect 结果。
7.3 权限拒绝
Section titled “7.3 权限拒绝”拒绝不是空字符串结果。它应该成为明确 error/denied 状态,让模型知道动作没有发生,也让用户知道文件或 shell 没被执行。
7.4 工具参数错误
Section titled “7.4 工具参数错误”schema validation 必须早于副作用。把可修复错误写回模型,模型可能重试;连续相同错误则应计入循环保护。
7.5 重复工具调用
Section titled “7.5 重复工具调用”OpenCode 检查最近若干 part 是否对同一工具重复同样 input,并通过 doom_loop permission 决定是否继续,见 packages/opencode/src/session/processor.ts:423-448。
packages/opencode/src/session/processor.ts
packages/opencode/src/session/processor.ts:423-448
423 const parts = MessageV2.parts(ctx.assistantMessage.id)把流事件写回消息。424 const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)把流事件写回消息。425426 if (按条件进入分支。427 recentParts.length !== DOOM_LOOP_THRESHOLD ||428 !recentParts.every(429 (part) =>430 part.type === "tool" &&431 part.tool === value.name &&432 part.state.status !== "pending" &&433 JSON.stringify(part.state.input) === JSON.stringify(input),434 )435 ) {436 return返回给上一层。437 }438439 const agent = yield* agents.get(ctx.assistantMessage.agent)等待 Effect 结果。440 yield* permission.ask({进入权限审批。441 permission: "doom_loop",442 patterns: [value.name],443 sessionID: ctx.assistantMessage.sessionID,444 metadata: { tool: value.name, input },445 always: [value.name],446 ruleset: agent.permission,447 })448 return返回给上一层。
mini agent 可以先用更透明的策略:同一 name + stableJson(args) 连续出现 N 次就停止,并报告重复轨迹。
7.6 取消
Section titled “7.6 取消”Tool context 带 AbortSignal,见 packages/opencode/src/tool/tool.ts:16-25。mini agent 应把同一个 signal 传给 LLM 与工具;只让 UI 显示“已取消”而后台进程继续运行是错误实现。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:16-25
16export type Context<M extends Metadata = Metadata> = {定义数据结构约束。17 sessionID: SessionID18 messageID: MessageID19 agent: string20 abort: AbortSignal用于中断运行任务。21 callID?: string22 extra?: { [key: string]: unknown }23 messages: MessageV2.WithParts[]会话消息片段结构。24 metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>Effect 异步工作流。25 ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>Effect 异步工作流。
8. 第一版应该保留什么,延后什么
Section titled “8. 第一版应该保留什么,延后什么”| 第一版保留 | 可以延后 | 原因 |
|---|---|---|
| 内存 message ledger | 数据库与 session 分享 | 先证明闭环 |
| 一个 provider adapter | 多 provider transform | 先稳定内部协议 |
| read + 明确审批的 shell | edit/write/patch/LSP | 降低不可逆风险 |
| schema validation | MCP/plugin tool | 先稳定 registry |
| max steps + cancel + duplicate guard | compaction/subagent | 先防失控 |
| text event logger | TUI/Desktop | 先获得可观测性 |
注意:如果目标明确要求“coding agent 能修改代码”,那么 edit 不能永远延后;但可以在只读闭环验证后再加入,并把 diff/审批/原子写入作为独立里程碑。
9. 一个可实现的目录骨架
Section titled “9. 一个可实现的目录骨架”src/ cli.ts # 输入输出,不含 loop agent-loop.ts # 状态机与停止条件 messages.ts # ledger 类型与 append llm.ts # provider-neutral port permissions.ts # allow/deny/ask tools/ tool.ts # schema + execute contract registry.ts read.ts shell.ts events.ts # 可观察但不拥有真相test/ agent-loop.test.ts permission.test.ts read.test.ts shell.test.ts这里的关键不是文件数量,而是依赖方向:CLI 依赖 loop,loop 依赖 ports,具体 provider/tool 实现 ports;任何 UI 都不应被 tool import。
10. 实现顺序:每一步都形成可验证能力
Section titled “10. 实现顺序:每一步都形成可验证能力”里程碑一:无工具的消息闭环
Section titled “里程碑一:无工具的消息闭环”- 定义 Message 与 LLM port。
- user message 落账后调用 fake deterministic LLM。
- 断言 assistant message 也落账。
里程碑二:一个只读工具
Section titled “里程碑二:一个只读工具”- 定义 Tool 与 registry。
- LLM 返回 read call。
- 参数校验、执行、result 回填、第二轮回答。
里程碑三:停止与错误
Section titled “里程碑三:停止与错误”- max steps。
- unknown tool。
- invalid args。
- repeated identical call。
- AbortSignal。
里程碑四:权限与 shell
Section titled “里程碑四:权限与 shell”- 默认拒绝危险 shell。
- allow/deny/ask 三条路径。
- 审批只允许本次与持久批准分开。
- stdout/stderr/exit code 明确建模。
里程碑五:真实 provider 与事件
Section titled “里程碑五:真实 provider 与事件”- 用 adapter 接真实流式 API。
- 将 provider events 归一化。
- 事件 logger 展示 step、call、result、finish,不泄漏 secret。
这种顺序让每一步都可演示、可测试。先做漂亮 TUI 会掩盖循环与安全边界尚未成立的问题。
11. 最小测试矩阵
Section titled “11. 最小测试矩阵”| 场景 | 关键断言 |
|---|---|
| 模型直接回答 | 只调用一次 LLM,loop stop |
| read 后回答 | 两次 LLM,tool result 带原 call id |
| 参数非法 | 工具未执行,错误可观察 |
| permission deny | 副作用未发生,状态为 denied/error |
| permission ask | 回复前暂停,回复后只继续一次 |
| 相同调用重复 | 达阈值停止,不无限循环 |
| 用户取消 | LLM/tool 收到 abort,账本记录 interrupted |
| 工具抛错 | error result 回填或按策略终止 |
尽量用真实临时目录测试 read/shell,而不是 mock 掉文件系统后只测试自己复制的逻辑。这个思想也与 OpenCode AGENTS.md 的“测试真实实现、尽量少 mock”一致,但 mini 项目仍需根据速度和隔离选择测试层次。
12. OpenCode 的选择:哪些不应照抄
Section titled “12. OpenCode 的选择:哪些不应照抄”OpenCode 当前 loop 还处理 compaction、subtask、结构化输出、plugin transform、provider executed tools、summary、事件迁移等,见 packages/opencode/src/session/prompt.ts:1287-1471。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1287-1471
1287 const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)选择模型或 provider。1288 const task = tasks.pop()12891290 if (task?.type === "subtask") {按条件进入分支。1291 yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })等待 Effect 结果。1292 continue1293 }12941295 if (task?.type === "compaction") {按条件进入分支。1296 const result = yield* compaction.process({等待 Effect 结果。1297 messages: msgs,1298 parentID: lastUser.id,1299 sessionID,1300 auto: task.auto,1301 overflow: task.overflow,1302 })1303 if (result === "stop") break按条件进入分支。1304 continue1305 }13061307 if (按条件进入分支。1308 lastFinished &&1309 lastFinished.summary !== true &&1310 (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))等待 Effect 结果。1311 ) {1312 yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })选择模型或 provider。1313 continue1314 }13151316 const agent = yield* agents.get(lastUser.agent)等待 Effect 结果。1317 if (!agent) {按条件进入分支。1318 const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。1319 const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""1320 const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })1321 yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })广播状态变化。1322 throw error失败时抛出错误。1323 }1324 const maxSteps = agent.steps ?? Infinity1325 const isLastStep = step >= maxSteps1326 msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(等待 Effect 结果。1327 Effect.provideService(RuntimeFlags.Service, flags),Effect 异步工作流。1328 Effect.provideService(AppFileSystem.Service, fsys),读写本地文件。1329 Effect.provideService(Session.Service, sessions),Effect 异步工作流。1330 )13311332 const msg: MessageV2.Assistant = {会话消息片段结构。1333 id: MessageID.ascending(),1334 parentID: lastUser.id,1335 role: "assistant",1336 mode: agent.name,1337 agent: agent.name,1338 variant: lastUser.model.variant,1339 path: { cwd: ctx.directory, root: ctx.worktree },1340 cost: 0,1341 tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },1342 modelID: model.id,选择模型或 provider。1343 providerID: model.providerID,选择模型或 provider。1344 time: { created: Date.now() },1345 sessionID,1346 }1347 yield* sessions.updateMessage(msg)等待 Effect 结果。13481349 const finalizeInterruptedAssistant = Effect.gen(function* () {Effect 异步工作流。1350 if (msg.time.completed) return按条件进入分支。1351 msg.error ??= MessageV2.fromError(new DOMException("Aborted", "AbortError"), {会话消息片段结构。1352 providerID: msg.providerID,选择模型或 provider。1353 aborted: true,1354 })1355 msg.time.completed = Date.now()1356 yield* sessions.updateMessage(msg)等待 Effect 结果。1357 })13581359 const handle = yield* processor等待 Effect 结果。1360 .create({1361 assistantMessage: msg,1362 sessionID,1363 model,1364 })1365 .pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant))Effect 异步工作流。13661367 const outcome: "break" | "continue" = yield* Effect.gen(function* () {Effect 异步工作流。1368 const lastUserMsg = msgs.findLast((m) => m.info.role === "user")1369 const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false1370 const promptOps = yield* ops()等待 Effect 结果。13711372 const tools = yield* SessionTools.resolve({等待 Effect 结果。1373 agent,1374 session,1375 model,1376 processor: handle,1377 bypassAgentCheck,1378 messages: msgs,1379 promptOps,1380 }).pipe(1381 Effect.provideService(Plugin.Service, plugin),调用插件扩展点。1382 Effect.provideService(Permission.Service, permission),Effect 异步工作流。1383 Effect.provideService(ToolRegistry.Service, registry),Effect 异步工作流。1384 Effect.provideService(MCP.Service, mcp),Effect 异步工作流。1385 Effect.provideService(Truncate.Service, truncate),Effect 异步工作流。1386 )13871388 if (lastUser.format?.type === "json_schema") {按条件进入分支。1389 tools["StructuredOutput"] = createStructuredOutputTool({1390 schema: lastUser.format.schema,定义并校验数据形状。1391 onSuccess(output) {1392 structured = output1393 },1394 })1395 }13961397 if (step === 1)按条件进入分支。1398 yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))Effect 异步工作流。13991400 if (step > 1 && lastFinished) {按条件进入分支。1401 for (const m of msgs) {遍历集合。1402 if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue按条件进入分支。1403 for (const p of m.parts) {遍历集合。1404 if (p.type !== "text" || p.ignored || p.synthetic) continue按条件进入分支。1405 if (!p.text.trim()) continue按条件进入分支。1406 p.text = [1407 "<system-reminder>",1408 "The user sent the following message:",1409 p.text,1410 "",1411 "Please address this message and continue with your tasks.",1412 "</system-reminder>",1413 ].join("\n")1414 }1415 }1416 }14171418 yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })调用插件扩展点。14191420 const [skills, env, instructions, modelMsgs] = yield* Effect.all([Effect 异步工作流。1421 sys.skills(agent),1422 sys.environment(model),1423 instruction.system().pipe(Effect.orDie),Effect 异步工作流。1424 MessageV2.toModelMessagesEffect(msgs, model),会话消息片段结构。1425 ])1426 const system = [...env, ...instructions, ...(skills ? [skills] : [])]1427 const format = lastUser.format ?? { type: "text" as const }1428 if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)按条件进入分支。1429 const result = yield* handle.process({等待 Effect 结果。1430 user: lastUser,1431 agent,1432 permission: session.permission,1433 sessionID,1434 parentSessionID: session.parentID,1435 system,1436 messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],1437 tools,1438 model,1439 toolChoice: format.type === "json_schema" ? "required" : undefined,1440 })14411442 if (structured !== undefined) {按条件进入分支。1443 handle.message.structured = structured1444 handle.message.finish = handle.message.finish ?? "stop"1445 yield* sessions.updateMessage(handle.message)等待 Effect 结果。1446 return "break" as const返回给上一层。1447 }14481449 const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)1450 if (finished && !handle.message.error) {按条件进入分支。1451 if (format.type === "json_schema") {按条件进入分支。1452 handle.message.error = new MessageV2.StructuredOutputError({会话消息片段结构。1453 message: "Model did not produce structured output",1454 retries: 0,1455 }).toObject()1456 yield* sessions.updateMessage(handle.message)等待 Effect 结果。1457 return "break" as const返回给上一层。1458 }1459 }14601461 if (result === "stop") return "break" as const按条件进入分支。1462 if (result === "compact") {按条件进入分支。1463 yield* compaction.create({等待 Effect 结果。1464 sessionID,1465 agent: lastUser.agent,1466 model: lastUser.model,选择模型或 provider。1467 auto: true,1468 overflow: !handle.message.finish,1469 })1470 }1471 return "continue" as const返回给上一层。
照抄的代价是:你会在还没跑通 read loop 前,就背负成熟产品的兼容与扩展复杂度。
| OpenCode 机制 | mini agent 决策 |
|---|---|
| Effect service/layer | 可先用显式构造函数依赖注入 |
| 持久化 MessageV2 | 先内存 ledger,类型保留扩展空间 |
| plugin + MCP tools | 先单一 registry |
| compaction | 先限制最大历史/token,明确报错 |
| SSE/UI event system | 先 callback/async iterator 日志 |
| 多级 permission rules | 先按 tool 的 allow/deny/ask |
“先简化”不等于删掉安全与停止条件;它只删产品规模,不删 agent 正确性。
13. 可以带走的方法
Section titled “13. 可以带走的方法”方法一:用账本驱动循环
Section titled “方法一:用账本驱动循环”每次决定都从 messages 恢复,而不是依赖散落的局部变量。这样重试、回放和 UI 投影才有基础。
验证问题:进程在 tool result 写入后重启,能否知道下一步该把什么发给模型?
方法二:把副作用封进统一 Tool contract
Section titled “方法二:把副作用封进统一 Tool contract”schema、permission、abort、result/error 状态必须包住每个工具,而不是由工具作者自由发挥。
验证问题:新增 shell 时,是否天然经过与 read 相同的 validation 与 event 路径?
方法三:停止条件与能力一起设计
Section titled “方法三:停止条件与能力一起设计”每增加一种“继续”能力,就增加对应上限:工具轮需要 max steps,自动重试需要 retry budget,压缩需要失败出口。
验证问题:模型持续产生合法但无进展的调用时,系统在哪里停止?
14. 费曼复述与最终练习
Section titled “14. 费曼复述与最终练习”请关闭本章,用两分钟讲清:
- chat wrapper 与 coding agent 的分界是什么?
- 为什么 tool result 必须进入 message ledger?
- permission 为什么必须在 execute path 内?
- mini agent 哪些 OpenCode 能力可以延后,哪些不能?
最终练习:
- 第一关:画出“读
package.json”的八步序列,并标出两次 LLM 调用。 - 第二关:写出
Tool、Message、runLoop三个最小接口。 - 第三关:补齐 invalid args、deny、abort、重复调用四个测试。
- 第四关:加入 shell,但证明未批准命令绝不会启动进程。
- 迁移关:选择 OpenCode 的一个产品能力,说明它应在哪个边界加入,而不是塞进 loop。
如果你的实现还不能回答“工具失败后账本里有什么”,请先不要做 UI:回到第 5–7 节补齐状态模型。
最后复盘:小,但必须是真的
Section titled “最后复盘:小,但必须是真的”最终闭环只有一句伪代码:
用户消息入账 -> 模型决定 -> 工具受控执行 -> 结果入账 -> 模型再决定 -> 明确停止你从 OpenCode 学到的不是一份可缩写的文件清单,而是三条架构纪律:消息是可恢复的事实,工具是受控副作用,循环必须证明为什么继续以及为什么停止。
教程到这里结束,但源码学习的下一步很具体:亲手实现 read 闭环,然后用真实 trace 对照 SessionPrompt、SessionTools 和 SessionProcessor。当你的设计与 OpenCode 不同时,先问“我省略的是产品规模,还是正确性边界?”