跳转到内容

从 OpenCode 反推 mini coding agent

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度中等
预计阅读60 分钟
  • 章节 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
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/llm.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/src/session/tools.ts
  • packages/opencode/src/permission/index.ts
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/shell.ts
  • packages/opencode/src/session/processor.ts
  • packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts

源码基线:eec0843ce422。本章的 mini agent 是教学性设计;OpenCode 源码负责证明机制,但示例代码不是 OpenCode 的可直接运行摘录。

读完本章,你应该能:

  • 从成熟 OpenCode 中剥离出 coding agent 的最小闭环。
  • 画出消息账本、LLM、tool registry、permission 与 processor 的边界。
  • 沿着“读取 package.json 再回答”走完一轮 tool call。
  • 写出有停止条件、参数校验、审批和错误回填的 loop 伪代码。
  • 排出一个可交付 mini agent 的实现与测试顺序。

一个最小 coding agent 是一台有账本的循环机:模型从消息决定下一步,工具把外部世界的结果写回账本,循环根据明确条件继续或停止,权限闸门则阻止不该自动发生的动作。

中心问题是:从 OpenCode 删除 UI、MCP、插件、LSP、多 provider 和分布式接口之后,哪些机制再删一个,agent 就不再是真的?

必须保留的内核
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 必须能让模型观察工具结果后继续决策。

模块最小职责OpenCode 证据
CLI解析输入,调用 session servicepackages/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。它仍然保留四个不能省的约束:

  1. tool call 与 result 用 call.id 对应;
  2. 参数先校验再执行;
  3. 工具结果进入下一轮 messages;
  4. 循环有确定的停止上限。

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            )

教学版不必复制 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 机制整理的典型路径,不代表本章真的向模型发过这条请求。

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 也必须认真处理”

没有本地待处理 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          }

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 结果。

拒绝不是空字符串结果。它应该成为明确 error/denied 状态,让模型知道动作没有发生,也让用户知道文件或 shell 没被执行。

schema validation 必须早于副作用。把可修复错误写回模型,模型可能重试;连续相同错误则应计入循环保护。

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 次就停止,并报告重复轨迹。

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 + 明确审批的 shelledit/write/patch/LSP降低不可逆风险
schema validationMCP/plugin tool先稳定 registry
max steps + cancel + duplicate guardcompaction/subagent先防失控
text event loggerTUI/Desktop先获得可观测性

注意:如果目标明确要求“coding agent 能修改代码”,那么 edit 不能永远延后;但可以在只读闭环验证后再加入,并把 diff/审批/原子写入作为独立里程碑。

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. 实现顺序:每一步都形成可验证能力”
  • 定义 Message 与 LLM port。
  • user message 落账后调用 fake deterministic LLM。
  • 断言 assistant message 也落账。
  • 定义 Tool 与 registry。
  • LLM 返回 read call。
  • 参数校验、执行、result 回填、第二轮回答。
  • max steps。
  • unknown tool。
  • invalid args。
  • repeated identical call。
  • AbortSignal。
  • 默认拒绝危险 shell。
  • allow/deny/ask 三条路径。
  • 审批只允许本次与持久批准分开。
  • stdout/stderr/exit code 明确建模。
  • 用 adapter 接真实流式 API。
  • 将 provider events 归一化。
  • 事件 logger 展示 step、call、result、finish,不泄漏 secret。

这种顺序让每一步都可演示、可测试。先做漂亮 TUI 会掩盖循环与安全边界尚未成立的问题。

场景关键断言
模型直接回答只调用一次 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 正确性。

每次决定都从 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,压缩需要失败出口。

验证问题:模型持续产生合法但无进展的调用时,系统在哪里停止?

请关闭本章,用两分钟讲清:

  1. chat wrapper 与 coding agent 的分界是什么?
  2. 为什么 tool result 必须进入 message ledger?
  3. permission 为什么必须在 execute path 内?
  4. mini agent 哪些 OpenCode 能力可以延后,哪些不能?

最终练习:

  • 第一关:画出“读 package.json”的八步序列,并标出两次 LLM 调用。
  • 第二关:写出 ToolMessagerunLoop 三个最小接口。
  • 第三关:补齐 invalid args、deny、abort、重复调用四个测试。
  • 第四关:加入 shell,但证明未批准命令绝不会启动进程。
  • 迁移关:选择 OpenCode 的一个产品能力,说明它应在哪个边界加入,而不是塞进 loop。

如果你的实现还不能回答“工具失败后账本里有什么”,请先不要做 UI:回到第 5–7 节补齐状态模型。

最终闭环只有一句伪代码:

用户消息入账 -> 模型决定 -> 工具受控执行 -> 结果入账 -> 模型再决定 -> 明确停止

你从 OpenCode 学到的不是一份可缩写的文件清单,而是三条架构纪律:消息是可恢复的事实,工具是受控副作用,循环必须证明为什么继续以及为什么停止。

教程到这里结束,但源码学习的下一步很具体:亲手实现 read 闭环,然后用真实 trace 对照 SessionPromptSessionToolsSessionProcessor。当你的设计与 OpenCode 不同时,先问“我省略的是产品规模,还是正确性边界?”