第二部分 · RLM
第 4 章:ipython——唯一的内置工具
一个参数的 schema,一条六阶段的管道,三种私有 MIME
最奇怪的工具定义
打开 core/tools/ipython.ts,你会看到一个在 Agent 工程里几乎独一无二景象:整个系统的内置工具面,就是下面这一个定义——
return {
name: "ipython",
label: "ipython",
description:
"Execute Python scratchpad code and `%%bash` shell cells in a persistent IPython kernel. Variables, imports, and loaded data persist across calls, and are revived on a best-effort basis when a session is resumed (objects that cannot be serialized are dropped and reported). Project imports, tests, scripts, CLIs, and dependency checks should run through the target project's own environment.",
promptSnippet: "ipython - persistent agent notebook for Python scratchpad code and %%bash orchestration",
// The kernel is single-threaded — pi must not run two ipython calls in parallel within a batch.
executionMode: "sequential",
parameters: ipythonSchema,
execute: async (toolCallId, params, signal, onUpdate, ctx) => { ... },
};
参数 schema 只有一个字段:code: string。
pi 的内置工具面是七个自描述对象:read、write、edit、bash、grep、find、ls——每个有自己的参数 schema、并发模式、UI 渲染。Prime 把它们全部删了,只留 ipython。读文件是 Path.read_text(),列目录是 os.listdir(),跑 shell 是 %%bash,精确替换是 edit 技能(第 10 章)。工具面从「枚举能力」变成了「提供一个图灵完备的环境」。注意连注释里都还留着 pi 的字样——这是 fork 的考古层。
注意描述文本承担的职责:它不只是告诉模型「这个工具能干什么」,还在教模型使用哲学——变量会跨调用存活、会话恢复是 best-effort、项目自己的测试要走项目自己的环境。在只有一个工具的系统里,工具描述就是半本操作手册。
六阶段执行管道
模型输出一段 Python,到它变成 tool_result 回到模型,中间经过六道工序:
① %%bash 单元格改写
applyShellSettingsToBashMagicCell() 用正则解析 cell 头部的 %%bash 魔法。这不是为了实现 %%bash——它是 IPython 自带的 script magic,起一个临时 bash 子进程——而是为了两个宿主级配置的注入点:设置了 shellPath 时把 %%bash 改写成 %%script <安全引用的路径>,设置了 commandPrefix 时把前缀拼进单元格正文。解析器(tools/ipython-cell-code.ts)刻意保留原始缩进与换行结构,非 bash cell 原样通过。
官方文档对语义边界的表述很精确:每个 %%bash cell 是一个临时子 shell,而 Python 状态与 %cd 的变更在 kernel 内持久。这一句话划清了两个世界:shell 是无状态的逃生舱,Python 是有状态的工作台。
② 懒启动与引导
provisioner.ensure() 在第 3 章讲过了:拿 boot permit、解析 venv、fork 或 spawn、恢复快照、注入 bootstrap cell。工具侧只看到一个 ensure() Promise——并发调用共享同一个 memo,失败清 memo 允许下次重试。启动进度通过 reportStartupProgress 实时推进 TUI 的 working message:用户看到的「Starting IPython kernel...」就是这条线。
③ 忙 kernel:中断风暴与人机决策
模型的上一个 cell 可能还在跑——比如一个没收敛的 while 循环。新的 execute 到来时,KernelManager 先尝试「复用等待」:
const KERNEL_BUSY_REUSE_WAIT_MS = 5000; const KERNEL_BUSY_INTERRUPT_INTERVAL_MS = 500; // waitForActiveExecutionToClearForReuse: // 最多等 5 秒,期间每 500ms 发一次 interrupt_request
5 秒内反复发中断(interrupt 风暴),等待旧执行退出。若仍然忙,抛出 KernelBusyAfterInterruptError——工具层的 executeWithBusyKernelChoice() 捕获它,把决策升级给用户:
const action = await chooseBusyKernelAction(ctx, signal); // "Wait and preserve state" —— 继续等,保住命名空间 // "Kill kernel and restart" —— provisioner.kill(),kernelRestarted = true,重试 // 无 UI(headless)时:取消本次调用
选择杀掉的代价是命名空间失忆。工具层用一段显式通知把这个代价告诉模型:
const KERNEL_RESTART_NOTICE = [
"<ipython_kernel_reset>",
... // 「kernel 已重启,此前定义的变量、导入与函数均已失效…」
"</ipython_kernel_reset>",
].join("\n");
「中断一个死循环 cell」这种交互细节被做成了完整闭环:人有二选一的界面,模型有机器可读的标签,两者都不会对失忆感到意外。
④ 执行与输出泵
cell 进入 executionQueue 串行,经 shell 通道发出 execute_request。此后一切输出从 iopub 广播回流,runIopubPump() 单循环分发:
| iopub 消息 | 去向 |
|---|---|
stream | 按 name 累加进 stdout/stderr,并实时 onStream 推给 TUI |
execute_result | 取 data["text/plain"] 作为最后一个表达式的值(result) |
display_data / update_display_data | 解析三种私有 MIME(下一节) |
error | {ename, evalue, traceback[]},状态置为 error |
status(execution_state === "idle") | 本次执行的终止信号 → 汇总 ExecuteResult |
归属过滤是关键细节:普通输出只接受 parent_header.msg_id 匹配当前执行的消息。但有两个例外必须网开一面——comm 消息(host request,第 5 章)和 agent-message 回执——因为异步 Python 任务可能在调度它的 cell 已经 idle 之后才发出它们。例外不是疏忽,是对「cell 结束 ≠ 程序结束」这一事实的承认。
⑤ 三种私有 MIME:富输出走 display_data
stdout 只是输出的一种。技能需要把结构化数据送回宿主——diff、图片、消息回执——Prime 的做法是复用 Jupyter 的 display_data 通道,注册三个私有 MIME 类型:
export const DIFF_DISPLAY_MIME = "application/vnd.prime-agent.diff+json"; export const ATTACHMENT_DISPLAY_MIME = "application/vnd.prime-agent.attachment+json"; export const AGENT_MESSAGE_DISPLAY_MIME = "application/vnd.prime-agent.agent-message+json";
- diff:
edit技能每改一个文件就display()一条{path, old_str, new_str, start_line},宿主收集进ExecuteResult.diffs,TUI 渲染成彩色 diff。文件编辑的结果不走 stdout 文本,走结构化通道。 - attachment:
attach-image技能发{mime_type, data(base64), path};宿主把其中的图片类型过滤出来,转成ImageContent块直接进入模型上下文——模型用attach_image看截图,下一轮就能看见。 - agent-message 回执:第 7 章细讲。
attachment 有一条硬上限:超过 10,000,000 个 base64 字符,整个 cell 转成 error。注释写得很直白——fail loudly rather than silently drop。对模型来说,「你的附件太大」是一个可以行动的错误;静默丢失是一场无法诊断的灾难。
⑥ 输出整形
回到工具定义,execute 的收尾是把 ExecuteResult 拼回模型能消费的形态:
let text = r.stdout;
if (r.stderr) text += (text ? "\n" : "") + r.stderr;
if (r.result) text += (text ? "\n" : "") + r.result;
if (r.status === "error" && r.error) {
text += (text ? "\n" : "") + r.error.traceback.join("\n");
}
if (kernelRestarted) {
text = text ? `${KERNEL_RESTART_NOTICE}\n\n${text}` : KERNEL_RESTART_NOTICE;
}
const imageBlocks = imageBlocksFromAttachments(r.attachments);
const content: (TextContent | ImageContent)[] = [{ type: "text", text: text || "" }, ...imageBlocks];
stdout、stderr、表达式值、traceback 依次拼接;kernel 重启通知置顶;图片附件变成内容块。每路文本各有 65,536 字符上限(DEFAULT_MAX_OUTPUT_CHARS),超出截断并留下 [... output truncated at N chars ...] 标记——同样是「让截断可见」的哲学。
返回的 details(durationMs、status、diffs、attachments、sentAgentMessages、kernelRestarted……)不进模型上下文,留给 TUI 渲染与遥测。isError 在 status 为 error 或 aborted 时为真——循环的常规错误处理由此接管。
没有超时
值得专门指出的缺席:这条管道里没有 cell 超时。一个 cell 可以跑任意久——下载数据集、训练小模型、等待构建。只有两条退出路径:用户/循环发起 abort(control 通道 interrupt_request,1 秒宽限后强制 resolve status: "aborted"),或忙 kernel 决策里的人为 kill。
这是长任务语义的直接推论:给 cell 设 120 秒超时的 Agent 做不了「跑完整个测试套件」这类工作。Prime 把时长控制交给模型(它可以在 Python 里自己写超时)和人(可以中断),而不是在基础设施层一刀切。
实践应用
- 单一入口,哲学先行。当工具面收缩到一个图灵完备入口时,工具描述必须承担「如何使用这个世界」的教学。description 里的每一句都是提示词工程。
- 结构化输出走带类型的旁路。stdout 给人看,display_data + 私有 MIME 给程序看。diff 不变成文本再被 TUI 重新解析——数据从产生到渲染不经过字符串化的损耗。
- 失败必须可见。截断留标记、附件超限报错、kernel 失忆发通知。每一条都是同一个原则:宁可响亮的错误,不要安静的丢失。
- 把破坏性决策升级给人,把恢复语义告诉模型。忙 kernel 二选一是人的决策;
<ipython_kernel_reset>标签是模型的知情权。人机各拿各的那份信息。
总结
这一章看了唯一内置工具的全貌:一个字段 code 的 schema,六阶段管道(bash 改写 → 懒启动 → 忙 kernel 协商 → 执行泵 → 私有 MIME 分流 → 整形返回),以及两处刻意的缺席——没有超时,没有静默失败。ipython 不是一个「执行 Python 的工具」,它是模型与一个持久计算环境之间的唯一边界。
但边界上有一个洞:Python 里有些东西是 kernel 无权决定的——创建子会话、记录目标、给另一个 agent 发消息。这些操作的权威状态在 TypeScript 宿主那边。下一章看这个洞如何被一座类型化的桥补上。