一、引言:Python REPL 解决什么
pi 的工具调用是天然无状态的——read("file.ts") 读文件、bash("git status") 执行命令,两次调用之间没有共享状态。agent 要做"读文件 → 解析 → 循环处理每行 → 写回"这种组合操作,必须拆成多轮:第一轮 read 拿到内容,LLM 在 context 里看到内容后,第二轮 edit 写回。中间结果靠 LLM 在 context 里"人肉传递"——既浪费 token,又容易出错。
prime-agent 引入持久化 Python REPL——LLM 生成的 Python 代码在一个持久 REPL 里执行。上一轮 content = open("file.ts").read() 定义的变量,下一轮 print(content[:100]) 直接可用。这让 agent 从"每次工具调用独立"变成"在持久编程环境里工作"。
二、Python REPL 执行模型
REPL 是 prime-agent 最精妙的设计。pi 的 agent 是"LLM 调工具"——每个工具调用是独立的、无状态的,agent 的能力被工具的粒度框住。prime-agent 把工具调用升级为"LLM 写代码在持久化 Python 环境里执行"——一次调用能完成任意复杂的逻辑,状态跨轮次保持,agent 的能力从"工具的组合"变成"图灵完备的编程"。
这不是简单的"加了几个工具"——是把 agent 的执行模型从"声明式工具调用"换成"命令式编程"。这个改动的影响贯穿整个 prime-agent 架构:持久化状态需要 snapshot/restore、崩溃恢复需要 owner 存活检测、子 agent 需要独立的 REPL 实例、harness 状态需要通过 host_request 反向调 TypeScript 持久化。本系列从 REPL 开始拆解——理解了 REPL,prime-agent 的其余机制(RLM 子 agent、refinement、daemon、agent 家族)都有了基础。
什么是 Python REPL
REPL 是 Read-Eval-Print Loop 的缩写——交互式编程环境。Python 自带的 REPL 就是终端里输入 python 后看到的 >>> 提示符:输入一行代码,立即执行,看到结果,再输入下一行。
>>> x = 42
>>> print(x * 2)
84
>>> y = "hello"
>>> print(y.upper())
HELLO
关键特征是持久命名空间——第一行 x = 42 定义的变量 x,在第二行 print(x * 2) 里直接可用。REPL 不是每次输入都重新开始——所有变量、导入的模块、定义的函数都在内存里保持,直到退出。
prime-agent 把这个概念引入 agent——LLM 生成的 Python 代码在一个持久 REPL 里执行。上一轮 content = open("file.ts").read() 定义的变量,下一轮 print(content[:100]) 直接可用。这让 agent 从"每次工具调用独立"变成"在持久编程环境里工作"。
REPL 带来的优势:有状态的工具调用
pi 的工具调用是天然无状态的——read("file.ts") 读文件、bash("git status") 执行命令,两次调用之间没有共享状态。agent 要做"读文件 → 解析 → 循环处理每行 → 写回"这种组合操作,必须拆成多轮:第一轮 read 拿到内容,LLM 在 context 里看到内容后,第二轮 edit 写回。中间结果靠 LLM 在 context 里"人肉传递"——既浪费 token,又容易出错。
REPL 让工具调用天然变成有状态的——因为所有操作都在同一个 Python 命名空间里执行:
# 一段代码完成"读 → 解析 → 处理 → 写回"
content = open("src/index.ts").read()
lines = content.split("\n")
fixed = [l.replace("var ", "let ") for l in lines]
open("src/index.ts", "w").write("\n".join(fixed))
不需要拆成多轮、不需要 LLM 在 context 里传递中间结果、不浪费 token。上一轮的变量下一轮还能用——content 这个变量在后续轮次里直接可用,不用重新读文件。
这个优势是结构性的——不是"REPL 比工具快一点",而是"agent 的执行模型从无状态变成有状态"。后续的 RLM 子 agent(子 agent 有自己的 REPL)、harness 状态(通过 host_request 持久化)、daemon 恢复(snapshot/restore REPL 状态)都建立在这个基础之上。
REPL 带来的劣势
有状态是优势,也是负担。pi 的工具调用无状态——read 失败了只影响这一次调用,下一轮重新 read 就行。REPL 有状态——一旦状态出了问题,影响范围更大:
状态管理复杂:REPL 的变量、导入的模块、打开的文件句柄跨轮次保持。这意味着需要序列化(snapshot)、恢复(restore)、大小限制(16MB/256MB)、原子写入(防止崩溃损坏)。pi 的工具调用不需要这些——每次调用是原子的,没有持久状态要管理。
崩溃恢复困难:pi 的 agent loop 崩溃了,重启 session 从 JSONL 恢复消息历史就行——工具没有持久状态。prime-agent 的 REPL 崩溃了,不仅要恢复消息历史,还要恢复 Python 命名空间——通过 dill 序列化的 snapshot。snapshot 可能不完整(超限的变量被跳过)、可能过时(snapshot 和最新状态不一致)、恢复后可能行为异常(某些对象不能被 dill 正确序列化)。
安全面更大:pi 的工具是预定义的函数——read 只能读文件、bash 只能执行命令,参数有 schema 校验。REPL 执行任意 Python 代码——os.system("rm -rf /") 和 open("file.ts").read() 走同一个通道,没有 schema 校验、没有参数白名单。能力范围更大,风险也更大。
调试更难:pi 的工具调用有明确的输入输出——read({ path: "x.ts" }) → ToolResultMessage。REPL 执行一段代码,输出可能分散在 stdout / stderr / display 事件 / host_request 侧道里。如果代码有 bug,错误信息可能藏在 Python traceback 里,不像工具调用那样有结构化的 error result。
资源泄漏风险:pi 的工具调用结束就释放资源——read 读完文件就关 fd。REPL 里 f = open("file.ts") 打开的文件句柄会一直保持,直到显式 f.close() 或 REPL 退出。agent 如果忘记关闭资源,长时间运行后可能耗尽 fd 或内存。
这些劣势不是"REPL 不好"——而是"有状态的代价"。prime-agent 用 snapshot/restore、owner 存活检测、orphan 进程回收等机制来缓解这些问题,但无法完全消除。这是架构选择的 trade-off——用更高的复杂度换取更强的执行能力。
1. 持久命名空间
repl.py(1188 行)是 prime-agent 的 Python REPL 运行时——一个通过 JSON-lines 协议和 TypeScript host 通信的持久化 Python 进程。LLM 生成的代码在这里执行,状态跨轮次保持。
REPL 的核心是一个持久的 __main__ 命名空间(ns: dict[str, Any])——所有代码在同一个命名空间里执行:
# repl.py:953-980(简化)
async def _serve(queue, ns):
while True:
req = await queue.get()
rtype = req.get("type")
if rtype == "execute":
await _handle_request(_handle_execute, req, ns) # 在 ns 里执行
elif rtype == "snapshot":
await _handle_request(_handle_state, req, ns) # 序列化 ns
elif rtype == "restore":
await _handle_request(_handle_state, req, ns) # 恢复 ns
elif rtype == "list_names":
await _handle_request(_handle_list_names, req, ns) # 列出 ns 里的变量
elif rtype == "shutdown":
return
ns 是一个普通的 Python dict——键是变量名,值是变量值。每次 execute 请求的代码都在这个 ns 里 eval/exec。上一轮 content = open("file.ts").read() 定义的 content,下一轮 print(content[:100]) 直接可用——因为两次执行用的是同一个 ns。
_list_names(repl.py:920-925)过滤出用户定义的变量——跳过 _ 开头的、跳过 _ALWAYS_SKIP(rlm / mcp / bash / asyncio 等系统模块):
_ALWAYS_SKIP = {"rlm", "mcp", "bash", "asyncio", "In", "Out", "get_ipython", "exit", "quit", "open"}
def _list_names(ns):
return sorted(
name for name in ns
if isinstance(name, str) and not name.startswith("_") and name not in _ALWAYS_SKIP
)
2. Cell 执行
每次 execute 请求是一个"cell"——一段 Python 代码。执行流程:
TypeScript host 发 {"type": "execute", "id": "abc123", "code": "print('hello')"}
→ Python REPL 收到
→ _handle_execute(req, ns)
→ compile(code, "<cell>", "exec") # 编译
→ exec(code, ns) # 在持久 ns 里执行
→ 捕获 stdout/stderr/display 事件
→ _send({"event": "done", "id": "abc123", "status": "ok"})
cell 执行支持 top-level await——代码里可以直接写 await asyncio.sleep(1),不需要包在 async def 里。这是通过 compile(code, "<cell>", "exec") + exec 在 asyncio 事件循环里执行的。
每个 cell 有一个唯一 ID(_current_cell)——display 事件用这个 ID 标注"这段输出是哪个 cell 产生的"。这让 TypeScript host 能把输出和请求对应起来。
3. asyncio 事件循环
REPL 跑在一个 asyncio 事件循环上(repl.py:46):
_loop: asyncio.AbstractEventLoop | None = None
_serve_task: asyncio.Task[Any] | None = None
_serve 是事件循环的主任务——从队列里取请求、执行、发回结果。请求不是直接处理的——stdin 的读取在独立线程里做(_read_requests),读到请求后通过 _loop.call_soon_threadsafe(queue.put_nowait, req) 投递到事件循环的队列:
# repl.py:1042-1058(简化)
def _read_requests(stdin_fd, queue):
with os.fdopen(stdin_fd, "rb") as stream:
for raw in stream: # 阻塞读 stdin
raw = raw.strip()
if not raw: continue
_handle_request_line(raw, queue) # 解析 + 投递到队列
# stdin 关闭 → shutdown
_loop.call_soon_threadsafe(queue.put_nowait, {"type": "shutdown"})
为什么用独立线程读 stdin?因为 asyncio 的事件循环不能同时阻塞读 stdin 和执行 cell 代码。读 stdin 在线程里做,代码执行在事件循环里做——两者通过 asyncio.Queue 通信。
4. 中断
用户按 Ctrl-C 或 TypeScript host 发 {"type": "interrupt"} 时,REPL 需要中断正在执行的 cell(repl.py:1002-1007):
if rtype == "interrupt":
_request_interrupt(req.get("id"))
return
中断是协作式的——不是直接 kill -9,而是发 SIGINT 信号让 asyncio 任务自己取消。_request_interrupt 设置 _pending_interrupts 标记,_serve 循环在下一次检查时取消当前 cell 的 task。
中断有一个 inflight 机制——用 _inflight set 跟踪当前正在执行的 request ID。中断时可以指定 ID(只中断某个 cell)或不指定(中断任何正在执行的 cell):
# repl.py:62-63
_active: dict[str, Any] = {"task": None, "rid": None, "interrupted": False}
_inflight: set[str] = set()
5. Host Request——Python 反向调 TypeScript
REPL 不只是被动接收 TypeScript 的请求——Python 代码可以反向调 TypeScript host(repl.py:119-132):
async def host_request(data: dict[str, Any]) -> dict[str, Any]:
"""Send one typed request to the host and await its raw reply dict."""
rid = uuid.uuid4().hex
future: asyncio.Future = _loop.create_future()
_pending_host[rid] = future
_send({"event": "host_request", "id": rid, "data": data})
return await future # 等 TypeScript 回复
host_request 让 Python 代码能调 TypeScript 的能力——如 rlm.harness.create_memory() 内部走 host_request 让 TypeScript 持久化到 session 文件。这是一个双向通信通道:
TypeScript → Python: {"type": "execute", "code": "..."}
Python → TypeScript: {"event": "host_request", "data": {"type": "create_memory", ...}}
TypeScript → Python: {"type": "host_reply", "id": "...", "data": {"status": "ok"}}
Python → TypeScript: {"event": "done", "status": "ok"}
host_reply 不走 _serve 队列——它直接 resolve 对应的 future(repl.py:1008-1017),否则会死锁(cell 在等 host_reply,队列被 cell 占着):
if rtype == "host_reply":
# Bypass the FIFO queue: the awaiting cell IS the in-flight execute,
# so a queued reply would deadlock behind it.
_resolve_host_reply(rid, data)
return
6. 状态快照——崩溃恢复
REPL 的状态(ns dict)需要能序列化到磁盘——session 恢复时从快照恢复变量。snapshot 和 restore 请求处理这个(repl.py:680-800):
# snapshot:把 ns 序列化到文件
# 用 dill(不是 pickle)——dill 能序列化更多 Python 对象(如 closures、lambda)
dill.dump(payload, writer) # 写到临时文件
os.replace(tmp, path) # 原子替换——防止崩溃时文件损坏
# restore:从文件恢复到 ns
dill.load(open(path, "rb")) # 读回来
ns.update(restored) # 合并到当前 ns
关键设计:
- 用 dill 而不是 pickle——dill 能序列化 pickle 不能处理的对象(lambda、closure、嵌套函数)
- 原子替换——先写临时文件,写完后
os.replace原子替换。崩溃时要么旧文件完整、要么新文件完整,不会出现半写的损坏文件 - 大小限制——单变量最大 16MB,总快照最大 256MB。超限的变量被跳过并记录在 manifest 里
- SIGINT park——快照写入期间屏蔽 SIGINT,防止中断导致文件损坏
7. Owner 存活检测
REPL 进程需要检测 TypeScript host 是否还活着——如果 host 崩溃了,REPL 应该自动退出(repl.py:1061-1099):
# POSIX:检查 parent process 是否还在
def _owner_alive_posix(owner, initial_ppid):
if initial_ppid == owner and os.getppid() != initial_ppid:
return False # parent 改了 → host 死了(进程被 reparent)
try:
os.kill(owner, 0) # 发 signal 0 检测存活
except ProcessLookupError:
return False
return True
# Windows:用 OpenProcess + WaitForSingleObject
def _wait_owner_windows(owner):
handle = k32.OpenProcess(SYNCHRONIZE, False, owner)
k32.WaitForSingleObject(handle, INFINITE) # 阻塞等 host 退出
host 进程的 PID 通过环境变量 PRIME_AGENT_KERNEL_OWNER_PID 传给 REPL。如果 host 死了,REPL 检测到后自动 shutdown——清理 bash 子进程、关闭 MCP 连接、退出。
三、Python runtime
prime-agent-runtime/ 是一个独立的 Python 包(pyproject.toml + src/rlm/),通过 uv 安装到 REPL 环境里。它提供 REPL 里 LLM 能调用的 Python 模块——rlm.harness(harness 状态)、rlm.bash(shell 执行)、rlm.skill(skill CLI)、rlm.mcp(MCP 协议)、rlm 本身(子 agent spawn)。
1. __init__.py——RLM 入口
rlm/__init__.py(366 行)定义了 LLM 在 REPL 里直接用的顶层 API。它不是 REPL 本身——repl.py 是协议层,__init__.py 是"LLM 能调什么"的 API 层。
核心导出:
from .bash import BashHandle, BashResult, bash
from .harness import HarnessEntry, HarnessScope, HarnessState, get_harness_state
rlm.run——spawn 子 agent(后续文章详讲)
rlm.create_session——创建独立 daemon session
RLMSpawnHandle / RLMCreateSessionHandle / RLMModel / RLMSubagent——这些 dataclass 是 host_request 返回值的类型包装。LLM 调 rlm.run("analyze deps") 后,TypeScript host 执行 spawn,返回 payload,Python 侧用 __init__.py 的 _spawn_handle_from_payload 解析成 RLMSpawnHandle。
2. harness.py——Harness 状态管理
harness.py(838 行)是 prime-agent 的"自改进"基础设施——管理 prompt notes / memories / skills / subagent specs 四类可持久化状态。LLM 通过 rlm.harness API 在 Python REPL 里直接读写这些状态。
存储模型
四类状态用 HarnessKind 区分:
HarnessKind = Literal["prompt", "memory", "skill", "subagent"]
两类 scope:
- local:session 级——存在 session 目录的
harness/harness_state.json里,只对当前 session 有效 - global:跨 session——存在
~/.prime/agent/harness/harness_state.json里,所有 session 共享
存储格式是单个 JSON 文件:
{
"schema": 1,
"entries": {
"prompt": { "build_policy": { "id": "build_policy", "kind": "prompt", "title": "构建策略", "content": "用 npm run build,不要用 yarn", ... } },
"memory": { "api_rate_limit": { "id": "api_rate_limit", "kind": "memory", "title": "API Rate Limit", "content": "每分钟 60 次", ... } },
"skill": {},
"subagent": {}
},
"refinements": [
{ "id": "refine_0001", "trigger": "构建失败", "changes": ["创建 memory: build_command"], "evidence": "npm run build 成功,yarn 失败" }
]
}
entries 按 kind 分组,每组是 { id: HarnessEntry } 的 dict。refinements 记录每次 /refine 的事件——改动列表 + 证据 + 结果。
HarnessEntry
每条 harness 状态是一个 HarnessEntry:
@dataclass
class HarnessEntry:
id: str # 唯一标识(自动从 title 生成 slug)
kind: HarnessKind # "prompt" / "memory" / "skill" / "subagent"
title: str # 人类可读标题
content: str # 主要内容
path: str = "general" # 分类路径(如 "build" / "deploy")
scope: HarnessScope # "local" / "global"
reference: dict = ... # skill 的 Python import 信息
arguments: dict = ... # skill 的参数 schema
metadata: dict = ... # 任意元数据
source: str = "agent" # 谁创建的——"agent"(LLM 创建)或 "user"(用户创建)
created_at: str # 创建时间
updated_at: str # 最后更新时间
version: int = 1 # 版本号——每次 update +1
version 字段支持回滚——/refine 可以用旧版本号恢复之前的 entry。
reference 和 arguments 只对 skill 类型有意义——skill 是可执行的 Python 包,reference 记录 import 路径(如 { "type": "python", "import": "my_skill" }),arguments 记录参数 schema。
CRUD 操作
HarnessState 类提供完整的 CRUD(harness.py:144-787):
class HarnessState:
def create_memory(self, title, content, *, global_=False, ...): ...
def update_memory(self, id, title, content, *, global_=False, ...): ...
def delete_memory(self, id, *, global_=False, ...): ...
def create_prompt_note(self, title, content, *, global_=False, ...): ...
def create_skill(self, title, content, *, reference, arguments, global_=False, ...): ...
def create_subagent(self, title, content, *, global_=False, ...): ...
# ... 每种 kind 都有 create / update / delete
def list(self, kind=None, *, global_=False): ...
def get(self, kind, id, *, global_=False): ...
def overview(self, *, global_=False): ...
每种 kind 有专用的 create_xxx / update_xxx / delete_xxx 方法,但内部都委托给统一的 create / update / delete:
def create(self, kind, title, content, *, id=None, path="general", global_=False, ...):
# 1. 解析 global_ 参数 + scope prefix
id, global_ = _strip_scope_prefix(id, global_)
# 2. 如果 global_=True,委托给全局 HarnessState
if target := self._global_target(global_, kwargs):
return target.create(kind, title, content, ...)
# 3. 同步磁盘——防止其他进程(如 /refine)改了文件
self._sync_from_disk()
# 4. 检查是否已存在(create 不允许覆盖)
entry_id = id or _slug(title, kind)
if entry_id in self.entries[kind]:
raise ValueError(f"{kind} entry {entry_id!r} already exists")
# 5. 写入 + 持久化
return self._upsert(kind, title, content, id=entry_id, ...)
原子写入
save() 用原子替换防止崩溃损坏(harness.py:287-319):
def save(self):
# 1. 序列化到临时文件
temp_path = target_path.with_name(f"{target_path.name}.{os.getpid()}.{uuid4().hex}.tmp")
with open(temp_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# 2. 保留原文件权限
os.chmod(temp_path, existing_mode)
# 3. 原子替换
os.replace(temp_path, target_path)
# 4. 清理临时文件(如果 replace 失败)
temp_path.unlink(missing_ok=True)
和 REPL 的 snapshot 一样的策略——先写临时文件,os.replace 原子替换。崩溃时要么旧文件完整、要么新文件完整。
跨进程同步
_sync_from_disk(harness.py:189-199)检测文件是否被其他进程修改:
def _sync_from_disk(self):
"""Reload if another process rewrote the state file since we last touched it."""
if self._disk_mtime() != self._loaded_mtime:
self.load()
用文件的 mtime(修改时间)判断——如果磁盘上的 mtime 和内存里记录的不一致,说明另一个进程(如 TypeScript 侧的 /refine 命令)改了文件,重新加载。
这解决了"Python REPL 和 TypeScript host 同时操作 harness 文件"的并发问题——Python 侧每次 CRUD 前先检查 mtime,确保不会用内存里的旧状态覆盖磁盘上的新状态。
RefinementEvent
每次 /refine 记录一个 RefinementEvent(harness.py:116-124):
@dataclass
class RefinementEvent:
id: str # 如 "refine_0001"
trigger: str # 触发原因——如"构建失败后发现 npm run build 才是对的"
changes: list[str] # 改了什么——如["创建 memory: build_command"]
evidence: str = "" # 证据——如"npm run build 成功,yarn 失败"
outcome: str = "" # 结果——如"后续构建都成功了"
created_at: str # 时间
这让 harness 状态的变更可追溯——overview() 方法列出最近 5 次 refinement 事件,用户可以看到 agent 学了什么、为什么学、学了之后效果如何。
overview——harness 状态总览
overview()(harness.py:740-787)生成人类可读的 harness 状态摘要:
def overview(self, *, global_=False):
lines = [
f"Harness state ({self.scope}): {self.file_path}",
"Call contract: installed Python skills use await <skill_import>(...) ...",
]
for kind in _KINDS:
records = self.list(kind)[:max_entries_per_kind]
lines.append(f"{kind}: {len(self.entries[kind])}")
for entry in records:
summary = entry.content.strip().replace("\n", " ")
if len(summary) > 120:
summary = f"{summary[:117]}..."
lines.append(f" - [{entry.scope}:{entry.id}] {entry.title} ({entry.path}, v{entry.version}): {summary}")
# ... 最近 5 次 refinement 事件
return "\n".join(lines)
LLM 在 REPL 里调 print(rlm.harness.overview()) 看到当前所有 harness 状态——知道哪些 memory / skill / prompt note 已经积累了、版本号是多少、最近 refine 了什么。
3. bash.py——持久化 bash handle
bash.py(1104 行)是 prime-agent 的 shell 执行模块。它和 pi 的 bash 工具完全不同——不是"执行命令返回结果",而是"返回一个 live handle,命令在后台异步执行"。
BashHandle——异步命令 handle
核心抽象是 BashHandle——一个对正在运行的 shell 命令的引用(bash.py:216-224):
class BashHandle:
"""Live handle to a shell command; await it for the BashResult.
A handle awaited before any other API use (the `await bash(cmd)` one-shot
form, including `h = bash(cmd)` awaited immediately) owns the command:
cancelling that await kills the process group. Touching .pid/.running/
.output()/.tail()/.poll()/.kill() first marks the handle as a background
handle; later awaits only wait and cancelling them leaves it running.
"""
两种使用模式:
# 模式 1:同步等待(和 pi 的 bash 工具类似)
result = await bash("git status")
print(result.stdout)
# 模式 2:异步后台(pi 做不到)
handle = bash("npm test") # 立即返回,命令在后台跑
# ... 做其他事 ...
result = await handle # 等命令完成
print(result.exit_code)
# 模式 3:多个命令并行(pi 做不到)
h1 = bash("npm test")
h2 = bash("npm run lint")
r1 = await h1
r2 = await h2
BashResult 是命令完成后的结果:
@dataclass(frozen=True)
class BashResult:
exit_code: int
output: str # stdout + stderr 合并
duration: float # 执行时长(秒)
输出捕获——BoundedBuffer
_BoundedBuffer(bash.py:168-213)是输出捕获的核心——保留开头 512KB + 结尾 1.5MB,中间丢弃:
class _BoundedBuffer:
_HEAD_CAP = 512 * 1024 # 512KB
_TAIL_CAP = 3 * 512 * 1024 # 1.5MB
def write(self, chunk):
if len(self._head) < _HEAD_CAP:
# 先填 head
take = _HEAD_CAP - len(self._head)
self._head.extend(chunk[:take])
chunk = chunk[take:]
if not chunk: return
# head 满了 → 写 tail(滚动队列)
self._tail.append(chunk)
while self._tail_size > _TAIL_CAP:
# tail 超限 → 丢最旧的
...
def text(self):
if not self._dropped:
return (head + tail).decode("utf-8")
# 有丢弃 → 插入标记
return head + f"\n... [{dropped} bytes dropped] ...\n" + tail
为什么保留头 + 尾?因为命令输出的开头通常有命令回显、编译器版本等信息,结尾有错误摘要、exit code。中间的大量 log 不重要。这和 pi 的 bash 工具的 OutputAccumulator 思路类似,但 prime-agent 的实现更精细——用滚动队列而不是简单截断。
完成检测——completion marker
bash.py 不用 process.wait() 检测命令完成——而是用一个特殊的 completion marker 注入到输出流里(bash.py:275-285):
completion_token = secrets.token_hex(32)
self._completion_marker = (
_COMPLETION_PREFIX + completion_token.encode("ascii") + _COMPLETION_SUFFIX
)
# marker 格式:\x1eprime-agent-complete:<token>\x1f
shell 脚本在命令执行完后输出这个 marker + exit code。_consume_output(bash.py:441-463)在输出流里扫描 marker:
def _consume_output(self, chunk):
marker = self._completion_marker
data = self._completion_pending + chunk
marker_at = data.find(marker)
if marker_at >= 0:
# 找到 marker → 命令完成
self._buffer.write(data[:marker_at])
self._completion_output = self._buffer.text()
self._completion_terminal.set() # 通知等待者
self._buffer.write(data[marker_at + len(marker):]) # marker 后面的输出
为什么不用 process.wait()?因为 shell 的 cmd &(后台命令)会立即返回——shell 进程退出但子进程还在跑。completion marker 让 bash.py 知道"前台命令完成了",即使 shell 还在等后台子进程。
token 用 secrets.token_hex(32) 生成——防止命令输出里碰巧出现 marker 字符串导致误判。marker 分两半注入(前半 + 后半),防止 echo 泄漏。
进程组管理
bash.py 用 start_new_session=True 创建独立的进程组(bash.py:296-304):
self._proc = subprocess.Popen(
[_shell(), "-c", script],
cwd=os.getcwd(),
env=_child_env(),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True, # 独立进程组
stdin=status_write, # status channel
)
独立进程组让 kill() 能杀整个命令树——git status 可能 spawn 子进程(如 pager),进程组 kill 确保子进程也被清理。
Windows 没有进程组——用 Job Object(_winjob.py)实现进程树包含。kill() 时 TerminateJobObject 终止整个 job。
Orphan 回收
如果 cell 结束但 bash 命令还在跑,命令变成 orphan。bash.py 有三层防护:
_live_handlesset(bash.py:56)——所有活跃的 BashHandle 都在集合里。_kill_live_handles()在 atexit 时清理- orphan-process-journal(TypeScript 侧)——
_record_journal(self._pid, active=True)在创建时记录 PID。TypeScript host 的 reaper 定期检查 journal,清理孤儿进程 - owner 存活检测——如果 host 崩溃,REPL 的 owner 检测触发 shutdown,
_kill_live_handles()清理所有 bash 子进程
PID 复用防护——journal 用 processStartId(来自 session-lease.ts)而不只是 PID,防止 PID 被复用后误杀新进程。
Cell 关联
BashHandle 知道自己是哪个 cell 创建的(bash.py:228-231):
completion_context = _current_cell_completion_context()
self._creating_cell_finished = completion_context[0] if completion_context else None
self._creating_cell_task = completion_context[1] if completion_context else None
如果 cell 被中断(用户按 Ctrl-C),相关的 bash 命令也会被取消——但只有"foreground" handle(被 cell 直接 await 的)会被取消。"background" handle(cell 没等它)继续跑,等下一个 cell 或 atexit 清理。
这个区分通过 _released 标记实现——如果调了 .pid / .running / .output() 等属性,handle 变成 background,后续的 await 不会 kill 它。
4. skill.py——Python skill CLI
skill.py(37 行)让 Python skill 能作为 CLI 命令执行:
def cli():
prog = Path(sys.argv[0]).stem # 从命令名推断模块名
module = __import__(prog) # 动态导入 skill 模块
run = getattr(module, "run", None) # 找 run() 函数
asyncio.run(run_cli(run, prog=prog)) # 用 tyro 解析参数 + 执行
prime-agent 的 skill 是可执行的 Python 包——不是 pi 的静态 Markdown。skill 暴露一个 run() 函数,tyro 自动从函数签名生成 CLI 参数解析。这让 skill 既是 Python 模块(在 REPL 里 import 调用)又是 CLI 命令(在终端执行)。
5. mcp.py + mcp_base.py——MCP 协议
mcp.py(658 行)+ mcp_base.py(337 行)让 Python REPL 能连接 MCP server。mcp.py 提供 connect() / close() / call_tool() 等 API,通过 host_request 让 TypeScript 侧的 MCP manager 管理连接生命周期。Python 侧只是调用接口——真正的 MCP 通信在 TypeScript 侧的 mcp/mcp-manager.ts 里。
四、kernel——TypeScript 侧
kernel/ 是 TypeScript 侧管理 Python REPL 进程的模块。Python runtime(repl.py)是被管理的子进程,kernel/ 是管理者——负责启动 Python 环境、spawn REPL 进程、通过 JSON-lines 协议通信、管理执行队列、处理中断、做状态快照。
1. bootstrap.ts——Python 环境安装
bootstrap.ts(962 行)负责"确保 Python 环境就绪"——安装 Python 3.11 + uv + prime-agent-runtime + 预装包。
核心函数 ensureKernelPython(bootstrap.ts:109+):
async function ensureKernelPython(options): Promise<string> {
// 1. 检查 uv 是否已安装——如果没有,自动安装
// curl -LsSf https://astral.sh/uv/install.sh | sh
// 2. 创建虚拟环境(venv)
// 用 uv venv 创建 Python 3.11 的虚拟环境
// 3. 安装 prime-agent-runtime
// uv pip install prime-agent-runtime
// 4. 安装预装包(requests / httpx / pyyaml / pandas / numpy / scipy / ...)
// DEFAULT_RLM_EXTRA_PACKAGES
// 5. 安装 Python skills(如果有)
// 递归解析 skill 依赖,用 uv pip install 安装
// 6. 运行 RUNTIME_READY_CHECK——验证 runtime API 完整
// 检查 rlm.spawn / rlm.harness.create_memory / mcp.list_tools 等方法存在
// 7. 写 .bootstrap-version 文件——记录版本,下次跳过安装
}
关键设计:
- uv 包管理——用 uv 而不是 pip。uv 比 pip 快 10-100 倍,且支持
uv venv创建虚拟环境 - 版本检查——
.bootstrap-version文件记录 schema + runtime 版本 + 预装包列表。如果文件存在且版本匹配,跳过安装。否则重新安装 - 预装包——
DEFAULT_RLM_EXTRA_PACKAGES包含 12 个常用包(requests / httpx / pyyaml / pandas / numpy / scipy / bs4 / lxml / pydantic / tyro 等),让 LLM 直接import pandas不需要安装 - READY_CHECK——一个 Python 单行脚本,检查
rlm.spawn/rlm.harness.create_memory/mcp.list_tools等 API 都存在。如果 runtime 版本不匹配,RUNTIME_READY_CHECK抛异常,bootstrap 重新安装 - Python skill 依赖解析——
normalizePythonSkills递归读 skill 的pyproject.toml,解析[project.dependencies],把依赖也装上
2. repl-manager.ts——REPL 进程管理
repl-manager.ts(1694 行)是 kernel 的核心——ReplKernelManager 类管理一个 Python REPL 子进程的完整生命周期。
启动
start() → doStart()(repl-manager.ts:296-382):
async doStart(options) {
// 1. 确保 Python 环境就绪
python = await ensureKernelPython({ pythonSkills: this.options.pythonSkills });
// 2. spawn Python 子进程
const child = spawnHidden(python, ["-m", "rlm.repl"], {
cwd: this.options.cwd,
env: {
...process.env,
PRIME_AGENT_KERNEL_OWNER_PID: String(process.pid), // 让 REPL 检测 host 存活
},
stdio: ["pipe", "pipe", "pipe"],
});
// 3. 等 ready 事件
const protocol = await this.waitForReady(child);
// 4. 检查协议版本
if (protocol !== REPL_PROTOCOL_VERSION) {
throw new Error("Kernel runtime speaks protocol X, expected Y");
}
this.state = "running";
}
启动流程:安装 Python → spawn python -m rlm.repl → 等 ready 事件 → 检查协议版本 → 标记 running。
spawnHidden 创建一个"隐藏"子进程——不继承终端,不显示窗口。PRIME_AGENT_KERNEL_OWNER_PID 让 REPL 知道谁是 host——host 死了 REPL 自动退出。
事件处理
wireChild(repl-manager.ts:389-512)绑定子进程的 stdout/stderr/exit 事件:
private wireChild(child: ChildProcess) {
// stdout → JSON-lines 解析
child.stdout.on("data", (buf) => {
buffered += decoder.write(buf);
// 按换行符拆分
while ((newline = buffered.indexOf("\n")) !== -1) {
const line = buffered.slice(0, newline);
buffered = buffered.slice(newline + 1);
const event = JSON.parse(line); // 解析 JSON
const invalidReason = invalidProtocolFrameReason(event);
if (invalidReason) {
this.failProtocolFrame(child, invalidReason); // 协议错误 → 修复
return;
}
this.handleEvent(event); // 分派事件
}
});
// stderr → 日志(有大小限制)
child.stderr.on("data", (buf) => {
this.appendKernelStderrText(decoder.write(buf));
writeFullySync(stderrLog.fd, buf); // 写到磁盘日志
});
// exit → 清理
child.on("exit", (code, signal) => {
this.state = "shutdown";
liveKernels.delete(this);
this.cleanupResources();
});
}
stdout 是协议通道——每行一个 JSON 对象。stderr 是诊断通道——写到 ~/.prime/agent/logs/kernel-stderr.log(有 5MB 大小限制,超限轮转)。
执行队列
REPL 一次只执行一个 cell——executionQueue 序列化所有 execute 请求(repl-manager.ts:185):
private executionQueue: Promise<unknown> = Promise.resolve();
async execute(code: string, options?: ExecuteOptions): Promise<ExecuteResult> {
// 排队——等前一个 execute 完成
const result = this.executionQueue.then(() => this.doExecute(code, options));
this.executionQueue = result.then(() => undefined, () => undefined);
return result;
}
为什么串行?因为 REPL 的命名空间是共享的——两个 cell 并发执行会互相干扰(一个 cell 改了变量,另一个 cell 看到的是中间状态)。串行保证每个 cell 看到的是上一个 cell 执行完后的完整状态。
中断
TypeScript host 发 {"type": "interrupt"} 给 REPL 进程。但如果 REPL 没响应(如死循环),repl-manager 有超时机制:
// 等中断响应
const interrupted = await raceWithTimeout(
this.waitForInterrupt(requestId),
KERNEL_BUSY_INTERRUPT_INTERVAL_MS
);
if (!interrupted) {
// 超时——检查 REPL 是否还活着
if (await this.isBusy()) {
throw new KernelBusyAfterInterruptError();
}
}
如果中断超时且 REPL 仍然 busy,repl-manager 可以 kill 子进程并重启——但这是最后手段,会丢失 REPL 状态。
协议修复
如果 stdout 收到无法解析的 JSON 或未知事件类型,failProtocolFrame(repl-manager.ts:514-553)触发协议修复:
private failProtocolFrame(child: ChildProcess, diagnostic: string) {
this.appendKernelDiagnostic(diagnostic);
this.readyDeferred?.reject(error);
this.rejectActiveExecution(error);
// 不是 shutdown 状态 → 尝试修复
if (this.state === "running") {
const owner = { superseded: false };
this.protocolRepairOwner = owner;
this.repairProtocolChild(child, owner); // kill + 重启 + restore snapshot
}
}
修复流程:kill 损坏的子进程 → 重新 spawn → 从 snapshot 恢复状态。如果 snapshot 本身有问题(恢复后仍然 corruption),标记 snapshot 为"可疑",不再尝试恢复。
状态快照
state-snapshot.ts(47 行)定义快照的接口。实际的 snapshot/restore 请求通过协议发给 Python REPL 执行(repl.py 的 _handle_state)。
repl-manager 有一个 debounced 自动快照机制——cell 执行完后延迟一段时间自动 snapshot,防止长时间运行后崩溃丢失状态:
private scheduleSnapshot(): void {
if (this.snapshotTimer) return;
this.snapshotTimer = setTimeout(() => {
this.snapshotTimer = undefined;
void this.snapshot(); // 发 {"type": "snapshot", ...} 给 REPL
}, DEFAULT_SNAPSHOT_DEBOUNCE_MS);
}
五、通信协议
TypeScript host 和 Python REPL 通过 JSON-lines 协议通信——每行一个 JSON 对象,通过子进程的 stdin/stdout 传输。
协议版本
REPL_PROTOCOL_VERSION = 3
启动时握手——REPL 发 {"event": "ready", "protocol": 3},host 检查版本匹配。不匹配则报错并要求更新 runtime。
TypeScript → Python:请求
| type | 字段 | 做什么 |
|---|---|---|
execute | id, code | 在 REPL 命名空间里执行一段 Python 代码 |
snapshot | id, path, manifest_path | 序列化 REPL 命名空间到文件 |
restore | id, path | 从文件恢复 REPL 命名空间 |
list_names | id | 列出用户定义的变量名 |
interrupt | id? | 中断正在执行的 cell(id 可选——不指定则中断任何) |
host_reply | id, data | 回复 Python 的 host_request |
shutdown | id? | 关闭 REPL |
每条请求有一个唯一 id——REPL 在回复 done 事件时带上同一个 id,让 host 对应请求和响应。
Python → TypeScript:事件
| event | 字段 | 做什么 |
|---|---|---|
ready | protocol | 启动完成,协议版本握手 |
stdout | id, text | cell 的 stdout 输出 |
stderr | id, text | cell 的 stderr 输出 |
result | id, data | cell 的 display 输出(MIME 类型 → JSON payload) |
display | id, data | 实时 display 事件(如图表、HTML) |
host_request | id, data | Python 反向调 TypeScript host |
error | id, ename, evalue, traceback | 执行错误 |
done | id, status, ... | cell 执行完成(status: "ok" / "error") |
done 事件是每条请求的终止信号——host 收到 done 后 resolve 对应的 Promise。done 的额外字段取决于请求类型(如 snapshot 的 done 带 saved / skipped / bytes)。
完整往返
1. TypeScript 发: {"type": "execute", "id": "abc123", "code": "print('hello')"}
2. Python 执行中 → 发: {"event": "stdout", "id": "abc123", "text": "hello\n"}
3. Python 完成 → 发: {"event": "done", "id": "abc123", "status": "ok"}
4. TypeScript 收到 done → resolve execute 的 Promise
带 host_request 的往返(Python 反向调 TypeScript):
1. TypeScript 发: {"type": "execute", "id": "cell1", "code": "rlm.harness.create_memory(...)"}
2. Python 执行 create_memory → 内部调 host_request → 发: {"event": "host_request", "id": "req1", "data": {"type": "create_memory", ...}}
3. TypeScript 收到 host_request → 执行 harness.create_memory → 发: {"type": "host_reply", "id": "req1", "data": {"status": "ok"}}
4. Python 收到 host_reply → resolve future → create_memory 返回
5. Python 完成 → 发: {"event": "done", "id": "cell1", "status": "ok"}
6. TypeScript 收到 done → resolve execute 的 Promise
关键点:host_reply 不走 _serve 队列——它直接 resolve 对应的 future(repl.py:1008-1017),否则会死锁(cell 在等 host_reply,队列被 cell 占着)。
中断协议
1. TypeScript 发: {"type": "interrupt", "id": "abc123"} (指定 ID)或 {"type": "interrupt"} (任意)
2. Python 收到 → _request_interrupt → 发 SIGINT 给当前 cell 的 task
3. Cell 被取消 → 发: {"event": "done", "id": "abc123", "status": "error", "reason": "interrupted"}
4. TypeScript 收到 done(error) → reject execute 的 Promise
中断是协作式的——不是 kill -9,而是 SIGINT 让 asyncio 任务自己取消。如果 cell 在等 IO(如 await bash("sleep 100")),bash handle 也会被取消。
六、Q&A
Q1:Harness 中的 global state 什么时候被更新,是显式的还是隐式的?
global state 的更新是显式的——通过 global_=True 参数。
每个 CRUD 操作(create_memory / update_memory / create_skill 等)都接受一个 global_: bool = False 参数:
# harness.py:549-560
def create_memory(self, title, content, *, global_=False, ...):
return self.create("memory", title, content, global_=global_, ...)
# LLM 在 REPL 里显式指定
rlm.harness.create_memory("构建命令", "npm run build", global_=True) # → 全局
rlm.harness.create_memory("当前分支", "feature/auth", global_=False) # → session 级(默认)
默认 global_=False——写入 session 级的 harness/harness_state.json。只有显式传 global_=True 才写入全局的 ~/.prime/agent/harness/harness_state.json。
/refine 命令更新 harness 时也是显式的——它回顾 trajectory 后决定哪些更新写到 local、哪些写到 global,每条更新都带 global_ 参数。
没有"自动从 local 提升到 global"的隐式机制——LLM 或 /refine 必须明确指定 scope。这避免了"session 级的临时笔记意外污染全局"的问题。
Q2:HarnessKind 是 prime-agent 新增的机制吗?pi 中 prompt / memory / skill 的存储是怎样的?
是的,HarnessKind 是 prime-agent 新增的机制。pi 没有这个概念。
pi 的存储模型:
| 资源 | pi 怎么存 | pi 怎么更新 |
|---|---|---|
| skill | .pi/skills/xxx/SKILL.md(Markdown 文件) | 用户手动写文件 |
| prompt | .pi/prompts/xxx.md(Markdown 文件) | 用户手动写文件 |
| extension | .pi/extensions/xxx.ts(TypeScript 文件) | 用户手动写文件 |
| memory | 没有 | 没有 |
| subagent spec | 没有 | 没有 |
pi 的所有资源都是静态文件——用户写好,agent 读。agent 不能自己创建或更新这些文件。
prime-agent 的 HarnessKind 把四种资源统一成一个结构化 JSON 存储(harness_state.json),agent 可以通过 Python API 自己 CRUD:
| HarnessKind | 存什么 | pi 有对应物? |
|---|---|---|
prompt | prompt note——行为策略补充 | pi 的 prompt template 是文件,不能自改进 |
memory | 事实/决策/偏好/失败教训 | pi 完全没有 |
skill | 可执行 Python skill spec(带 import 信息) | pi 的 skill 是静态 Markdown,不能执行 |
subagent | 可复用的子 agent 委托规格 | pi 完全没有 |
关键差异:
- pi:资源是文件,agent 只读不写。要改 skill,用户手动编辑 SKILL.md
- prime-agent:资源是 JSON 里的 entry,agent 可以
rlm.harness.create_memory(...)/update_skill(...)/delete_prompt_note(...)自己改。/refine命令让 agent 基于工作轨迹自动更新这些 entry
这就是"Self-Improving"的落地——pi 的 skill 永远不变,prime-agent 的 harness 状态随 agent 的经验增长而演化。
Q3:HarnessEntry 中 metadata 是由谁注入的?metadata 是有限的(可穷举的)还是由 LLM 任意写入的(无限的)?
谁注入
两条路径,通过 source 字段区分来源:
| 注入方 | 路径 | source 值 |
|---|---|---|
| LLM 直接调 CRUD API | rlm.harness.create_memory(..., metadata={...}) → create() → _upsert() | "agent"(默认) |
/refine 命令 | refiner LLM 生成 JSON proposal 的 edits[].metadata → applyProposal() 构造 HarnessEntry | "refine" |
两者写入的是同一个字段——_upsert 不区分来源,只做 merge 或覆盖(harness.py:398-399):
# 已有 entry:显式传了 metadata 就覆盖,None 则保留旧值
if metadata is not None:
existing.metadata = dict(metadata)
# 新 entry:
metadata=dict(metadata or {})
/refine 侧的 merge 逻辑一样——edit.metadata ?? before?.metadata ?? {}(refinement.ts:819),没传就沿用旧值。
有限的还是无限的
完全自由(无限的)——dict[str, Any],没有 schema、没有校验、没有可穷举的 key 集合。
对比 reference 字段(仅 skill kind 用)有严格校验——_validate_python_skill_reference()(harness.py:131-141)强制要求 type == "python" + import + callable。metadata 没有任何对应的校验函数。
三层证据:
1. 类型定义——完全开放(harness.py:108):
metadata: dict[str, Any] = field(default_factory=dict)
2. load() 只做类型兜底,不校验内容(harness.py:253-254):
if not isinstance(entry_data.get("metadata"), dict):
entry_data["metadata"] = {}
# 只检查"是不是 dict",不检查里面有什么 key
3. /refine 的 system prompt 只是建议,不是约束(refinement.ts:142):
When an edit is persisted, include metadata such as
{"scope":"local"}or{"scope":"global"}when that helps future review understand the intended blast radius.
这是一个 suggestion——LLM 可以放任何 key。proposal 的 JSON schema 示例里 metadata 就是个空对象 "metadata": {}(refinement.ts:163),完全留给 LLM 自由发挥。
4. 没有任何代码读取特定的 metadata key 做行为决策。
整个代码库里对 metadata 的操作只有三类:
- passthrough:
objectRecord(entry.metadata) ?? {}(refinement.ts:344)——原样搬运 - fallback merge:
edit.metadata ?? before?.metadata ?? {}(refinement.ts:819)——没传就沿用旧值 - rollback copy:
metadata: edit.before.metadata(refinement.ts:866)——回滚时原样拷回
没有人做 entry.metadata["scope"] 或 entry.metadata.get("kind") 这类读取。metadata 被存储、合并、拷贝,但从不被语义化解读。
metadata 是一个只写不读的开放式附件——LLM(或 /refine)可以往里塞任意 key-value,系统存储它但不基于它做任何决策。它的定位是"给未来的人类审查或 LLM 回顾留的注释",不是"驱动 agent 行为的结构化字段"。
Q4:prime-agent 的 skill 是可执行的 Python 包,这句话怎么理解?是 prime-agent 不支持通用 skill、自己重新实现了 skill 规范的意思吗?
不是。 prime-agent 没有抛弃 pi 的 Markdown skill,也没有重新实现 skill 规范——它扩展了 Agent Skills 标准,在原生的 Markdown skill 之上增加了一种 superset:Python-backed skill。两种 skill 共存,通过同一个 SKILL.md 入口发现。
两种 skill kind 共存:
export type SkillKind = "markdown" | "python";
| Markdown skill | Python-backed skill | |
|---|---|---|
| 入口 | SKILL.md | SKILL.md(相同) |
| 额外要求 | 无 | pyproject.toml + src/<import_name>/__init__.py |
| LLM 怎么用 | 读 SKILL.md 全文,按指示操作 | 在 REPL 里直接 await <import_name>(...) 调用 |
| 是可执行包? | 不是——是指令文档 | 是——editable install 到 kernel venv |
发现逻辑——SKILL.md 是统一入口,Python 是附加检测。如果只有 SKILL.md,没有 pyproject.toml,那就是 pi 原生的 Markdown skill——和 pi 完全兼容。如果目录里有 pyproject.toml + src/__init__.py,就升级为 Python-backed skill。
别和 Harness State 的 skill 混淆——文件系统 skill 和 harness state skill entry 是两个不同的概念:
文件系统 skill(skills.ts) | Harness state skill(harness.py) | |
|---|---|---|
| 存哪 | 磁盘上的 SKILL.md + 可选 Python 包 | harness_state.json 里的 entry |
| 谁创建 | 用户手写或 skill-creator skill 生成 | LLM 通过 rlm.harness.create_skill(...) 或 /refine 动态创建 |
| kind 限制 | markdown 或 python 都行 | 必须是 python——强制 reference.type == "python" |
| 是真实包? | Python skill 是真实安装在 kernel venv 的包 | 不是包——是"可复用 Python 调用的持久化描述" |
| 能执行? | Python skill 可以 await import_name(...) | 自身不能执行——它描述的是一个已存在的 Python callable 的调用契约 |
所以"prime-agent 的 skill 是可执行的 Python 包"这个说法只对 Python-backed skill 成立——Markdown skill 仍然是指令文档。prime-agent 没有重新实现 skill 规范,而是在 Agent Skills 标准之上做了向后兼容的扩展。
七、下一章预告
下一篇文章将拆解 prime-agent 的 RLM 运行时——rlm-runtime.ts(TypeScript 侧的子 agent spawn 桥接)+ prompts/rlm.ts(RLM system prompt 构造)+ context-tree.ts(prompt-as-a-variable)+ semantic-edges.ts(context 分段标记)+ side-question.ts(侧问机制)。RLM 让 agent 从单层循环变成递归的多 agent 树——rlm() spawn 子 agent、子 agent 再 spawn 孙 agent,每层有自己的 context 和 REPL。