0. 本章学习目标

你会从 OpenCode 的真实 CLI、session、LLM、tool、permission 和 processor 源码里反推出一个 mini coding agent 的最小闭环。

1. 一句话讲明白

一个 mini coding agent 的最小闭环是:接收用户输入,保存成 session message,调用 LLM,发现 tool call,执行工具并写回 tool result,再把结果发回 LLM,直到模型停止。

来源:run.ts:768-803prompt.ts:1211-1489llm.ts:39-60tool.ts:16-45session/tools.ts:42-115processor.ts:451-500

2. 它在 OpenCode agent 中的位置

这章是总装图。OpenCode 有 CLI、TUI、Desktop、VS Code、SDK/API、多 provider、MCP、插件、LSP、权限和事件流。mini agent 第一版只保留闭环。

CLI -> Session -> LLM -> Tool Registry -> Tool Execution -> Tool Result -> LLM -> Final Answer

3. 生活类比

用户是产品经理,Session 是项目记录本,LLM 是主程,Tool 是具体工种,Permission 是安全负责人,Processor 是会议纪要员。

4. Java 开发者类比

Mini agentJava 类比源码依据
CLI 输入main / Picocli commandrun.ts:768-803
SessionPromptApplication Serviceprompt.ts:1211-1230
Agent loopState machine / workflow engineprompt.ts:1248-1489
LLM serviceGateway / client adapterllm.ts:39-60
Tool registryStrategy registrysession/tools.ts:75-115
PermissionSecurity interceptor + async approvalpermission/index.ts:161-195

5. 最小源码路径

  1. packages/opencode/src/cli/cmd/run.ts:768-803
  2. packages/opencode/src/session/prompt.ts:1211-1230
  3. packages/opencode/src/session/prompt.ts:1248-1489
  4. packages/opencode/src/session/llm.ts:39-60
  5. packages/opencode/src/tool/tool.ts:16-45
  6. packages/opencode/src/session/tools.ts:42-115
  7. packages/opencode/src/permission/index.ts:161-195
  8. packages/opencode/src/session/processor.ts:451-500

6. 用户输入到 agent 行动的整体链路

用户输入 prompt
  -> run.ts 调用 client.session.prompt
  -> SessionPrompt.prompt 创建 user message
  -> loop 读取 session message
  -> 选择 agent/model
  -> 创建 assistant message
  -> SessionTools.resolve 暴露工具
  -> handle.process 调用 LLM stream
  -> LLM 输出 text/tool-call/tool-result event
  -> processor 写回 text/tool part
  -> 工具执行时通过 ctx.ask 走权限
  -> tool result 写回 message part
  -> loop 再次把 tool result 发给 LLM
  -> 模型 finish 后退出

7. 核心源码逐段讲解

7.1 CLI 只负责送输入

路径:packages/opencode/src/cli/cmd/run.ts:768-803

const result = await client.session.prompt({
  sessionID,
  agent,
  model,
  variant: args.variant,
  parts: [...files, { type: "text", text: message }],
})

CLI 不实现 agent loop,只把输入整理成 parts 并调用 session service。

7.2 prompt 写入 user message

路径:packages/opencode/src/session/prompt.ts:1211-1230

const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
yield* revert.cleanup(session)
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)

if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID })

prompt 的职责是创建用户消息并启动 loop,不把所有推理逻辑塞在入口函数里。

7.3 loop 判断继续还是结束

路径:packages/opencode/src/session/prompt.ts:1248-1276

while (true) {
  yield* status.set(sessionID, { type: "busy" })
  let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
  const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)

  const hasToolCalls =
    lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false

  if (lastAssistant?.finish && !["tool-calls"].includes(lastAssistant.finish) && !hasToolCalls) {
    break
  }
}

agent 不是调用一次 LLM,而是围绕 message state 的状态机。

7.4 每一步准备 assistant、tools 和 LLM 输入

路径:packages/opencode/src/session/prompt.ts:1325-1440

const msg: MessageV2.Assistant = {
  id: MessageID.ascending(),
  parentID: lastUser.id,
  role: "assistant",
  agent: agent.name,
  modelID: model.id,
  providerID: model.providerID,
  sessionID,
}
yield* sessions.updateMessage(msg)

const handle = yield* processor.create({ assistantMessage: msg, sessionID, model })
const tools = yield* SessionTools.resolve({ agent, session, model, processor: handle, messages: msgs, promptOps })
const result = yield* handle.process({ user: lastUser, agent, sessionID, system, messages: modelMsgs, tools, model })

7.5 LLM 网关的输入

路径:packages/opencode/src/session/llm.ts:39-60

export type StreamInput = {
  user: MessageV2.User
  sessionID: string
  model: Provider.Model
  agent: Agent.Info
  system: string[]
  messages: ModelMessage[]
  tools: Record<string, Tool>
  toolChoice?: "auto" | "required" | "none"
}

export interface Interface {
  readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>
}

mini agent 可以把它简化成 LlmClient.complete({ messages, tools }),但要保留 tools 参数。

7.6 Tool 接口

路径:packages/opencode/src/tool/tool.ts:16-45

export type Context = {
  sessionID: SessionID
  messageID: MessageID
  agent: string
  abort: AbortSignal
  messages: MessageV2.WithParts[]
  metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
  ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}

export interface Def {
  id: string
  description: string
  parameters: Parameters
  execute(args, ctx: Context): Effect.Effect<ExecuteResult>
}

工具不只是函数,它带 schema、上下文、权限和结果 metadata。

7.7 工具适配和执行

路径:packages/opencode/src/session/tools.ts:42-115

