这一章回答:Guest 收到的只是源码前缀时,怎样判断哪些读取已经足够完整,可以提前工作;源码全部到达后,又怎样把提前结果交还给原调用?
完整因果路径是:Host 先用 prefix_begin 把本轮 inputs、Manifest 和 prepare 回调交给 Guest,创建一个 Prefix 对象;随后每次 prefix_feed 都把新片段追加到同一对象的 source。Prefix 只取最后一个换行之前的文本来解析。解析成功后,它从 tree.body 的第一个顶层 statement 开始连续扫描:当前 statement 不是允许的单目标工具赋值,就立即停止;是候选且尚未处理过,才把字面量或固定 inputs[...] 参数编码成 request,调用 prepare。非零 handle 按源码顺序进入 ready。源码接收结束后,execute 仍在同一个 Guest 中读取这个 Prefix 保存的 source 和输入,最终 Python 工具调用构造同样的 request,并且只能匹配 ready 队首;匹配成功才领取对应 handle。
这套逻辑不执行 AST,也不把源码片段当作独立程序。它只负责提前识别和排队;真正的 compile、Python 调用、Future 结果解码和错误传播仍在既有 bootstrap/Host 路径。这里的“完整句”要按当前实现理解为:位于最后一个换行之前,并且能被 ast.parse 成功解析的已收到文本。没有换行、括号未闭合或其他语法不完整时,Prefix 暂不准备。
1. Manifest 先决定哪些具名函数有资格提前读取
Host 的 Go Manifest 会在创建 Runner 时转换为 Guest 使用的列表。列表中的每项只有 name 和 allow_early_read;Host 的 Call 实现不会暴露给 Python。prefix_begin 从 JSON 取出这份列表,并执行 Prefix(request["inputs"], request["manifest"], prepare)。因此 Prefix 不是把 lookup 写死在代码里,而是由当前本轮的 Manifest 生成允许集合。
例如测试传入:
MANIFEST = [{"name": "lookup", "allow_early_read": True}]
如果清单再包含 buy 且 allow_early_read 为 False,buy(...) 仍可在最终 Python 程序中作为工具存在,但不能成为 prefix 候选。Prefix 也会避免把目标变量写成 inputs 或任何允许提前读取的工具名,以免覆盖最终执行时要注入的名字。
2. 模块状态和两阶段交接
guest/prefix.py · L1–L8 · 打开完整源码
"""Append-only source intake; only leading, complete, literal/input tool reads.
No eval/exec here. Final execution and Future ownership remain in the existing path.
"""
import ast
import json
模块声明先给出范围:源码只追加,候选必须位于前导的完整调用,文件中没有 eval/exec。ast 用来识别语法形状,json 用来生成 Host prepare 约定的 request;这两项导入都不执行用户源码。
guest/prefix.py · L9–L18 · 打开完整源码
class Prefix:
def __init__(self, inputs, manifest, prepare):
self.inputs = inputs
self.manifest = manifest
self.early_names = {spec["name"] for spec in manifest if spec["allow_early_read"]}
self.prepare = prepare
self.source = ""
self.seen = 0
self.ready = []
self.claimed = 0
Prefix 是一轮执行在 Guest 中的状态对象。构造时保存:
inputs:prefix_begin收到的本轮输入快照;manifest:原样保留给最终execute使用的 Guest 清单;early_names:从 manifest 筛出的允许提前读取的具名函数;prepare:通向 HostrunState.prepare的回调;source:所有已收到片段的拼接文本;seen:已经走过候选位置的数量;ready:按源码顺序保存(request, handle);claimed:下一个待领取的 ready 下标。
这三个计数/容器代表不同阶段:source 是输入交付,seen 记录发现进度,ready 是已经发起的提前工作,claimed 是最终执行已经领取到哪里。Prefix 不保存工具结果,也不拥有 worker 的取消和等待。
_prefix 的创建和最终 execute 不在本文件,但交接关系固定:prefix_begin 把对象挂到 Guest 的当前执行环境;Host 收完 channel 后调用同一模块的 execute({"prefix": true});bootstrap 据此从 _prefix.source、_prefix.inputs 和 _prefix.manifest 组装完整请求。于是接收阶段和执行阶段共享同一份 source 与同一个 Prefix 队列。
3. feed:追加之后才尝试识别完整文本
guest/prefix.py · L20–L27 · 打开完整源码
def feed(self, chunk):
self.source += chunk
# A trailing partial line is not a complete received statement.
complete = self.source[:self.source.rfind("\n") + 1]
try:
tree = ast.parse(complete, filename="<spine>")
except SyntaxError:
return # Incomplete/invalid source is diagnosed at final compile.
feed 先执行 self.source += chunk,所以一条赋值拆在多个 chunk 中时,后一次解析能看到前一次文本。接着通过最后一个 \n 截出 complete:没有换行时,切片结果为空;有换行时,包含该换行及之前的全部文本。尾部没有换行的片段会留在 source,但暂不进入本轮候选扫描。
随后 ast.parse(complete, filename="<spine>") 只构造 AST。若解析抛出 SyntaxError,方法直接返回,不调用 prepare;已经追加的 source 不会丢失,后续 chunk 仍会在累计文本上重试。这个异常吞掉只适用于 prefix 的观察阶段,最终完整 source 仍由 compile 负责报告语法错误。
“按换行截取”并不等于所有 Python 句子都由一行组成:多行字符串、括号续行等结构仍要等到 AST 能成功解析。实现的选择是宁可延迟准备,也不在半句上猜参数。
4. 扫描停止条件:第一个非候选 statement 就停
guest/prefix.py · L28–L43 · 打开完整源码
for index, statement in enumerate(tree.body):
if not self.candidate(statement):
break # Never pass a branch, dependency, mutation, import, or other code.
if index < self.seen:
continue
self.seen = index + 1
call = statement.value
try:
args = {k.arg: self.argument(k.value) for k in call.keywords}
request = json.dumps({"tool": call.func.id, "args": args})
except Exception:
continue # Argument errors are still raised at the actual Python call.
handle = self.prepare(request)
if handle:
self.ready.append((request, handle))
解析成功后,循环只遍历顶层 tree.body,按 index 从零开始处理。对每个 statement,第一件事是调用 candidate:
- 返回
False时执行break,整个 prefix 扫描结束,不会跳过它继续查找后面的工具调用; - 对已经计入
seen的索引执行continue,避免每次新 chunk 都重复准备旧候选; - 新候选先把
seen设为index + 1,再转换参数和调用prepare。
因此以下都可能成为停止点:顶层 if/循环/import/普通赋值、依赖尚未执行变量的调用、非 early-read 工具调用,以及不符合参数限制的工具调用。即使停止点后面有一个字面量读取,也不会跨过去准备。当前 seen 的位置更新在参数转换之前;如果 inputs[...] 取值或 JSON 编码抛异常,异常会被捕获并跳过准备,但这个位置不会在后续 chunk 再次尝试。
对真正的候选,代码从调用节点取出关键字参数,用 argument 转成值,再生成 {"tool": ..., "args": ...} 的 JSON 字符串。prepare 返回非零 handle 时,request 和 handle 一起追加到 ready;返回零或其他假值时不追加,最终调用没有可领取的提前结果,仍走普通路径。
这条扫描规则的核心不是“找到所有看起来像工具的调用”,而是维护一个安全的前缀:只有从 statement 0 开始连续通过门槛的读取才进入队列。它也解释了为什么 if False: 内部的读取不会被准备,以及为什么前面出现 buy(... )(Manifest 中不允许 early read)时,后面的 lookup(...) 也不会进入 ready。
5. candidate:当前 Manifest 下的窄 AST 形状
guest/prefix.py · L44–L61 · 打开完整源码
def candidate(self, statement):
if not (isinstance(statement, ast.Assign) and len(statement.targets) == 1
and isinstance(statement.targets[0], ast.Name)
and statement.targets[0].id != "inputs"
and statement.targets[0].id not in self.early_names):
return False
call = statement.value
return (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
and call.func.id in self.early_names and not call.args
and all(k.arg is not None and self.literal_or_input(k.value) for k in call.keywords))
@staticmethod
def literal_or_input(node):
if isinstance(node, ast.Constant):
return isinstance(node.value, (str, int, float, bool, type(None)))
return (isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name)
and node.value.id == "inputs" and isinstance(node.slice, ast.Constant)
and isinstance(node.slice.value, (str, int)))
candidate 的第一层要求是普通 ast.Assign,只有一个目标,目标是简单名称;目标不能是 inputs,也不能是 early_names 中的工具名。它没有放行属性赋值、下标赋值、多目标赋值或覆盖允许提前读取的工具绑定。
右侧必须是直接调用:被调用对象是一个名称,名称属于 early_names,没有位置参数,所有参数都必须是有名字的关键字参数,并且每个关键字值通过 literal_or_input。所以当前规则由 Manifest 同时约束“调用哪个函数”和“哪些函数名不能被当作变量覆盖”。它没有把所有 Manifest 工具都当成 early-read 工具。
literal_or_input 允许两类表达式:
str、int、float、bool或None这几种 Python 常量;inputs[...],其中inputs必须是简单名称,下标必须是常量字符串或整数。
argument 对已通过形状检查的节点取实际值:常量直接返回;输入下标从 self.inputs 读取。读取可能因输入为 None、键缺失或对象不支持下标而失败。feed 会把这类失败留在提前阶段之外,最终真实调用仍会在原位置重新评价参数并产生对应的 Python 错误。
这是一道提前工作的门槛,不是最终 Python 语法或类型检查。比如 inputs["item"] 可以进入候选,inputs[key]、compute()、inputs.get("item") 和 inputs["a"] + 1 不可以;不符合门槛的代码仍可能在最终程序中正常执行,或者在那里报错。
guest/prefix.py · L63–L66 · 打开完整源码
def argument(self, node):
if isinstance(node, ast.Constant):
return node.value
return self.inputs[node.slice.value]
argument 把已经通过形状检查的节点转成实际参数:常量直接取值,输入下标从 self.inputs 读取。它本身不重复验证节点形状,因此调用顺序很重要:先由 candidate 和 literal_or_input 放行,再由 argument 取值;取值或 JSON 编码失败时,feed 的异常处理会把错误留给最终调用。
6. claim:只认队首和完整 request
guest/prefix.py · L68–L75 · 打开完整源码
def claim(self, request):
# Leading calls execute in source order even if the full program is not optimized.
# Match the actual request too; never search ahead or reuse a different read.
if self.claimed < len(self.ready) and self.ready[self.claimed][0] == request:
handle = self.ready[self.claimed][1]
self.claimed += 1
return handle
return None
最终工具调用到达 bootstrap 时,会按调用名和参数重新生成 JSON request。claim 只检查 ready[self.claimed] 是否存在,以及它保存的 request 是否与当前 request 完全相等。它不会按工具名查找,也不会向后搜索一个相同 request。
匹配成功后取出队首 handle,并把 claimed 加一。于是两个内容相同但出现在不同源码位置的读取,会占用两个 ready 条目,分别领取两个 handle;一次领取后再次 claim 不会复用旧 handle。request 不匹配时返回 None 且不移动游标,调用者可以继续按正确的源码顺序尝试。
最终的 handle 仍只是整数标识。真正的结果等待、工具错误、取消和单次删除发生在 Host runState.resolve;Prefix 不绕过 Future,也不能仅凭自身状态声称 worker 已完成。
7. 用一个时序例子串起四个状态
假设 inputs["item"] == "book",并收到三次片段:
a=lookup(key=inputs['item']:没有换行,complete为空,尚未调用prepare,seen=0、ready为空。)\n:累计 source 现在包含完整第一条赋值;Manifest 允许lookup,argument读到book,prepare返回 handle 1,因而seen=1、ready=[(request1, 1)]。b=lookup(key='shipping')\n:重新解析会再次看到 index 0,但它小于seen被跳过;index 1 是新候选,获得 handle 2。- 最终
execute运行第一条调用时只能领取 request1;第二条调用到达时才领取 request2。若先尝试 request2,队首检查会返回None,不会把 handle2 提前消费。
如果第一条完整 statement 是 if ... 或不允许 early read 的 buy(...),第 1 个位置就会触发 break,上面的第二条 lookup 不会获得任何提前 handle。这就是“source 停止扫描”与“最终程序仍可执行”可以同时成立的原因。
下一步读 prefix-tests.md,看真实 Wasm 测试怎样用 channel、started 和 stopped 把这条 Guest 内部时序接到 Host worker;再读 prefix-python-tests.md,看同一规则如何在不启动 Wasm 的情况下被隔离验证。
读完本章应能回答:
- 当前 Manifest 如何决定
early_names,为什么非 early-read 工具会成为扫描停止点? - 为什么“最后一个换行”仍不足以保证语法完整,
ast.parse失败时 source 和seen分别怎样变化? - 为什么
claim必须匹配队首的完整 request,而不能按工具名向后搜索?