piscratch
§ 00 · Source-level Observatory

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。

★ 791
Stars
5.2K
LOC
TypeScript
Language
Aug 13
Active
user prompt ↓ Tui.onPrompt → cli context append ↓ runAgent while(true) ↓ stream() parses SSE + tool_calls ↓ execute tools → tool_result message ↓ next LLM turn or end_turn
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

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 不是魔法,而是一组状态转换和协议适配。

▸核心数据

架构模式
事件流驱动的单循环 agent;Context 是唯一状态。
核心抽象
Model、Context、ContentBlock、AgentTool、AgentEvent。
验证面
Vitest 覆盖 agent loop、工具、SSE、session。
下游体验
CLI + Web TraceLab,兼顾运行和教学。

▸为什么不是 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 搞坏。

§ 01 · AGENT LOOP

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。

src/agent.ts · L80-134TypeScript
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 不配对报错。

src/agent.ts · L145-173TypeScript
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  }
深度阅读 · Agent Loop 与 Context 写回
§ 02 · PROTOCOL

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 兼容格式。

src/llm.ts · L57-92TypeScript
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。

src/llm.ts · L107-155TypeScript
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。

src/llm.ts · L167-238TypeScript
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 }
深度阅读 · LLM 协议与工具契约
§ 03 · TOOLS

工具层:能读写改跑,但保留教学边界

▸四个工具的最小闭环

工具层用 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 状态,这是把副作用限制在工具边界内的关键。

src/tools.ts · L20-49TypeScript
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 更可控。

src/tools.ts · L71-124TypeScript
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 新消息,避免重复写入历史。

src/cli.ts · L47-104TypeScript
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 与教学调试器
§ 04 · TRACE LAB

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 让网页里的每一步都能回到真实源码。

scripts/generate-traces.ts · L44-96TypeScript
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 把当前源码行高亮。

web/app/trace-debugger.ts · L100-149TypeScript
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 为什么会进入下一轮。

web/app/TraceLab.tsx · L40-78TypeScript
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  };
§ 05 · RISKS

技术亮点之外,别忽略这些

▸参数验证被有意省略

仓库自己在 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、工具回合不可拆分和摘要质量验证。

结论 — pi-from-scratch 适合作为 agent 内核教材,不应直接把 run_bash 和无验证 args 暴露到未隔离生产环境。
Generated · 2026-08-13Source · SaladDay/pi-from-scratch← 返回