const context = (args, options): Tool.Context => ({
  sessionID: input.session.id,
  messageID: input.processor.message.id,
  callID: options.toolCallId,
  ask: (req) =>
    permission.ask({
      ...req,
      sessionID: input.session.id,
      tool: { messageID: input.processor.message.id, callID: options.toolCallId },
      ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
    }),
})

tools[item.id] = tool({
  description: item.description,
  inputSchema: jsonSchema(schema),
  execute(args, options) {
    const ctx = context(args, options)
    const result = yield* item.execute(args, ctx)
    return output
  },
})

这是 Adapter:内部 Tool 被包装成模型 provider 能调用的 tool。

7.8 权限闸门

路径:packages/opencode/src/permission/index.ts:161-195

for (const pattern of request.patterns) {
  const rule = evaluate(request.permission, pattern, ruleset, approved)
  if (rule.action === "deny") return yield* new DeniedError(...)
  if (rule.action === "allow") continue
  needsAsk = true
}

if (!needsAsk) return

const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
return yield* Effect.ensuring(Deferred.await(deferred), Effect.sync(() => pending.delete(id)))

7.9 工具结果写回

路径:packages/opencode/src/session/processor.ts:451-500

case "tool-result": {
  const toolCall = yield* readToolCall(value.id)
  const rawOutput = toolResultOutput(value)
  const output = {
    ...rawOutput,
    attachments: attachments.length ? attachments : undefined,
  }
  yield* completeToolCall(value.id, output)
  return
}

工具结果必须回到 message history,下一轮 LLM 才知道工具执行了什么。

8. 关键 TypeScript 语法复习

  • Record<string, Tool>:类似 Java Map<String, Tool>。来源:llm.ts:49
  • literal union:"auto" | "required" | "none",类似轻量 enum。来源:llm.ts:51
  • optional property:small?: boolean,字段可能是 undefined。来源:llm.ts:48
  • 泛型默认值:M extends Metadata = Metadata。来源:tool.ts:28
  • Omit:从已有类型裁掉字段,类似创建一个 request draft DTO。来源:tool.ts:25
  • as const:保留字面量类型。来源:prompt.ts:1436

9. 涉及的设计模式和架构思想

这里最重要的是 Controller -> Application Service -> State Machine、Strategy Registry、Adapter、Event Bus、Safety Gate。

10. 它如何和 Tool、Provider、Session、文件系统协作

服务职责OpenCode 对应
SessionStore保存 messagesSession.ServiceMessageV2
AgentLoopwhile 循环,判断停止SessionPrompt.runLoop
LlmClient调用模型并返回事件LLM.Service
ToolRegistry管理 read/edit/shellToolRegistry.ServiceSessionTools.resolve
PermissionServiceallow/deny/askPermission.Service

11. 如果自己实现 mini agent,这一章对应什么代码

下面是教学草图,不是 OpenCode 源码。

type Message =
  | { role: "user"; content: string }
  | { role: "assistant"; content: string; toolCalls?: ToolCall[]; finish?: "stop" | "tool-calls" }
  | { role: "tool"; toolCallID: string; content: string }

type ToolDef<TInput = unknown> = {
  id: string
  description: string
  parameters: unknown
  execute(input: TInput, ctx: ToolContext): Promise<{ output: string }>
}

async function runLoop(session: Session, llm: LlmClient, tools: Record<string, ToolDef>) {
  while (true) {
    const result = await llm.complete({ messages: session.messages, tools })
    session.messages.push(result.assistant)
    if (!result.assistant.toolCalls?.length) break

    for (const call of result.assistant.toolCalls) {
      const tool = tools[call.name]
      const output = await tool.execute(call.input, makeToolContext(session, call))
      session.messages.push({ role: "tool", toolCallID: call.id, content: output.output })
    }
  }
}

实现顺序:先 fake LLM 跑通闭环,再接真实 provider;先做 read_file,再加 edit_filerun_shell

12. 费曼复述区

请不用“agent loop”这个词,向一个 Java 同事解释:为什么 coding agent 不能只调用一次 LLM?为什么 tool result 必须写回消息历史?

换一种说法:模型像只会发指令的主程。它说“读这个文件”,工具读完后必须把结果告诉主程,主程才能决定下一步。

13. 练习题

  • 用 5 句话解释 CLI -> Session -> LLM -> Tool -> LLM
  • 把 OpenCode 的 Tool.Context 简化成你的 ToolContext
  • 实现 ToolRegistry,输入 agent 名称,返回工具集合。
  • 写一个 fake LLM:第一次返回 read_file tool call,第二次根据 tool result 回答用户。

14. 源码追踪任务

  1. run.ts:792 追到 prompt.ts:1211
  2. prompt.ts:1372 追到 session/tools.ts:75
  3. session/tools.ts:93 追到 edit.ts:69
  4. edit.ts:98 追到 permission/index.ts:161
  5. processor.ts:499 回到 prompt.ts:1461-1477

15. 面试式自测

  1. 你如何解释 agent loop 和普通 chatbot 的区别?
  2. Tool result 为什么必须进入 message history?
  3. Tool registry 为什么比 if toolName === ... 更适合扩展?
  4. 权限系统为什么不应该写死在 edit_file 里?
  5. 如果 LLM provider 的 tool schema 格式不同,你会在哪一层做适配?

16. 下一步阅读建议

下一步建议做一个真实 mini agent:先 fake LLM 跑通 loop,再接真实 provider,只开放 read_file,然后逐步加入 edit_file、权限和 run_shell

配置系统本轮没有完整生成;如果要让站点完全覆盖最初大纲,下一章建议补 10-config-system