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-803、prompt.ts:1211-1489、llm.ts:39-60、tool.ts:16-45、session/tools.ts:42-115、processor.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 agent | Java 类比 | 源码依据 |
|---|---|---|
| CLI 输入 | main / Picocli command | run.ts:768-803 |
| SessionPrompt | Application Service | prompt.ts:1211-1230 |
| Agent loop | State machine / workflow engine | prompt.ts:1248-1489 |
| LLM service | Gateway / client adapter | llm.ts:39-60 |
| Tool registry | Strategy registry | session/tools.ts:75-115 |
| Permission | Security interceptor + async approval | permission/index.ts:161-195 |
5. 最小源码路径
packages/opencode/src/cli/cmd/run.ts:768-803packages/opencode/src/session/prompt.ts:1211-1230packages/opencode/src/session/prompt.ts:1248-1489packages/opencode/src/session/llm.ts:39-60packages/opencode/src/tool/tool.ts:16-45packages/opencode/src/session/tools.ts:42-115packages/opencode/src/permission/index.ts:161-195packages/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>:类似 JavaMap<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 | 保存 messages | Session.Service、MessageV2 |
| AgentLoop | while 循环,判断停止 | SessionPrompt.runLoop |
| LlmClient | 调用模型并返回事件 | LLM.Service |
| ToolRegistry | 管理 read/edit/shell | ToolRegistry.Service、SessionTools.resolve |
| PermissionService | allow/deny/ask | Permission.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_file 和 run_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_filetool call,第二次根据 tool result 回答用户。
14. 源码追踪任务
- 从
run.ts:792追到prompt.ts:1211。 - 从
prompt.ts:1372追到session/tools.ts:75。 - 从
session/tools.ts:93追到edit.ts:69。 - 从
edit.ts:98追到permission/index.ts:161。 - 从
processor.ts:499回到prompt.ts:1461-1477。
15. 面试式自测
- 你如何解释 agent loop 和普通 chatbot 的区别?
- Tool result 为什么必须进入 message history?
- Tool registry 为什么比
if toolName === ...更适合扩展? - 权限系统为什么不应该写死在
edit_file里? - 如果 LLM provider 的 tool schema 格式不同,你会在哪一层做适配?