这一章回答:C 已经把请求交到 CPython 后,Python 层怎样决定用户能看到什么、怎样调用 Host 工具,以及怎样把成功/失败重新编码给 Go?调用者是 runtime.c 缓存的 execute、prefix_begin、prefix_feed;输入是 JSON 字符串和源码片段;最终交付是 UTF-8 JSON bytes。当前协议的关键点是:请求 manifest 描述工具,Python 为每个名称建立 proxy,所以用户写 lookup(key=...),函数名称和 Host 实现由同一份 manifest 对齐。
普通执行和工具回调有两条相反方向的路径:
Go execute JSON → execute → exec 用户源码
用户 lookup(...) → make_tool proxy → invoke_tool
→ _spine.call/resolve → Go bridge → JSON response
→ decode → Python 值或 RuntimeError
Python 负责 JSON、作用域、AST 变换和异常 envelope;C 负责 bytes/长度,Go 负责工具实现、handle 和 Guest 内存边界。
1. 模块加载时只建立桥和 prefix 哨兵
CPython 导入本模块时还没有用户源码,也没有本轮输入。它导入 C 注册的 _spine 三个函数,把 _prefix 设为 None,随后等待 execute 或 prefix 入口。
guest/bootstrap.py · L1–L8 · 打开完整源码
"""Execution convention: inputs in, result out; optional whole-program PLM."""
import json
import sys
from _spine import call, prepare, resolve
_prefix = None
call、prepare、resolve 是当前模块的全局名字,来自 C 模块,不是用户 scope 中自动注入的变量。_prefix=None 把“普通执行还没有源码接收状态”表达成明确哨兵。用户程序稍后运行在另一个 scope 字典里;工具 proxy 的函数体仍按定义位置从 bootstrap 全局查找这些桥接名字。
2. 普通工具调用:名称在 Python,执行权在 Host
decode 接收 C 返回的 response bytes,解析为 dict;有 error 就在当前 Python 调用位置抛 RuntimeError,否则取 value。这样 Host 工具错误先作为协议数据跨边界,再转换成用户可捕获的 Python 异常。
guest/bootstrap.py · L9–L21 · 打开完整源码
def decode(response):
response = json.loads(response)
if "error" in response:
raise RuntimeError(response["error"])
return response["value"]
def invoke_tool(name, args):
request = json.dumps({"tool": name, "args": args})
handle = _prefix.claim(request) if _prefix is not None else None
return decode(call(request) if handle is None else resolve(handle, request))
invoke_tool(name, args) 把函数名和 kwargs 组成 {"tool": name, "args": args},用 json.dumps 生成请求。若 prefix 已有队首匹配 handle,就走 resolve(handle, request);否则走普通 call(request)。两条路径都交给 decode,所以调用者只看到 Python 值或 RuntimeError。
这里没有一个通用的 tool 函数。invoke_tool 是内部协议函数,用户看到的名称由下面的 make_tool 逐个生成。Python dict 和 Go 的 json.RawMessage 之间不共享对象,必须通过 JSON 文本经过 C/Host。
guest/bootstrap.py · L22–L28 · 打开完整源码
def make_tool(name):
def proxy(**args):
return invoke_tool(name, args)
proxy.__name__ = name
return proxy
make_tool(name) 返回一个只接收关键字参数的闭包 proxy。闭包调用 invoke_tool(name, args),并把 proxy.__name__ 设置为 manifest 中的名称。execute 会为每个 manifest spec 调一次 make_tool,因此用户 scope 中会出现 lookup 这样的具名函数。闭包捕获的是名称,不是某个预先执行的工具结果。
3. PLM 提前调用如何保留原位语义
PLM 需要一个 thunk,让它能尝试读取工具参数,但又不能把参数错误提前变成用户程序更早看到的异常。prepare_call 接收 thunk,成功求值后组装和普通调用相同的请求;prefix 队首已有 handle 时直接复用,否则调用 C 的 prepare。
guest/bootstrap.py · L29–L41 · 打开完整源码
def prepare_call(thunk):
try:
name, args = thunk()
request = json.dumps({"tool": name, "args": args})
handle = _prefix.claim(request) if _prefix is not None else None
return prepare(request) if handle is None else handle
except Exception:
# Argument lookup/encoding errors belong at the original Python call, not here.
return 0
def resolve_call(handle, name, **args):
return decode(resolve(handle, json.dumps({"tool": name, "args": args})))
prepare_call 捕获 Exception,参数查找、JSON 编码或读取失败就返回 0。0 只代表没有可提前领取的结果,不能当作业务值。它没有捕获 BaseException,因此不吞掉更宽的控制信号。resolve_call 不包这一层 try:它正位于原始工具调用的交付点,会重新用当前的 name/args 组装请求并调用 resolve。
这两个函数共同维持一个重要顺序:提前阶段可以启动本轮 worker,但真正的值或工具错误仍在用户源码原来的调用位置交付。Go 会在 resolve 时校验 handle、工具名和参数是否匹配;Python 层不凭一个整数猜结果。
4. prefix 只接收源码,不执行源码
prefix 开始时,C 传入包含 inputs 和 manifest 的初始请求。prefix_begin 解析 JSON,导入 Prefix,创建 _prefix 对象,并把 prepare 函数交给它。后续每个源码片段进入 prefix_feed,由 _prefix.feed 追加和分析。
guest/bootstrap.py · L44–L53 · 打开完整源码
def prefix_begin(request):
global _prefix
from prefix import Prefix
request = json.loads(request)
_prefix = Prefix(request["inputs"], request["manifest"], prepare)
def prefix_feed(chunk):
_prefix.feed(chunk)
global _prefix 让初始化写回模块级状态;没有它,赋值会变成局部变量。Prefix 可以在完整、允许的前缀上发起 prepare,但这里没有 compile/exec 用户程序。prefix_feed 正常返回 None;如果状态还没建立,访问 _prefix.feed 会产生异常,由 C wrapper 报告失败。
prefix 的状态属于当前 CPython Guest,不是 Go 全局变量。最终执行时,Go 发送一个小的 {"prefix":true} 请求,execute 再从 _prefix 取回 source、inputs、manifest,并强制启用 PLM 入口。
5. execute 统一普通、PLM 和 prefix 的最终交付
execute 是 Python 层总入口。它先保留一个空的 transformed,再在一个覆盖范围很大的 try 中解析请求、选择 prefix、构造用户 scope、可选地做 AST transform、执行代码并编码响应。
guest/bootstrap.py · L55–L65 · 打开完整源码
def execute(request):
transformed = ""
try:
request = json.loads(request)
if request.get("prefix"):
request = {
"source": _prefix.source,
"inputs": _prefix.inputs,
"manifest": _prefix.manifest,
"plm": True,
}
普通请求直接取自己的 source、inputs 和 manifest。prefix 请求则从 _prefix 重新组装同样的信息,并把 plm 设为 true;这说明 prefix 的源码接收和最终执行是两个阶段。无论哪条路径,scope 至少包含 __name__ 和 inputs,随后按 manifest 为每个工具名放入 proxy。用户代码拿到的是 lookup 这样的函数,不会自动看到 _prefix、call 或 prepare。
PLM 开启时才导入 ast 和 plm.transform,传入完整 source 与 manifest。transform 返回 AST 和 helper 名称;只有 helper 非空才把 prepare_call/resolve_call 放进 scope,并用 ast.unparse 保存实际变换文本。普通路径不会导入这套优化器。
guest/bootstrap.py · L66–L83 · 打开完整源码
scope = {"__name__": "__main__", "inputs": request["inputs"]}
for spec in request["manifest"]:
scope[spec["name"]] = make_tool(spec["name"])
code = request["source"]
if request.get("plm"):
import ast
from plm import transform
code, helpers = transform(code, request["manifest"])
if helpers:
scope.update(zip(helpers, (prepare_call, resolve_call)))
transformed = ast.unparse(code)
exec(compile(code, "<spine>", "exec"), scope)
response = {"value": scope.get("result"), "transformed": transformed}
return json.dumps(response).encode("utf-8")
except BaseException as error:
return json.dumps({"error": f"{type(error).__name__}: {error}", "transformed": transformed}).encode("utf-8")
finally:
sys.stdout.flush()
exec(compile(code, "<spine>", "exec"), scope) 在同一个字典中创建用户变量和 result。成功后响应包含 scope.get("result") 和变换文本,再 JSON 编码成 UTF-8 bytes。没有给 result 赋值时,交付的 value 是 None;这不是 Python 层自动报错。
except BaseException 把逃出用户执行、请求解析、PLM、编译或结果编码的异常写入 error 字段,并保留已经产生的 transformed 文本。finally 总是 flush stdout;flush 失败也可能成为 C 看到的调用失败。成功/失败 JSON 和 stdout 是两条不同的输出。
一次 lookup 的状态序列
以默认源码为例,scope 建立后有 inputs 和 lookup proxy。第一行调用 lookup(key=inputs["item"]):
- proxy 把名称
lookup和 kwargs 交给invoke_tool。 invoke_tool编码请求;普通路径没有_prefixhandle,于是调用_spine.call。- C/Go 返回
{"value":21}一类响应,decode取出 Python 整数。 - 用户第二行使用整数和 inputs 组成 result dict。
execute从 scope 取 result,返回外层 JSON bytes。
如果 Host 返回 error,decode 在第一行抛 RuntimeError;用户若未捕获,execute 将它序列化为 error 字段。若是 PLM,prepare 可能先启动工作,但 resolve_call 仍在原调用位置确认交付。若是 prefix,feed 阶段成功也只表示已更新解析/准备状态,不表示用户赋值执行完毕。
读完本章应能回答:为什么 manifest 要在 Guest 中变成多个具名 proxy、为什么 prepare_call 可以返回 0 而不破坏原调用语义,以及为什么最终响应中的 transformed 只是诊断文本。下一步进入 guest-c.md 对照 bytes 和 packed ABI,或进入 bridge.md 跟 invoke_tool 发出的请求如何匹配 ToolSpec。