pi-from-scratch 600 行手撕 Agent Loop
这个仓库把 coding agent 拆到最小可运行形态:src/agent.ts:80 暴露 async generator 循环,src/llm.ts:167 解析 OpenAI 兼容 SSE,src/tools.ts:123 注册四个内置工具,再由 src/cli.ts:47 接到终端输入和 JSONL session。
它解决什么,凭什么值得拆
pi-from-scratch 的目标不是做一个功能最全的 agent,而是把生产级 coding agent 的最小骨架摊开:src/agent.ts:89 用一个无限循环表达 agent 的生命线,src/agent.ts:98 把模型流事件接进循环,src/agent.ts:122 把 assistant message 写回 context,src/agent.ts:172 再把工具结果变成下一轮模型输入。这个闭环足够小,但已经包含 tool use、streaming、abort、compaction、session persistence 和测试。
它的教学价值在于每个模块都很薄。src/llm.ts:57 只负责消息格式转换,src/llm.ts:107 只解析一行 SSE,src/tools.ts:24 只做输出截断,src/cli.ts:97 只做 append-only JSONL 持久化。读者可以看到 agent 不是魔法,而是一组状态转换和协议适配。
▸核心数据
▸为什么不是 README 摘要
源码里真正值得看的点出现在边界处理:src/agent.ts:107 在 abort 时丢弃未完成 tool_calls,src/agent.ts:125 遇到 max_tokens 时把错误写回 context,src/tools.ts:88 对 edit 做 0 匹配错误,src/tools.ts:89 对多匹配错误硬失败。这些细节决定一个教学 agent 是否会把 session 搞坏。
Agent Loop:一个 while(true)承载所有回合
主循环从 src/agent.ts:80 的 async generator 开始,先在 src/agent.ts:86 建立 toolMap,再在 src/agent.ts:89 进入循环。每轮先执行 src/agent.ts:91 的 compaction,再通过 src/agent.ts:98 调用 stream。这里的取舍很清楚:agent 本体不直接知道 OpenAI SSE,只消费统一 StreamEvent。
80export async function* runAgent( 81 model: Model, 82 context: Context, 83 tools: AgentTool[], 84 signal?: AbortSignal, 85): AsyncGenerator<AgentEvent> { 86 const toolMap = new Map(tools.map(t => [t.name, t])) 87 const toolDefs = tools.map(t => ({ name: t.name, description: t.description, parameters: t.parameters })) 88 89 while (true) { 90 // 0. 对话压缩:消息过多时先压缩旧消息 91 await compactContext(model, context, signal) 92 93 // 1. 流式调用 LLM,收集文本和 tool_calls 94 let text = '' 95 let stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'aborted' = 'end_turn' 96 const toolCalls: { id: string; name: string; args: unknown }[] = [] 97 98 for await (const ev of stream(model, context, { tools: toolDefs, signal })) { 99 if (ev.type === 'text_delta') { 100 text += ev.delta 101 yield { type: 'assistant_text', delta: ev.delta } 102 } else if (ev.type === 'tool_call') { 103 toolCalls.push({ id: ev.id, name: ev.name, args: ev.args }) 104 yield { type: 'tool_call', id: ev.id, name: ev.name, args: ev.args } 105 } else if (ev.type === 'done') { 106 stopReason = ev.stopReason 107 if (ev.stopReason === 'aborted') { 108 // abort 时丢弃 tool_calls(无对应 tool_result 会导致 session 恢复后 API 报错) 109 context.messages.push(buildAssistantMessage(text, [])) 110 yield { type: 'turn_end', stopReason: 'aborted' } 111 return 112 } 113 } else if (ev.type === 'error') { 114 context.messages.push(buildAssistantMessage(text, [])) 115 yield { type: 'assistant_text', delta: `\n[error] ${ev.error.message}` } 116 yield { type: 'turn_end', stopReason: 'error' } 117 return 118 } 119 } 120 121 // 2. 把 assistant 回复塞回 context 122 context.messages.push(buildAssistantMessage(text, toolCalls)) 123 124 // 3. 截断处理:max_tokens 时 tool args 可能不完整,不执行,把错误放回到 Context 让模型重发 125 if (stopReason === 'max_tokens' && toolCalls.length > 0) { 126 const results = toolCalls.map(tc => ({ 127 tool_use_id: tc.id, 128 content: `error: output truncated by max_tokens, tool "${tc.name}" args may be incomplete.`, 129 })) 130 context.messages.push(buildToolResultMessage(results)) 131 for (let i = 0; i < toolCalls.length; i++) { 132 yield { type: 'tool_result', id: toolCalls[i].id, name: toolCalls[i].name, result: results[i].content } 133 } 134 continue
这段代码的关键不是“能调用工具”,而是工具调用如何进入 context。src/agent.ts:102 收到 tool_call 时只缓存和广播,src/agent.ts:122 才将完整 assistant 消息落入 context;如果 src/agent.ts:125 发现 max_tokens 截断,就不执行工具,而是在 src/agent.ts:130 写回错误,逼模型下一轮重发参数。
▸工具结果补齐
执行工具的段落从 src/agent.ts:145 开始,串行执行是教学版有意简化。真正值得注意的是 src/agent.ts:151 未知工具返回错误而不是抛出,src/agent.ts:155 把 signal 传给工具,src/agent.ts:166 为 abort 后没跑到的 tool_call 补上 error。否则 OpenAI 兼容 API 恢复 session 时会因为 tool_call / tool_result 不配对报错。
145 // 5. 串行执行 tool_calls(pi 有 sequential/parallel 两种模式,教学版统一串行) 146 // args 未经 parameters 验证直接传入 execute(教学版简化,生产应先 validate) 147 const results: { tool_use_id: string; content: string }[] = [] 148 for (const tc of toolCalls) { 149 const tool = toolMap.get(tc.name) 150 let result: string 151 if (!tool) { 152 result = `error: tool "${tc.name}" not found` 153 } else { 154 try { 155 result = await tool.execute(tc.args, signal) 156 } catch (e) { 157 result = `error: ${(e as Error).message}` 158 } 159 } 160 results.push({ tool_use_id: tc.id, content: result }) 161 yield { type: 'tool_result', id: tc.id, name: tc.name, result } 162 if (signal?.aborted) break 163 } 164 165 // 6. 为被 abort 跳过的 tool_call 补上错误结果(API 要求每个 tool_call 都有对应 tool_result) 166 for (const tc of toolCalls.slice(results.length)) { 167 results.push({ tool_use_id: tc.id, content: 'error: aborted' }) 168 yield { type: 'tool_result', id: tc.id, name: tc.name, result: 'error: aborted' } 169 } 170 171 // 7. 把 tool_result 放回到 Context,进入下一轮 172 context.messages.push(buildToolResultMessage(results)) 173 }
LLM Adapter:把 OpenAI 兼容流转成四种事件
▸Context 到 OpenAI messages
src/llm.ts:57 是协议适配的入口,系统提示在 src/llm.ts:59 被提升为 system message;assistant 的 tool_use 在 src/llm.ts:74 被转成 function tool_calls;user 里的 tool_result 在 src/llm.ts:85 被拆成 role:tool 消息。这让 agent 内部保持纯 JSON,但对外满足 OpenAI 兼容格式。
57export function contextToOpenAIMessages(context: Context): object[] { 58 const messages: object[] = [] 59 if (context.systemPrompt) messages.push({ role: 'system', content: context.systemPrompt }) 60 61 for (const msg of context.messages) { 62 if (typeof msg.content === 'string') { 63 messages.push({ role: msg.role, content: msg.content }) 64 continue 65 } 66 67 const blocks = msg.content 68 if (msg.role === 'assistant') { 69 const toolCalls: object[] = [] 70 let text = '' 71 for (const b of blocks) { 72 if (b.type === 'text') text += b.text 73 else if (b.type === 'tool_use') { 74 toolCalls.push({ id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input) } }) 75 } 76 } 77 // OpenAI 要求 assistant 消息必须有 content(非 null)或 tool_calls。 78 // 纯 tool_call 时 content 为 null;两者皆空(如 abort/error 后的无内容轮次)用空串占位,避免 API 400。 79 const content = text || (toolCalls.length ? null : '') 80 messages.push({ role: 'assistant', content, tool_calls: toolCalls.length ? toolCalls : undefined }) 81 } else { 82 // user message 里的 tool_result block → OpenAI 要求独立的 role:tool 消息 83 for (const b of blocks) { 84 if (b.type === 'tool_result') { 85 messages.push({ role: 'tool', tool_call_id: b.tool_use_id, content: b.content }) 86 } else if (b.type === 'text') { 87 messages.push({ role: 'user', content: b.text }) 88 } 89 } 90 } 91 } 92 return messages
▸SSE 增量和 tool_call buffer
流式解析的复杂度集中在 src/llm.ts:107 和 src/llm.ts:144。模型可能把 function arguments 分多次吐出,所以 src/llm.ts:127 先按 index 建 buffer,src/llm.ts:132 追加 JSON 字符串,最后 src/llm.ts:151 尝试 JSON.parse。这个设计解释了为什么 tool_call 事件要等流结束后统一 flush。
107function handleSSELine( 108 data: string, 109 toolCallBuffers: Map<number, { id: string; name: string; argsBuf: string }>, 110): { textDelta: string | null; stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | null } { 111 let chunk: OpenAIChunk 112 try { chunk = JSON.parse(data) as OpenAIChunk } catch { return { textDelta: null, stopReason: null } } 113 114 const choice = chunk.choices[0] 115 if (!choice) return { textDelta: null, stopReason: null } 116 117 let textDelta: string | null = null 118 let stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | null = null 119 120 if (choice.delta?.content) textDelta = choice.delta.content 121 122 // tool_call 增量:按 index 累积 name + arguments 的 partial JSON 123 if (choice.delta?.tool_calls) { 124 for (const tc of choice.delta.tool_calls) { 125 const idx = tc.index ?? 0 126 if (!toolCallBuffers.has(idx)) { 127 toolCallBuffers.set(idx, { id: tc.id ?? `call_${idx}`, name: '', argsBuf: '' }) 128 } 129 const entry = toolCallBuffers.get(idx)! 130 if (tc.id) entry.id = tc.id 131 if (tc.function?.name) entry.name = tc.function.name 132 if (tc.function?.arguments) entry.argsBuf += tc.function.arguments 133 } 134 } 135 136 // finish_reason 映射:tool_calls → tool_use,length → max_tokens,stop → end_turn(默认值) 137 if (choice.finish_reason === 'tool_calls') stopReason = 'tool_use' 138 else if (choice.finish_reason === 'length') stopReason = 'max_tokens' 139 140 return { textDelta, stopReason } 141} 142 143/** 流结束:把累积的 tool_calls 按顺序发出 */ 144function flushToolCalls( 145 toolCallBuffers: Map<number, { id: string; name: string; argsBuf: string }>, 146): { id: string; name: string; args: unknown }[] { 147 const calls: { id: string; name: string; args: unknown }[] = [] 148 for (const [, tc] of [...toolCallBuffers].sort((a, b) => a[0] - b[0])) { 149 let args: unknown = {} 150 if (tc.argsBuf) { 151 try { args = JSON.parse(tc.argsBuf) } catch { args = {} } 152 } 153 calls.push({ id: tc.id, name: tc.name, args }) 154 } 155 return calls
网络请求本身在 src/llm.ts:167 之后组装:src/llm.ts:175 放 model、stream、messages,src/llm.ts:178 注入 tool definitions,src/llm.ts:187 发 fetch,src/llm.ts:234 再把累计 tool_calls 逐个 yield。错误路径也很薄,src/llm.ts:198 把非 2xx 变成 error event。
167export async function* stream( 168 model: Model, 169 context: Context, 170 opts: { tools?: ToolDef[]; signal?: AbortSignal } = {}, 171): AsyncGenerator<StreamEvent> { 172 const url = `${model.baseUrl ?? 'https://api.openai.com/v1'}/chat/completions` 173 const messages = contextToOpenAIMessages(context) 174 175 const body: Record<string, unknown> = { model: model.model, stream: true, messages } 176 if (model.maxTokens) body.max_tokens = model.maxTokens 177 if (opts.tools?.length) { 178 body.tools = opts.tools.map(t => ({ 179 type: 'function', 180 function: { name: t.name, description: t.description, parameters: t.parameters }, 181 })) 182 } 183 184 // 发请求 185 let response: Response 186 try { 187 response = await fetch(url, { 188 method: 'POST', 189 headers: { 'content-type': 'application/json', authorization: `Bearer ${model.apiKey}` }, 190 body: JSON.stringify(body), 191 signal: opts.signal, 192 }) 193 } catch (e) { 194 if (opts.signal?.aborted) { yield { type: 'done', stopReason: 'aborted' }; return } 195 yield { type: 'error', error: e as Error }; return 196 } 197 198 if (!response.ok || !response.body) { 199 const text = await response.text().catch(() => 'unknown error') 200 yield { type: 'error', error: new Error(`API ${response.status}: ${text}`) }; return 201 } 202 203 // 逐行解析 SSE 204 const reader = response.body.getReader() 205 const decoder = new TextDecoder() 206 let buf = '' 207 let stopReason: 'end_turn' | 'tool_use' | 'max_tokens' = 'end_turn' 208 const toolCallBuffers = new Map<number, { id: string; name: string; argsBuf: string }>() 209 210 try { 211 while (true) { 212 const { done, value } = await reader.read() 213 if (done) break 214 buf += decoder.decode(value, { stream: true }) 215 216 let nl: number 217 while ((nl = buf.indexOf('\n')) >= 0) { 218 const line = buf.slice(0, nl).trim() 219 buf = buf.slice(nl + 1) 220 if (!line.startsWith('data: ')) continue 221 const data = line.slice(6) 222 if (data === '[DONE]') continue 223 224 const result = handleSSELine(data, toolCallBuffers) 225 if (result.textDelta) yield { type: 'text_delta', delta: result.textDelta } 226 if (result.stopReason) stopReason = result.stopReason 227 } 228 } 229 } catch (e) { 230 if (opts.signal?.aborted) { yield { type: 'done', stopReason: 'aborted' }; return } 231 yield { type: 'error', error: e as Error }; return 232 } 233 234 // 发出累积的 tool_calls 235 for (const tc of flushToolCalls(toolCallBuffers)) { 236 yield { type: 'tool_call', id: tc.id, name: tc.name, args: tc.args } 237 } 238 yield { type: 'done', stopReason: opts.signal?.aborted ? 'aborted' : stopReason }
工具层:能读写改跑,但保留教学边界
▸四个工具的最小闭环
工具层用 src/tools.ts:34 定义 read_file、src/tools.ts:52 定义 write_file、src/tools.ts:72 定义 edit、src/tools.ts:98 定义 run_bash,最后由 src/tools.ts:123 统一导出。每个工具都是纯 execute 函数,不碰 agent 状态,这是把副作用限制在工具边界内的关键。
20/** 21 * 截取工具输出:超过 maxLines 行时只保留尾部,完整输出存到临时文件。 22 * 尾部优先——错误信息通常在末尾。 23 */ 24async function truncateOutput(content: string, maxLines = MAX_OUTPUT_LINES): Promise<string> { 25 const lines = content.split('\n') 26 if (lines.length <= maxLines) return content 27 const kept = lines.slice(-maxLines).join('\n') 28 const tmpPath = path.join(os.tmpdir(), `nanopi-output-${process.pid}-${truncateCounter++}.txt`) 29 await fs.writeFile(tmpPath, content, 'utf-8') 30 return `[output truncated: showing last ${maxLines} of ${lines.length} lines. full output: ${tmpPath}]\n${kept}` 31} 32 33/** read_file:返回文件内容(截取尾部防止超大输出) */ 34const readFile: AgentTool = { 35 name: 'read_file', 36 description: '读取文件内容。参数:path(文件路径)。大文件截取最后 200 行。', 37 parameters: { 38 type: 'object', 39 properties: { 40 path: { type: 'string', description: '要读取的文件路径' }, 41 }, 42 required: ['path'], 43 }, 44 execute: async (args) => { 45 const { path: filePath } = args as { path: string } 46 const content = await fs.readFile(filePath, 'utf-8') 47 return await truncateOutput(content) 48 }, 49}
edit 的实现体现了“少而硬”的质量门:src/tools.ts:87 统计 old_string 出现次数,src/tools.ts:88 对 0 次匹配失败,src/tools.ts:89 对多处匹配失败,src/tools.ts:91 用函数替换避免 new_string 里的美元符号被 String.replace 解释。对 coding agent 来说,这比模糊 patch 更可控。
71/** edit:局部字符串替换(精确匹配 + 唯一性校验) */ 72const edit: AgentTool = { 73 name: 'edit', 74 description: '编辑文件:精确替换一段文本。参数:path、old_string、new_string。old_string 必须在文件中唯一匹配,否则报错。', 75 parameters: { 76 type: 'object', 77 properties: { 78 path: { type: 'string', description: '文件路径' }, 79 old_string: { type: 'string', description: '要被替换的文本(必须唯一匹配)' }, 80 new_string: { type: 'string', description: '替换后的文本' }, 81 }, 82 required: ['path', 'old_string', 'new_string'], 83 }, 84 execute: async (args) => { 85 const { path: filePath, old_string, new_string } = args as { path: string; old_string: string; new_string: string } 86 const content = await fs.readFile(filePath, 'utf-8') 87 const count = content.split(old_string).length - 1 88 if (count === 0) throw new Error(`old_string not found in ${filePath}`) 89 if (count > 1) throw new Error(`old_string matches ${count} places in ${filePath}, must be unique`) 90 // 用函数替换避免 new_string 中的 $ 特殊字符($& $` $' $1)被 String.replace 解释 91 const newContent = content.replace(old_string, () => new_string) 92 await fs.writeFile(filePath, newContent, 'utf-8') 93 return `edited ${filePath}: replaced ${old_string.length} chars` 94 }, 95} 96 97/** run_bash:执行 shell 命令(截取尾部输出防止超大输出) */ 98const runBash: AgentTool = { 99 name: 'run_bash', 100 description: '执行 shell 命令。参数:command(命令字符串)。返回 stdout+stderr,截取最后 200 行。', 101 parameters: { 102 type: 'object', 103 properties: { 104 command: { type: 'string', description: '要执行的 shell 命令' }, 105 }, 106 required: ['command'], 107 }, 108 execute: async (args, signal) => { 109 const { command } = args as { command: string } 110 try { 111 const { stdout, stderr } = await execAsync(command, { maxBuffer: 1024 * 1024, timeout: 30000, signal }) 112 const output = stderr ? `[stderr] ${stderr}\n[stdout] ${stdout}` : stdout 113 return await truncateOutput(output) 114 } catch (e: unknown) { 115 if (signal?.aborted) return 'aborted' 116 const err = e as NodeJS.ErrnoException & { code?: number; stdout?: string; stderr?: string } 117 return `[exit ${err.code}] ${err.stderr ?? ''}${err.stdout ?? ''}` 118 } 119 }, 120} 121 122/** 返回全部内置工具 */ 123export function builtinTools(): AgentTool[] { 124 return [readFile, writeFile, edit, runBash]
▸CLI 与 session
src/cli.ts:47 接收用户输入后,先在 src/cli.ts:49 把 user message 追加到 context,再在 src/cli.ts:55 消费 runAgent 事件。回合结束后 src/cli.ts:68 执行持久化,而 src/cli.ts:97 只 append 新消息,避免重复写入历史。
47 tui.onPrompt(async (text) => { 48 try { 49 context.messages.push({ role: 'user', content: text }) 50 51 tui.setBusy(true) 52 const ctrl = new AbortController() 53 tui.onAbort(() => ctrl.abort()) // 每轮新建 AbortController,需重新注册回调指向新的 controller 54 55 for await (const ev of runAgent(model, context, tools, ctrl.signal)) { 56 switch (ev.type) { 57 case 'assistant_text': tui.printText(ev.delta); break 58 case 'tool_call': tui.printToolCall(ev.name, ev.args); break 59 case 'tool_result': tui.printToolResult(ev.name, ev.result); break 60 case 'turn_end': 61 if (ev.stopReason === 'max_tokens') tui.printText('\n[output truncated by max_tokens]') 62 if (ev.stopReason === 'error') tui.printText('\n[error occurred]') 63 tui.printTurnEnd() 64 break 65 } 66 } 67 68 await persistSession(context.messages) 69 } catch (e) { 70 console.error(`\n[error] ${(e as Error).message}`) 71 } finally { 72 tui.setBusy(false) 73 } 74 }) 75 76 tui.start() 77 78} 79 80/** 启动时加载历史 messages,恢复上次对话(导出供测试) */ 81export async function loadSession(file: string = SESSION_FILE): Promise<Message[]> { 82 try { 83 const data = await fs.readFile(file, 'utf-8') 84 const lines = data.trim().split('\n').filter(Boolean) 85 // 逐行容错:跳过损坏行而非丢弃全部历史(进程崩溃可能写出半行 JSON) 86 const messages = lines.flatMap(line => { 87 try { return [JSON.parse(line) as Message] } catch { return [] } 88 }) 89 persistedCount = messages.length // 已加载的不重复写 90 return messages 91 } catch { 92 return [] // 文件不存在,从空开始 93 } 94} 95 96/** 持久化 messages 到 session 文件(导出供测试) */ 97export async function persistSession(messages: Message[], file: string = SESSION_FILE): Promise<void> { 98 await fs.mkdir(path.dirname(file) || '.', { recursive: true }) 99 const newMessages = messages.slice(persistedCount) 100 for (const msg of newMessages) { 101 await fs.appendFile(file, JSON.stringify(msg) + '\n', 'utf-8') 102 } 103 persistedCount = messages.length 104}
Trace Lab:把运行过程变成可单步调试教材
▸真实 trace 不是手写截图
trace 生成器读取真实源码:scripts/generate-traces.ts:44 收集五个核心文件,scripts/generate-traces.ts:55 通过 needle 反查行号,scripts/generate-traces.ts:90 根据最新 context 判断源码位置,scripts/generate-traces.ts:166 构造教学步骤。这个 pipeline 让网页里的每一步都能回到真实源码。
44const sourceFiles = Object.fromEntries( 45 await Promise.all( 46 (['src/llm.ts', 'src/agent.ts', 'src/tools.ts', 'src/tui.ts', 'src/cli.ts'] as SourceFile[]) 47 .map(async (file) => [file, await readFile(resolve(projectRoot, file), 'utf8')]), 48 ), 49) as Record<SourceFile, string> 50 51function cloneContext(context: Context): TraceContext { 52 return JSON.parse(JSON.stringify(context)) as TraceContext 53} 54 55function findLine(file: SourceFile, needle: string, occurrence = 0): number { 56 const matches = sourceFiles[file] 57 .split('\n') 58 .map((line, index) => ({ line, number: index + 1 })) 59 .filter((entry) => entry.line.includes(needle)) 60 const match = occurrence < 0 ? matches.at(occurrence) : matches[occurrence] 61 if (!match) throw new Error(`找不到 trace 源码位置:${file} / ${needle}`) 62 return match.number 63} 64 65function source(file: SourceFile, needle: string, occurrence = 0): TraceSource { 66 return { file, line: findLine(file, needle, occurrence) } 67} 68 69function normalizeText(value: string, workspace: string): string { 70 return value.split(workspace).join('/workspace') 71} 72 73function normalizeValue<T>(value: T, workspace: string): T { 74 return JSON.parse(normalizeText(JSON.stringify(value), workspace)) as T 75} 76 77function messageKind(message: unknown): 'tool_result' | 'assistant' | 'other' { 78 if (!message || typeof message !== 'object') return 'other' 79 const item = message as { role?: string; content?: unknown } 80 if (item.role === 'assistant') return 'assistant' 81 if (item.role === 'user' && Array.isArray(item.content)) { 82 const hasResult = item.content.some((block) => ( 83 block && typeof block === 'object' && (block as { type?: string }).type === 'tool_result' 84 )) 85 if (hasResult) return 'tool_result' 86 } 87 return 'other' 88} 89 90function contextStepSource(context: TraceContext): TraceSource { 91 const latest = context.messages.at(-1) 92 if (messageKind(latest) === 'tool_result') { 93 return source('src/agent.ts', 'context.messages.push(buildToolResultMessage(results))', -1) 94 } 95 return source('src/agent.ts', 'context.messages.push(buildAssistantMessage(text, toolCalls))', -1) 96}
▸前端调试器复现调用栈
Web 侧的 web/app/trace-debugger.ts:100 会把 trace case 展开成 debug frames,web/app/trace-debugger.ts:119 的 push 同时记录变量、事件、context 和 callStack。展示层在 web/app/TraceLab.tsx:48 用 useMemo 构建 frames,web/app/TraceLab.tsx:72 支持继续到断点,web/app/TraceLab.tsx:186 把当前源码行高亮。
100export function buildDebugFrames(traceCase: TraceCase): TraceDebugFrame[] { 101 const frames: TraceDebugFrame[] = []; 102 const first = traceCase.steps[0]; 103 if (!first) return frames; 104 105 let context: TraceContext = clone(first.context); 106 const state: RuntimeState = { 107 text: "", 108 stopReason: "end_turn", 109 toolCalls: [], 110 results: [], 111 ev: null, 112 signalAborted: false, 113 loop: 1, 114 }; 115 let sequence = 0; 116 let toolPhaseStarted = false; 117 let abortPrepared = false; 118 119 const push = ( 120 file: SourceFile, 121 needle: string, 122 options: { 123 occurrence?: number; 124 label?: string; 125 event?: Record<string, unknown>; 126 variables?: Record<string, unknown>; 127 } = {}, 128 ) => { 129 const position = source(file, needle, options.occurrence ?? 0); 130 const variables = { 131 text: state.text, 132 stopReason: state.stopReason, 133 toolCalls: clone(state.toolCalls), 134 results: clone(state.results), 135 ev: clone(state.ev), 136 "signal.aborted": state.signalAborted, 137 "context.messages.length": context.messages.length, 138 loop: state.loop, 139 ...options.variables, 140 }; 141 frames.push({ 142 id: `${traceCase.id}-${sequence++}`, 143 label: options.label ?? sourceLabel(position), 144 source: position, 145 event: options.event ? clone(options.event) : undefined, 146 context: clone(context), 147 variables, 148 callStack: callStack(position), 149 });
这意味着项目不只是 CLI demo,而是把 agent loop 的隐性状态显性化:web/app/TraceLab.tsx:220 展示 Core State,web/app/TraceLab.tsx:232 展示调用栈,web/app/TraceLab.tsx:242 展示事件 JSON,web/app/TraceLab.tsx:249 展示 Context。它让学习者看到每个 tool_result 为什么会进入下一轮。
40export default function TraceLab() { 41 const [caseIndex, setCaseIndex] = useState(0); 42 const [frameIndex, setFrameIndex] = useState(0); 43 const [breakpoints, setBreakpoints] = useState<ReadonlySet<string>>(() => new Set()); 44 const [selectedFile, setSelectedFile] = useState<TraceSource["file"]>("src/tui.ts"); 45 const codeScrollRef = useRef<HTMLDivElement>(null); 46 47 const traceCase = traceCases[caseIndex] ?? null; 48 const debugFrames = useMemo(() => traceCase ? buildDebugFrames(traceCase) : [], [traceCase]); 49 const frame = debugFrames[frameIndex] ?? null; 50 const sourceCode = fullRepo[selectedFile] ?? ""; 51 const sourceLines = useMemo(() => sourceCode.split("\n"), [sourceCode]); 52 const executableLines = useMemo(() => new Set( 53 debugFrames 54 .filter((item) => item.source.file === selectedFile) 55 .map((item) => item.source.line), 56 ), [debugFrames, selectedFile]); 57 58 const selectCase = (nextIndex: number) => { 59 setCaseIndex(nextIndex); 60 setFrameIndex(0); 61 const first = traceCases[nextIndex] ? buildDebugFrames(traceCases[nextIndex])[0] : null; 62 if (first) setSelectedFile(first.source.file); 63 }; 64 65 const goToFrame = (next: number) => { 66 if (!debugFrames.length) return; 67 const bounded = Math.max(0, Math.min(debugFrames.length - 1, next)); 68 setFrameIndex(bounded); 69 setSelectedFile(debugFrames[bounded].source.file); 70 }; 71 72 const continueToBreakpoint = () => { 73 if (!debugFrames.length || frameIndex >= debugFrames.length - 1) return; 74 const nextHit = debugFrames.findIndex((item, index) => ( 75 index > frameIndex && breakpoints.has(breakpointKey(item.source)) 76 )); 77 goToFrame(nextHit >= 0 ? nextHit : debugFrames.length - 1); 78 };
技术亮点之外,别忽略这些
▸参数验证被有意省略
仓库自己在 src/agent.ts:146 和 src/tools.ts:4 标明教学版不验证 args。生产化时必须在 src/agent.ts:155 之前按 parameters 做 schema validation,否则模型可以把任意结构传给 src/tools.ts:109 的 run_bash。
▸run_bash 权限边界很粗
src/tools.ts:111 直接 exec shell command,只设了 maxBuffer 和 timeout。对于教学足够;对于真实 agent,需要工作目录沙箱、命令 allowlist、环境变量过滤和文件系统权限限制。
▸Context compaction 是条数近似
src/agent.ts:28 和 src/agent.ts:30 用消息数量触发压缩,而不是 token 估算;src/agent.ts:57 还会调用同一个 stream。这个实现讲清概念,但生产版要处理 token cut point、工具回合不可拆分和摘要质量验证。