Chương 15Phần 4: Pi 1.0
Codemode, MCP và tool search
Từ “không MCP” tới codemode: sandbox QuickJS, prelude, khai báo TypeScript và pi-mcp.
Trong chương này19 mục
- Vì sao: từ “không MCP” tới codemode
- Mô hình tư duy: script ở phía harness
- Data model của pi-codemode
- Sandbox: worker, QuickJS và giao thức host
- Prelude: những gì script nhìn thấy
- Sinh khai báo TypeScript cho tool
- Tất định, giới hạn và runtime từ xa
- Tool codemode của coding agent
- Chạy một script
- Lời gọi tool lồng nhau
- pi-mcp: client, JSON-RPC và transport
- pi-mcp: OAuth
- Extension MCP có sẵn
- Tool search
- Trace: một script gọi song song hai tool MCP
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Vì sao: từ “không MCP” tới codemode#
Năm 2025, Pi tuyên bố không hỗ trợ MCP (Model Context Protocol, giao thức để một client gọi tool do server bên ngoài cung cấp). Lý do, trình bày ở chương Lịch sử và triết lý, là chi phí ngữ cảnh: một server MCP thường khai báo sẵn hàng chục tool, và mô tả của chúng chiếm một phần đáng kể cửa sổ ngữ cảnh (context window) trước khi người dùng gõ chữ nào. Thay vào đó Pi khuyên dùng CLI kèm README, và “Bash is all you need”.
Ngày 2026-09-29, bài “You Said No MCP!” và bản 0.99.0 đưa MCP vào lõi (CHANGELOG.md:432). Bài viết giải thích hai điều. Một, MCP đã thay đổi kể từ đó, nhóm đã theo dõi các thay đổi ấy khoảng một năm, và muốn góp phần định hướng nó: gần với OpenAPI hơn, có kết quả có cấu trúc và khả năng tìm tool. Hai, vấn đề lớn nhất còn lại là ghép các lời gọi: “The biggest issue with MCP continues to be that it's hard to compose.” Pi trả lời bằng hai cơ chế. Chi phí ngữ cảnh được xử lý bằng exposure, nạp tool hoãn lại và system message giữa hội thoại (chương Pi 1.0). Việc ghép lời gọi được xử lý bằng codemode: mô hình không gọi từng tool một mà viết một đoạn JavaScript gọi nhiều tool, song song, lọc và gộp kết quả, rồi chỉ trả phần tóm tắt về cho mô hình.
Câu đó là chìa khoá của thiết kế. Tool bash chạy lệnh trên máy (hoặc trong sandbox) của người dùng; script codemode thì chạy bên cạnh vòng lặp agent, trong tiến trình của harness, nơi được coi là tin cậy. Một runtime JavaScript nhỏ biên dịch sang WebAssembly (QuickJS) là lớp bảo vệ quanh script, và trạng thái của script được giữ trong transcript của session chứ không trên hệ thống file. Gói pi-codemode mô tả mình đúng như vậy: chạy JavaScript do mô hình viết trong một VM QuickJS mà khả năng duy nhất là gọi các tool được tiêm vào; lời gọi lồng nhau không bao giờ vào ngữ cảnh của mô hình, chỉ output và giá trị trả về của script vào (codemode/README.md:3).
Trong repo, câu trả lời gồm hai gói mới và ba built-in extension của coding agent, thêm cùng commit 8562bcf66 (2026-09-29):
codemode, mô tả có ngân sách, models.*, store().
extension tool-searchTool tool_search: BM25 trên tool chưa khai báo, nạp kết quả cho lượt sau.
extension mcpmcp.json, kết nối nền, tool mcp__server__tool, /mcp, pi mcp.
Ba extension được khai báo là “thay được”: một extension khác đăng ký codemode, tool_search hay /mcp sẽ thay chỗ bản có sẵn thay vì chạy song song (extensions/index.ts:7–14). Cơ chế nạp extension chung ở chương Extensions; chương này đi vào bên trong ba extension đó.
Mô hình tư duy: script ở phía harness#
mô hình
│ lời gọi tool codemode { code: "<JavaScript>" }
▼
executeCodemode() (extension codemode, luồng chính của pi)
│ tạo CodemodeSandbox, mỗi tool gọi được thành một CodemodeTool
▼
Execution (host) ◄── postMessage, mọi giá trị là chuỗi JSON ──► worker thread
│ deadline, abort, bản ghi lời gọi, output QuickJS VM (wasm)
│ prelude: tools, text(),
│ ◄── { type: "call", id, name, args } ──────────────────── image(), store()...
│ ─── { type: "result", id, ok, payload } ─────────────────► script của mô hình
▼
ctx.executeTool(name, args) → NestedToolCallRunner → runToolCall()
│ kiểm tra đối số, hook tool_call / tool_result, parentToolCallId
▼
tool đích: read, bash, ... hoặc mcp__server__tool
▼ (MCP)
McpServerConnection → McpClient → StdioTransport | StreamableHttpTransport → server MCP
codemode đi xuống tới server MCP. Chỉ output của script đi ngược lên tới mô hình.- Script
- Đầu vào của tool
codemode: JavaScript thô (không phải JSON, không có code fence), chạy như thân của một hàm async, nên dùng đượcawaitvàreturnở cấp cao nhất. - VM và host
- VM là một instance QuickJS riêng, có bộ nhớ tuyến tính riêng, sống trong một worker thread. Host là phía Node của pi giữ bảng tool, deadline, tín hiệu huỷ và bản ghi lời gọi.
- Prelude
- JavaScript chạy trong VM trước script, dựng
tools,ALL_TOOLS,text(),image(),exit(),store()/load(),consolevà các global, trên một cầu nối (bridge) duy nhất tới host. - Lời gọi lồng nhau (nested tool call)
- Một lời gọi tool do script phát ra. Nó đi qua pipeline tool của agent như lời gọi của mô hình, nhưng không vào transcript; chỉ một bản ghi có giới hạn của nó được lưu trên kết quả của lời gọi
codemode. - Namespace
- Nhóm tool, thường là một server MCP, tên
mcp__<server>.
Data model của pi-codemode#
Gói không biết gì về pi. Với nó, một tool chỉ là một tên, vài schema để vẽ khai báo, và một hàm:
export interface CodemodeTool { /** * The script calls tools as `tools.<id>(args)`, where `<id>` is the name with characters that * are not valid in identifiers replaced by `_` (see `toCodemodeIdentifier`), and also as * `tools["<name>"](args)`. Globals are called as `<name>(args)` and must be identifiers, or * `<namespace>.<member>`, which groups them into a frozen namespace object. */ name: string; /** Shown as a doc comment in {@link renderDeclarations}, and listed in `ALL_TOOLS` for tools. */ description?: string; /** Schema of the single argument. Rendered as the parameter type; `unknown` when omitted. */ inputSchema?: CodemodeJsonSchema; /** Schema of the resolved value. Rendered as the promise type; `unknown` when omitted. */ outputSchema?: CodemodeJsonSchema; /** Globals only: `execute` receives all call arguments as an array instead of the first one. */ spread?: boolean; /** * Globals only: TypeScript parameter list and return type for {@link renderDeclarations}, for * example `(type: string, id?: string): Promise<Model[]>`. Replaces the rendering from the schemas. */ signature?: string; /** * `args` is whatever the script passed, after a JSON round trip. The return * value must be JSON-serializable; a thrown error surfaces in the script as * an `Error` with the same message. */ execute(args: unknown, context: CodemodeToolContext): Promise<unknown> | unknown;}execute(), nên tool không bao giờ nhận object sống của VM.Kết quả của một lần chạy không bao giờ là ngoại lệ. Lỗi của script, hết giờ, bị huỷ hay sandbox hỏng đều thành { ok: false, error }, và output tới lúc lỗi vẫn được giữ:
/** * One item of the script's output, in the order the script produced it: `text()` and `console.*` * produce text items, with `console: true` for `console.*`, and `image()` image items. `data` is base64. */export type CodemodeOutputItem = | { type: "text"; text: string; console?: true } | { type: "image"; data: string; mimeType: string };/** Keys the script changed with `store()`. Only successful executions report writes. */export interface CodemodeStoreWrites { set: Record<string, unknown>; /** Keys stored as `undefined`. */ delete: string[];}/** * `output` is kept for failed executions too, up to the failure. `exit()` completes with `value: undefined`. * `during`: in a {@link CodemodeSandboxBase.start} run that the script itself ended (it returned, threw, or exited), * the result being processed when it did. */export type CodemodeResult = | { ok: true; value: unknown; output: CodemodeOutputItem[]; calls: CodemodeCall[]; storeWrites: CodemodeStoreWrites; during?: CodemodeDuring; } | { ok: false; error: CodemodeError; output: CodemodeOutputItem[]; calls: CodemodeCall[]; during?: CodemodeDuring; };error.kind là script, timeout, aborted hoặc sandbox (types.ts:59–80). storeWrites chỉ có ở lần chạy thành công: script lỗi thì những gì nó store() bị bỏ.Ví dụ minh hoạ: một CodemodeResult thành công
{ ok: true,
value: "3 issue mở, 1 PR liên quan",
output: [ { type: "text", text: "đã quét 2 server", console: true } ],
calls: [ { name: "mcp__linear__list_issues", status: "ok", durationMs: 412 },
{ name: "mcp__github__search_pull_requests", status: "ok", durationMs: 655 } ],
storeWrites: { set: { lastScan: "2026-10-22" }, delete: [] } }
calls chỉ chứa lời gọi tool, không chứa global như searchTools() (types.ts:114–120).Đầu vào có định dạng riêng: JavaScript, có thể mở đầu bằng một dòng // @options: {"max_output_tokens": ..., "timeout_ms": ...}. Với provider hỗ trợ ràng buộc đầu vào bằng ngữ pháp, tool khai báo một ngữ pháp Lark chỉ cố định hình dạng dòng options, để mô hình viết script như văn bản thô thay vì một chuỗi JSON đầy ký tự thoát:
/** * Lark grammar for providers with grammar-constrained tool input. It only fixes the shape of the * options line; the options JSON and the code are checked by {@link parseCodemodeSource}. */export const CODEMODE_SOURCE_GRAMMAR = String.raw`start: options_source | plain_sourceoptions_source: OPTIONS_LINE NEWLINE SOURCEplain_source: SOURCEOPTIONS_LINE: /[ \t]*\/\/ @options:[^\r\n]*/NEWLINE: /\r?\n/SOURCE: /[\s\S]+/`;constrainedSampling: { type: "grammar", variants: { openai_lark: ... } } (codemode/tool.ts:386–387). Dòng options phải là dòng đầu; nằm ở chỗ khác thì lời gọi thất bại trước khi script chạy, thay vì bị lờ đi (source.ts:101–115).Sandbox: worker, QuickJS và giao thức host#
Vấn đề. QuickJS chạy đồng bộ. Một script while (true) {} chạy trên luồng chính của pi sẽ chặn event loop: không bộ hẹn giờ nào chạy, không tín hiệu huỷ nào tới, giao diện đứng. Script còn có thể ăn hết bộ nhớ hay làm hỏng trạng thái dùng chung. Giải pháp của CodemodeSandbox: mỗi lần execute() có một worker thread và một VM QuickJS mới tinh, mất khoảng 20 ms kể cả tạo VM; VM chỉ có hai thứ được nhập vào là một lớp WASI (đồng hồ, số ngẫu nhiên, ghi stdout/stderr mà worker vứt đi) và một điểm gọi host duy nhất (codemode/README.md:358–368).
Phía host được viết một lần cho mọi nơi VM có thể chạy. Chỗ khác nhau duy nhất là một launcher:
/** Callbacks a launcher uses to report what its VM does. */export interface VmEvents { /** A message from the VM. Validated here, so launchers can pass through anything they receive. */ message(message: unknown): void; /** The VM failed or stopped outside the bridge, for example a worker exit or a remote error. */ failure(error: CodemodeError): void;}/** The host's handle on a running VM. */export interface VmChannel { post(message: HostToWorkerMessage): void; /** Requests VM shutdown. Called once; may resolve before a worker or remote VM actually exits. */ stop(): Promise<void>;}/** * Starts a VM for one execution. Rejections become `sandbox` errors with the rejection's message. * The VM may start reporting messages before the returned promise settles. */export type VmLauncher = (data: VmStartData, events: VmEvents) => VmChannel | Promise<VmChannel>;CodemodeSandbox), luồng gọi (InlineCodemodeSandbox), và một isolate khác qua RPC (RemoteCodemodeSandbox), xem mục runtime từ xa.Hai phía chỉ trao đổi các message sau, và mọi đối số, kết quả, giá trị đều đi qua dưới dạng chuỗi JSON: worker chuyển chuỗi vào và ra VM, không tự dựng giá trị có cấu trúc (protocol.ts:4–8).
export type WorkerToHostMessage = | { type: "call"; id: number; target: "tool" | "global"; name: string; args: string | undefined } | { type: "output"; item: CodemodeOutputItem } /** * `writes` is a JSON array of `[key, json]` for `store()` and `[key]` for deletions. Deterministic mode: `during` * is the result the VM was processing when the script finished (0: its first run) and the calls issued by then. */ | { type: "done"; ok: true; value: string | undefined; writes: string; during?: CodemodeDuring } | { type: "done"; ok: false; error: ScriptErrorJson; during?: CodemodeDuring } /** The VM failed outside the script's control, for example a wasm trap. */ | { type: "crash"; message: string } /** Deterministic mode, before the script runs: what the VM is, so a replay can refuse a different one. */ | { type: "started"; fingerprint: string } /** * Deterministic mode, after the script waits again: having processed result `seq` (0: the script's first run), it * has issued `issued` calls in total. */ | { type: "idle"; seq: number; issued: number };export type HostToWorkerMessage = /** * `payload` is the JSON result when `ok`, otherwise the error message. Deterministic mode: `seq` numbers the * results from 1 in the order the VM applies them, and the VM's clock reads `now` while it processes this one. */ { type: "result"; id: number; ok: boolean; payload: string | undefined; seq?: number; now?: number; /** Deterministic mode, `ok: false`: a JSON object whose properties the script's `Error` gets. */ properties?: string; };started, idle, seq, now và properties chỉ dùng ở chế độ tất định.Launcher của worker thread:
function workerLauncher(options: CodemodeSandboxOptions): VmLauncher { return async (data, events) => { let module: CodemodeWasmModule; try { module = await (options.wasm ?? loadQuickJSWasm()); } catch (error) { throw new Error(`Failed to load QuickJS: ${errorMessage(error)}`); } const interrupt = new SharedArrayBuffer(4); const workerData: WorkerData = { ...data, wasm: module, interrupt }; const releaseWorker = options.acquireWorker?.(); let worker: Worker; try { worker = new Worker(options.workerUrl ?? defaultWorkerUrl(), { workerData }); } catch (error) { releaseWorker?.(); throw new Error(`Failed to start worker: ${errorMessage(error)}`); } if (releaseWorker) worker.once("exit", releaseWorker); const stop = async (): Promise<void> => { Atomics.store(new Int32Array(interrupt), 0, 1); // A non-interruptible built-in can delay Bun's terminate() until it returns (#10748). // Do not hold up the result while that worker exits. void worker.terminate().catch(() => undefined); // terminate() refs the worker internally; unref it after requesting termination. worker.unref(); };terminate() của Bun không dừng được một luồng đang quay trong wasm; VM thăm dò cờ đó.Trong worker, startVm() tạo VM với giới hạn bộ nhớ, giới hạn stack và hàm ngắt, chạy prelude, rồi bọc script vào một hàm async. Cầu nối duy nhất từ prelude ra host chỉ nhận giá trị nguyên thuỷ:
try { // Called from the prelude with primitives only. const bridge = vm.newFunction("bridge", (kind, a, b, c) => { switch (kind.toString()) { case "call": case "global": issued++; post({ type: "call", id: a.toNumber(), target: kind.toString() === "call" ? "tool" : "global", name: b.toString(), args: c === undefined || c.isUndefined ? undefined : c.toString(), }); break; case "output": post({ type: "output", item: a.toString() === "image" ? { type: "image", data: b.toString(), mimeType: c.toString() } : a.toString() === "console" ? { type: "text", text: b.toString(), console: true } : { type: "text", text: b.toString() }, }); break; // The prefix shares the first line with the script so reported line numbers // match the script as written. let fn: JSValueHandle; try { fn = vm.evalCode(`(async (tools, console) => {${data.code}\n})`, "codemode.js"); } catch (error) { if (!(error instanceof JSException)) throw error; post({ type: "done", ok: false, error: describeException(error) }); return session; } vm.callFunction(run, api, fn).dispose(); fn.dispose(); drain(); if (clock) post({ type: "idle", seq: 0, issued });drain() chạy các promise job từng cái một và thăm dò cờ ngắt sau mỗi 50 job, để cả while (true) await null cũng dừng được (vm.ts:238–250).Phía host, mỗi message call chạy tool tương ứng và gửi kết quả lại:
private async handleCall(message: Extract<WorkerToHostMessage, { type: "call" }>): Promise<void> { const { id, name } = message; const isTool = message.target === "tool"; const record: CodemodeCall | undefined = isTool ? { name, status: "cancelled", durationMs: 0 } : undefined; if (record) this.calls.push(record); const pending: PendingCall = { record, startedAt: performance.now(), controller: new AbortController() }; this.pending.set(id, pending); let status: CodemodeCallStatus; let reply: HostToWorkerMessage; try { const tool = (isTool ? this.executors.tools : this.executors.globals).get(name); if (!tool) throw new Error(`Unknown ${isTool ? "tool" : "global"} "${name}"`); const args: unknown = message.args === undefined ? undefined : JSON.parse(message.args); const value = await tool.execute(args, { signal: pending.controller.signal }); reply = { type: "result", id, ok: true, payload: value === undefined ? undefined : JSON.stringify(value) }; status = "ok"; } catch (error) { reply = { type: "result", id, ok: false, payload: errorMessage(error) }; status = "error"; } // Already cancelled by finish(): the record keeps "cancelled" and the // VM is gone or going. if (!this.pending.delete(id)) return; if (record) { record.status = status; record.durationMs = performance.now() - pending.startedAt; } this.post(reply);}AbortController riêng, bị huỷ khi script kết thúc; bản ghi khởi đầu là cancelled và chỉ thành ok/error nếu tool trả về trước đó. Lỗi của tool thành một message lỗi; trong script nó là một Error cùng thông điệp.- Khởi tạoConstructor của
Executionđặt deadline (chỉ khitimeoutMshữu hạn) và lắng nghe tín hiệu huỷ, rồi xin launcher một kênh. Câu trả lời phát sinh trước khi kênh có được xếp hàng rồi gửi sau (execution.ts:206–258). - ChạyMessage
outputđược nối vào output,callchạy tool,donevàcrashkết thúc. Message không giải mã được, ví dụ vì script đã sửa built-in khiến prelude gửi dữ liệu hỏng, làm lần chạy thất bại với lỗisandbox“bridge broken” thay vì làm sập pi (:315–328). - Kết thúcSự kiện kết thúc đầu tiên thắng:
done,crash, launcher lỗi, deadline, hay huỷ.finish()huỷ mọi lời gọi tool đang chờ, yêu cầu kênh dừng, và resolve kết quả khi yêu cầu đó xong; worker có thể thoát muộn hơn (:410–448).
Prelude: những gì script nhìn thấy#
Script và prelude chạy chung một realm. Vấn đề đã xảy ra thật: một script gán Array.prototype.toJSON = ... làm hỏng JSON mà prelude gửi cho host, làm sập pi và để lời gọi tool treo mãi (CHANGELOG.md:241). Từ 1.0.4, việc đầu tiên prelude làm là đóng băng mọi built-in:
// Freeze every object reachable from the built-in globals, plus the intrinsics that are only// reachable from instances (iterator and generator prototypes, %TypedArray%).//// Freezing alone breaks ordinary code through the "override mistake": a data property that is// read-only on a prototype cannot be assigned on an instance either, so// this.name = "MyError" in an Error subclass would throw. Commonly overridden properties// become accessors whose setter defines an own property on the instance instead.(function lockdown() { for (const key of Reflect.ownKeys(globalThis)) { const descriptor = Object.getOwnPropertyDescriptor(globalThis, key); add(descriptor.value); add(descriptor.get); add(descriptor.set); if ("value" in descriptor && descriptor.configurable) { Object.defineProperty(globalThis, key, { writable: false, configurable: false }); } } while (queue.length > 0) { const object = queue.pop(); add(Object.getPrototypeOf(object)); const descriptors = Object.getOwnPropertyDescriptors(object); for (const key of Reflect.ownKeys(descriptors)) { const descriptor = descriptors[key]; if ("value" in descriptor) { add(descriptor.value); if ( descriptor.writable && descriptor.configurable && (object === Object.prototype || OVERRIDABLE.has(key)) ) { allowOverride(object, key, descriptor.value, descriptor.enumerable); } } else { add(descriptor.get); add(descriptor.set); } } Object.freeze(object); }this.name = "MyError" trong một lớp con của Error sẽ ném lỗi. Vì thế vài thuộc tính hay bị ghi đè được thay bằng accessor tự định nghĩa thuộc tính riêng trên instance (prelude-source.ts:71–92).Sau đó prelude cất cầu nối vào closure, để script không gọi thẳng được, và dựng tools trên nó:
function caller(kind, name, spread) { return (...args) => new Promise((resolve, reject) => { let json; try { json = serialize(spread ? args : args[0]); } catch (error) { reject(error); return; } const id = nextId++; pending.set(id, { resolve, reject }); bridge(kind, id, name, json); });}const tools = Object.create(null);const allTools = [];for (const { name, jsName, description } of parse(toolsJson)) { const fn = caller("call", name); // The first tool wins when two names normalize to the same identifier. if (!(jsName in tools)) { tools[jsName] = fn; allTools.push(Object.freeze({ name: jsName, description })); } if (!(name in tools)) tools[name] = fn;}Object.freeze(tools);Object.freeze(allTools);my-tool thành my_tool, identifier.ts) và tên gốc, dùng được qua tools["my-tool"].tools mà script nhận là một Proxy. Đọc một thành viên không tồn tại ném lỗi kèm gợi ý, thay vì trả undefined rồi hỏng ở chỗ khác với “not a function”:
const comparable = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");function guard(target, label, names, hint) { return new Proxy(target, { get(object, property, receiver) { if (typeof property !== "string" || property in object || property in Object.prototype || property === "then" || property === "toJSON") { return Reflect.get(object, property, receiver); } const wanted = comparable(property); const exact = names.filter((name) => comparable(name) === wanted); const close = exact.length > 0 ? exact : names.filter((name) => wanted && (comparable(name).includes(wanted) || wanted.includes(comparable(name)))); let message = label + "." + property + " does not exist."; if (close.length > 0) message += " Did you mean " + close.slice(0, 5).map((name) => label + "." + name).join(", ") + "?"; else if (names.length <= 20) message += " Available: " + names.join(", ") + "."; if (hint) message += " " + hint; message += ' Check for a member with "' + property + '" in ' + label + "."; throw new TypeErrorCtor(message); }, });}const toolsProxy = guard( tools, "tools", allTools.map((tool) => tool.name), "ALL_TOOLS lists every tool; searchTools(query) finds tools by topic.",);Ví dụ minh hoạ: script gọi tools.Bash(...) nhận TypeError: tools.Bash does not exist. Did you mean tools.bash? ALL_TOOLS lists every tool; searchTools(query) finds tools by topic. Check for a member with "Bash" in tools., vì comparable() bỏ qua hoa thường và mọi ký tự ngoài chữ cái và chữ số. Hệ quả là kiểm tra kiểu typeof tools.x cũng ném; phải dùng "x" in tools (CHANGELOG.md:350).
Output được giữ ở host tới khi script kết thúc, nên phải có trần. Từ 1.0.1, vượt trần làm script thất bại, kể cả khi script bắt lỗi đó:
let outputChars = 0;let outputItems = 0;// Past the output limits the script fails: done() reports the error, so catching it does not// resume output, and the host ends the script.function output(kind, data, mimeType) { if (finished) return; outputChars += data.length; outputItems++; if (outputChars > ${MAX_OUTPUT_CHARS} || outputItems > ${MAX_OUTPUT_ITEMS}) { const error = new RangeErrorCtor( "script output exceeded the limit of ${MAX_OUTPUT_CHARS} characters or ${MAX_OUTPUT_ITEMS} text(), image(), and console calls. " + "Print a summary instead, or write large data to a file with a tool.", ); done(false, describeError(error)); throw error; } bridge("output", kind, data, mimeType);}done(false, ...) trước khi ném nghĩa là script có catch lỗi cũng không ghi thêm output được: kết cục đã được báo cho host.Các global còn lại theo cùng tinh thần. store(key, value) và load(key) đồng bộ, làm việc trên một ảnh chụp JSON truyền vào lúc khởi động, tối đa 262.144 ký tự JSON mỗi giá trị và 1.048.576 ký tự cho tất cả (prelude-source.ts:286–326). image() chỉ nhận dữ liệu base64 hợp lệ có chữ ký PNG, JPEG, GIF hoặc WebP, và lấy kiểu MIME từ chữ ký thay vì từ kiểu khai báo, vì một ảnh hỏng được lưu vào session sẽ làm mọi request sau bị provider từ chối (:403–429). Cuối cùng, VM không có timer hay I/O, nên một script đang chờ mà không có lời gọi tool nào dở dang thì không bao giờ tiếp tục được; prelude phát hiện và kết thúc nó ngay:
stalled() { if (finished || pending.size > 0) return false; done( false, stringify({ name: "Error", message: "The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.", }), ); return true;},Sinh khai báo TypeScript cho tool#
Để viết đúng tools.x(args), mô hình cần biết args trông thế nào và lời gọi trả về gì. Tool khai báo bằng JSON Schema, nhưng mô hình đọc TypeScript tốt hơn nhiều so với một khối schema lồng nhau. declarations.ts dịch schema sang biểu thức kiểu TypeScript:
/** * Convert a JSON Schema to a TypeScript type expression: objects on one line (`{ a: string; b?: number; }`) with properties sorted by name, * or one property per line with `//` comments when a property has a description or constraints the * type cannot express (`// @integer @minimum 1 @default 20`); `Array<T>` for arrays. Local references * (`#/$defs/...`, `#/definitions/...`) resolve against `schema`; recursive and remote references * render as `unknown`. A result longer than `maxChars` is rendered again without the constraint tags, * and renders as `unknown` if it is still too long. */export function schemaToType(schema: CodemodeJsonSchema, options: { maxChars?: number } = {}): string { const render = (tags: boolean) => toType(schema, { root: schema, resolving: new Set(), expansions: 0, tags }); const type = render(true); if (options.maxChars === undefined || type.length <= options.maxChars) return type; const untagged = render(false); return untagged.length <= options.maxChars ? untagged : "unknown";}maxChars (mặc định 16.000 ký tự cho đầu vào, declarations.ts:10) được vẽ lại không có tag, rồi thành unknown nếu vẫn quá dài. Tham chiếu $ref cục bộ được mở tối đa 32 lần cho mỗi schema, tham chiếu đệ quy hay từ xa thành unknown.Ràng buộc mà kiểu TypeScript không diễn đạt được, như integer, cận trên dưới, độ dài, pattern, default, được viết thành tag trên dòng chú thích của thuộc tính (1.2.0, declarations.ts:249–301). Thuộc tính được sắp theo tên; có mô tả hay tag thì mỗi thuộc tính một dòng, không thì cả object nằm trên một dòng (:303–340). Mẫu khai báo mà mô hình đọc cho từng tool ghép mô tả với một câu declare:
/** * A tool's sample: the description followed by the tool's declaration. Used for tool * listings and `ALL_TOOLS` entries. */export function renderToolSample( tool: Pick<CodemodeTool, "name" | "description" | "inputSchema" | "outputSchema">, options: { inputMaxChars?: number } = {},): string { const declaration = `declare const tools: { ${renderToolSignature(tool, options)} };`; return `${tool.description?.trim() ?? ""}\n\ncodemode tool declaration:\n\`\`\`ts\n${declaration}\n\`\`\``;}Ví dụ minh hoạ: lần theo code với một schema đầu vào
{ "type": "object", "required": ["query"],
"properties": {
"query": { "type": "string", "description": "Search text" },
"limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 } } }
↓ schemaToType()
{
// @integer @minimum 1 @maximum 50 @default 10
limit?: number;
// Search text
query: string;
}
limit đứng trước query vì thuộc tính được sắp theo tên; @integer đứng đầu, rồi các tag theo thứ tự của TAG_KEYWORDS, @default sau cùng.Kiểu trả về đến từ outputSchema. Tool của coding agent không có output schema được gán một schema { type: "string" }, nên lời gọi trả về Promise<string> (codemode/tool.ts:141–156). Tool MCP luôn có output schema là CallToolResult mà script nhận, với output schema riêng của tool nằm ở structuredContent (mcp/tools.ts:155–174). Từ 1.2.0 kiểu đó được viết ra đầy đủ thay vì một kiểu generic CallToolResult<T> mà describeTool() và ALL_TOOLS hiện ra mà không định nghĩa (CHANGELOG.md:134).
Tất định, giới hạn và runtime từ xa#
Các giới hạn#
| Giới hạn | Giá trị | Nơi đặt |
|---|---|---|
| Bộ nhớ VM | 256 MB trong coding agent; vượt thì InternalError: out of memory trong script | execute.ts:51–56 |
| Output | 16 Mi ký tự (text và base64 ảnh), 100.000 item | prelude-source.ts:36–43 |
| Output đưa cho mô hình | 10.000 token ước lượng (4 ký tự/token), giữ đầu và cuối, bản đầy đủ ghi ra file tạm; đổi bằng max_output_tokens | execute.ts:444–472 |
store() | 262.144 ký tự JSON mỗi giá trị, 1.048.576 ký tự cho tất cả | prelude-source.ts:34–35 |
| Deadline | Sandbox mặc định 300 giây; coding agent không đặt deadline trừ khi script có timeout_ms | execution.ts:49, execute.ts:555 |
| Stack | Guard của QuickJS 512 KiB trong worker, 256 KiB trên luồng gọi | vm.ts:53–58, inline.ts:41–42 |
| Worker đồng thời | 2 cho cả tiến trình pi (PI_CODEMODE_MAX_WORKERS), chờ tối đa 60 giây | execute.ts:57–69 |
Lời gọi models.* đồng thời | 4 mỗi script; số còn lại xếp hàng | execute.ts:49–50 |
Lần chạy tất định#
execute() tự chạy tool. start() (1.2.0) thì khác: mọi lời gọi đi tới host qua onCall, và host trả lời bằng run.deliver(seq, now, id, outcome). Mục đích là phát lại: cùng script, cùng thời điểm bắt đầu, cùng seed, được trả lời bằng cùng kết quả theo cùng thứ tự, thì gọi đúng những tool đó với đúng những đối số đó và cho cùng output, trên mọi VM có cùng fingerprint (codemode/README.md:314–343). VM vốn đã gần như tất định, vì nó chỉ chạy khi host đưa thứ gì đó vào và không có timer; phần còn lại được chốt khi tạo VM:
export async function startVm(options: StartVmOptions): Promise<VmSession> { const { data, post } = options; const maxStackSize = options.maxStackSize ?? MAX_STACK_SIZE; const deterministic = data.deterministic; const clock: VirtualClock | undefined = deterministic && { creating: true, startedAt: deterministic.startedAt, now: deterministic.startedAt, seed: deterministic.seed >>> 0, }; const vm = await QuickJS.create({ wasm: options.wasm, memoryLimit: data.memoryLimitBytes, // Without a guard, deep recursion overflows the wasm stack and traps instead of throwing a // catchable RangeError. maxStackSize, interruptHandler: options.shouldInterrupt, wasi: clock ? deterministicWasi(clock) : discardOutput, ...(clock ? { timezoneOffset: 0, intrinsics: Intrinsics.ALL & ~Intrinsics.WEAK_REF } : {}), }); if (clock) clock.creating = false;random_get lấy từ một PRNG mulberry32 trên seed (vm.ts:102–125). Garbage collection không tất định, nên WeakRef và FinalizationRegistry bị bỏ khỏi môi trường.const session: VmSession = { deliver(message) { if (disposed) return; try { if (!clock) { apply(message); return; } const { seq } = message; if (seq === undefined || !Number.isInteger(seq) || seq < nextSeq || early.has(seq)) { throw new Error(`Result ${message.id} has sequence number ${seq}, expected ${nextSeq} or later, once`); } early.set(seq, message); for (let next = early.get(nextSeq); next !== undefined; next = early.get(nextSeq)) { early.delete(nextSeq); // Never backwards: a resumed host's clock may be behind the one that started the run. if (next.now !== undefined && Number.isFinite(next.now)) clock.now = Math.max(clock.now, next.now); current = nextSeq; apply(next); post({ type: "idle", seq: nextSeq, issued }); nextSeq++; } } catch (error) { post(crashMessage(error)); } },idle với số lời gọi đã phát, để host đối chiếu lần phát lại từng bước.Trước khi script chạy, VM báo một fingerprint gồm phiên bản quickjs-wasi và QuickJS, hash FNV-1a của prelude, phiên bản chế độ tất định, stack, heap và ngân sách ngắt (vm.ts:314–321), để một host phát lại có thể từ chối một VM khác. Coding agent không dùng chế độ này; Pi Durable dùng nó cho codemode bền (chương Durable: task, scheduler, generation và tool).
Ngoài Node: luồng gọi, isolate khác, Cloudflare#
Cloudflare Workers và Durable Object không có worker thread. InlineCodemodeSandbox chạy VM ngay trên luồng gọi, và vì trong lúc script tính toán thì không timer nào chạy được (trên Workers cả Date.now() cũng đứng yên), nó giới hạn bằng ngân sách ngắt: đếm số lần QuickJS thăm dò hàm ngắt, quá 100.000 lần thì dừng với lỗi timeout. Ghi chú trong code đo được khoảng 50.000 lần mỗi giây CPU trên laptop Apple M và khoảng 10.000 trên Durable Object, tức chừng 2 tới 10 giây tính toán liên tục (inline.ts:5–25).
Chạy trên luồng gọi vẫn chặn luồng đó. Trong một Durable Object phục vụ một session agent, điều này nghĩa là chặn mọi request khác của session, kể cả lệnh huỷ của người dùng. RemoteCodemodeSandbox giữ vai host ở bên gọi và chỉ đẩy VM sang nơi khác:
* Problem: a VM on the calling thread blocks that thread while the script computes. In a Durable* Object that serves an agent session, a CPU-heavy script would stall every other request to the* session (including the user's abort) for seconds. Measured on Cloudflare (October 2026), a* script that computed for 10 seconds delayed the session's other requests by 10 seconds when it* ran in the session object or in a Dynamic Worker, but by about 50 ms when it ran in a Durable* Object of a separately deployed Worker.** Solution: the caller keeps the host role (`RemoteCodemodeSandbox`: tools, call records,* deadline, abort) and the remote side only runs the VM (`serveCodemodeRemote`, behind* `CodemodeSandboxDurableObject` in `@earendil-works/pi-codemode/cloudflare`).** Protocol, one RPC call per execution:** client remote* remote.execute(request, exchange) ───────▶ starts the VM (in-thread, with interrupt budget)* ◀─────── exchange([...outputs, call]) one call per tool call* runs the tool, replies with result ──────▶ VM continues* ◀─────── exchange([...outputs, done]) terminal, reply ignored* execute() resolves ◀────────────────────── VM disposedexchange mang tối đa một lời gọi, nên các lời gọi tool đồng thời là các RPC đồng thời; output được gom ở phía từ xa và gửi kèm lời gọi kế tiếp, để một script in trong vòng lặp không sinh một RPC mỗi dòng (remote.ts:25–29)./** * The remote end of `RemoteCodemodeSandbox`: a Durable Object whose one RPC method runs a script's * VM and calls back into the client for every tool call. * * Deploy it as its own Worker and bind it from the Worker that runs your agent with `script_name`. * A separate Worker is what keeps scripts off the agent's thread: a class in the same Worker can * share an isolate, and therefore a thread, with the objects that call it. * * The object keeps no state between executions and never touches storage. Executions that reach * the same instance share its thread: they interleave while they wait on tools, and a script that * computes delays the others. Name instances per session (`getByName(sessionId)`) to keep one * session's scripts together, or per execution for full independence. * * Limits apply to every request, whatever the client asks for. Override the fields in a subclass: * * export class CodemodeSandbox extends CodemodeSandboxDurableObject { * protected maxInterruptBudget = 200_000; * } */export class CodemodeSandboxDurableObject<Env = unknown> extends DurableObject<Env> { /** Interrupt polls per execution; the client may only ask for less. See `InlineCodemodeSandboxOptions`. */ protected maxInterruptBudget: number = DEFAULT_INTERRUPT_BUDGET; /** VM memory per execution; the client may only ask for less. */ protected maxMemoryLimitBytes: number = DEFAULT_REMOTE_MEMORY_LIMIT_BYTES; /** Called by `RemoteCodemodeSandbox` over RPC. Resolves when the script has settled or was stopped. */ execute(request: CodemodeRemoteRequest, exchange: CodemodeRemoteExchange): Promise<void> { return serveCodemodeRemote(request, exchange, { wasm: quickjsWasm, maxInterruptBudget: this.maxInterruptBudget, maxMemoryLimitBytes: this.maxMemoryLimitBytes, }); }}Tool codemode của coding agent#
Built-in extension codemode đăng ký một tool tắt sẵn (defaultActive: false); người dùng bật nó bằng "defaultTools": ["+codemode"] hay --tools +codemode, hoặc extension MCP bật khi có server dùng exposure codemode (codemode/index.ts:32–46). Định nghĩa tool cho thấy nó chỉ dùng các API chung của extension:
export function createCodemodeToolDefinition( options: CodemodeToolOptions = {},): ToolDefinition<typeof codemodeSchema, CodemodeToolDetails | undefined> { return { name: CODEMODE_TOOL_NAME, label: CODEMODE_TOOL_NAME, // Replaced with the declarations of the callable tools when the tool is activated. description: createCodemodeDescription([], { models: options.models === true }), promptSnippet: codemodeToolSystemPromptContribution.snippet, promptGuidelines: [...codemodeToolSystemPromptContribution.guidelines], parameters: codemodeSchema, // Scripts must not start other scripts. exposure: "model-only", prepareLoadout: (loadout) => prepareCodemodeLoadout(loadout, options), reachesTools: (input) => getScriptReach(input.code), // Capable models write the script as raw text instead of a JSON-escaped string. constrainedSampling: { type: "grammar", variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR } }, // The sandbox (worker, QuickJS wasm) loads on the first call, not at startup. execute: async (toolCallId, params, signal, onUpdate, ctx) => (await loadCodemodeExecutor()).executeCodemode(toolCallId, params, signal, onUpdate, ctx, options), ...codemodeRenderers, };}execute.lazy.ts là một import() động), nên bật codemode không làm pi khởi động chậm hơn. Tài liệu extension nhấn mạnh: codemode chỉ dùng hook prepareLoadout, exposure, reachesTools và ctx.executeTool(), nên một extension khác có thể cài lại cùng hành vi dưới tên khác (docs/extensions.md:293).Phần đầu mô tả là cố định: luật của script và các global, mỗi global một dòng. Chi tiết của API models không nằm ở đây mà ở docs/codemode.md, và mô tả bảo mô hình đọc file đó khi cần:
const DESCRIPTION_INTRO = `Run JavaScript that calls other tools. The input is raw JavaScript (not JSON, no code fence), run as an async function body in a QuickJS sandbox: top-level \`await\` and \`return\` work. No Node, file system, network, or timers.- \`await tools.<name>({ ...args })\` resolves to a string, or an object if the tool's declaration says so, and rejects with an Error on failure. A tool whose declaration returns an object resolves on failure too when it returned that object; check its fields. Calls still running when the script ends are cancelled.- Optional first line: \`// @options: {"max_output_tokens": 10000, "timeout_ms": 60000}\``;/** One line per global. The details live in {@link CODEMODE_DOCS_PATH}. */function describeGlobals(models: boolean): string { const lines = [ "Globals:", "- `text(value)`, `image(dataUrlOrImageBlock)`, `console.log(...)`, and top-level `return` add output; `exit()` ends the script. With several text items, each starts with a `==> text N/M <==` line, and `console` lines follow the other output in one `<console_output>` block. `image()` also saves the image to a temp file and the result names its path.", "- `store(key, value)` and `load(key)` keep JSON values across codemode calls.", "- `ALL_TOOLS`, `await searchTools(query, { limit?, namespace? })`, `await describeTool(name)`, `await describeNamespace(name)`: find unlisted tools, such as MCP tools.", ]; if (models) { lines.push(`- \`models\`: classifiers and image generation. Read ${CODEMODE_DOCS_PATH} first.`); } return lines.join("\n");}Cách viết này là kết quả của đợt cắt giảm ở 1.0.0: với bộ tool mặc định và codemode bật, một request GPT-5.6 giảm từ khoảng 5.300 xuống 3.300 token, vì mô tả không còn nhắc lại toàn bộ khai báo và tài liệu models chuyển ra file (CHANGELOG.md:349).
Phần còn lại của mô tả, danh sách “Nested tools”, do prepareLoadout dựng mỗi khi bộ tool đổi. Setting codemode.mode chọn một trong hai cách trình bày:
/** * How the codemode tool presents tools that are both declared and callable from scripts: * - `on`: their descriptions say how scripts call them, and the codemode description * lists only the callable tools without `direct` exposure. * - `only`: the codemode description lists every callable tool, and requests leave out the * declarations of active `direct` tools. * * Listed tools carry their prompt guidelines, which the system prompt only has for declared tools. * * Listing by exposure, not by the active set, keeps the codemode description unchanged when * `tool_search` loads a tool, so loads do not redeclare codemode. */function prepareCodemodeLoadout(loadout: ToolLoadout, options: CodemodeToolOptions): ToolLoadoutChanges { const mode = options.getMode?.() ?? SETTINGS_DEFAULTS.codemode.mode; const isDirect = (tool: AgentTool) => loadout.getExposure(tool.name) === "direct"; const callable = getCodemodeCallableTools(loadout.callable); const callableNames = new Set(callable.map((tool) => tool.name)); const descriptions: Record<string, string> = {}; if (mode === "on") { for (const tool of loadout.declared) { if (callableNames.has(tool.name)) descriptions[tool.name] = describeScriptCall(tool); } } const listed = mode === "only" ? callable : callable.filter((tool) => !isDirect(tool)); const namespaces = new Map( listed.flatMap((tool) => { const namespace = loadout.getNamespace(tool.name); return namespace ? [[tool.name, namespace] as const] : []; }), ); const guidelines = new Map(listed.map((tool) => [tool.name, loadout.getPromptGuidelines(tool.name)] as const)); descriptions[CODEMODE_TOOL_NAME] = createCodemodeDescription(listed, { models: options.models === true, namespaces, guidelines, deferred: new Set( listed.filter((tool) => loadout.getExposure(tool.name) === "deferred").map((tool) => tool.name), ), inlineBudget: options.getInlineBudget?.() ?? DEFAULT_CODEMODE_INLINE_BUDGET, }); const declaredNames = new Set(loadout.declared.map((tool) => tool.name)); return { descriptions, hiddenDeclarations: mode === "only" ? callable.filter((tool) => isDirect(tool) && declaredNames.has(tool.name)).map((tool) => tool.name) : [], };}on (mặc định): tool đang khai báo giữ nguyên khai báo, mô tả của chúng thêm một dòng cách gọi từ script; codemode liệt kê các tool gọi được mà không phải direct. only: codemode liệt kê mọi tool gọi được, và khai báo của tool direct bị giấu khỏi request, nên mô hình chỉ gọi chúng qua script.Tool deferred không bao giờ được liệt kê, kể cả khi còn ngân sách; chúng tìm được bằng searchTools(). Vì tool MCP mặc định là deferred, ở cấu hình mặc định (mode: "on", chỉ có read, bash, edit, write và tool MCP) mô tả codemode không có mục “Nested tools” nào, và không đổi một byte khi server MCP kết nối. Mô hình biết cách gọi các tool chính từ dòng thêm vào mô tả của chúng:
Ví dụ minh hoạ: dòng describeScriptCall() thêm vào cuối mô tả của hai tool
bash: ... Codemode: `tools.bash(args)` resolves to `{ output, truncated, full_output_path?, exit_code, wall_time_seconds }`.
edit: ... Codemode: `tools.edit(args)` resolves to a string.
bash có output schema (bash.ts:56–62) nên lời gọi trả object; edit không có nên trả chuỗi (codemode/tool.ts:289–306).Khi có tool được liệt kê (chế độ only, hoặc tool có exposure codemode của extension), chúng chia nhau một ngân sách khoảng 3.000 token ước lượng (codemode.inlineBudget, settings-defaults.ts:57–60). Cách chọn bảo đảm mọi namespace đều có mặt trước khi namespace nào được liệt kê đủ:
/** * Pick the tool sections that fit the budget, like OpenCode's catalog: in each round every group * (tools without a namespace first, then namespaces by name) places its cheapest remaining tool; a * group whose next tool does not fit drops out while the others continue. Every namespace is * represented before any namespace is complete. */function selectCatalog(groups: readonly CatalogGroup[], budget: number | undefined): Set<string> { if (budget === undefined) return new Set(groups.flatMap((group) => group.entries.map((entry) => entry.name))); const queues = groups.map((group) => [...group.entries].sort((a, b) => a.cost - b.cost)); const shown = new Set<string>(); let remaining = budget; let active = queues.filter((queue) => queue.length > 0); while (active.length > 0) { active = active.filter((queue) => { const next = queue[0]; if (next.cost > remaining) return false; remaining -= next.cost; shown.add(next.name); queue.shift(); return queue.length > 0; }); } return shown;}Hai chi tiết cuối của định nghĩa. reachesTools trả "all" nếu script dùng một global khám phá (searchTools, describeNamespace, describeTool, ALL_TOOLS), còn không thì trả các từ trong mã nguồn, nên tools.mcp__docs__search và tools["mcp__docs__" + name] đều “chạm” namespace mcp__docs (codemode/tool.ts:358–369). Và system prompt nhận một dòng hướng dẫn: dùng codemode để gộp các lời gọi độc lập bằng Promise.allSettled, nối chúng, hay lọc output lớn (codemode/tool.ts:108–113).
Chạy một script#
executeCodemode() là cầu nối giữa sandbox không biết gì về pi và session của pi. Việc đầu tiên là biến mỗi tool gọi được (ctx.tools, trừ chính codemode) thành một CodemodeTool mà execute đi qua ctx.executeTool():
const callable = ctx ? getCodemodeCallableTools(ctx.tools) : [];// ALL_TOOLS entries carry the declaration.const guidelines = options.getToolGuidelines?.();const samples = new Map( callable.map((tool) => [tool.name, renderToolSample(toCodemodeDeclaration(tool, guidelines?.get(tool.name)))]),);const sandboxTools: CodemodeTool[] = callable.map((tool) => ({ name: tool.name, description: samples.get(tool.name), execute: async (args, { signal: callSignal }) => { const record: CodemodeNestedCall = { id: `${toolCallId}/?`, name: tool.name, args: previewArgs(args), status: "running", }; calls.push(record); publish(); const callStartedAt = performance.now(); // Only tools from ctx.tools are callable, so ctx is set here. if (!ctx) throw new Error("Tool calls need a session"); const outcome = await ctx.executeTool(tool.name, args, { signal: callSignal }); record.id = outcome.toolCall.id; record.durationMs = performance.now() - callStartedAt; if (outcome.isError) { record.status = callSignal.aborted ? "cancelled" : "error"; record.error = truncateText(textOf(outcome.result) || `Tool "${tool.name}" failed`, ERROR_PREVIEW_CHARS); } else { record.status = "ok"; } publish(); return toScriptValue(tool, outcome); },}));description của mỗi tool là mẫu khai báo đầy đủ; nó thành mục ALL_TOOLS và kết quả của describeTool(). Mỗi lời gọi được ghi một dòng CodemodeNestedCall để giao diện hiện trạng thái trong lúc chạy.Giá trị mà script nhận theo một luật đơn giản, ghi ở đầu tool.ts: tool có outputSchema trả structuredContent, kể cả khi kết quả là lỗi nhưng vẫn mang nó; tool khác trả text; một lời gọi lỗi không có structuredContent để trả thì reject:
/** * The value a script receives for a nested call: a tool that declares * `outputSchema` resolves to its `structuredContent`, also for error results that carry one (such * as MCP results with `isError`); any other tool resolves to its text content. Other failures * reject with the tool's error text. */function toScriptValue(tool: AgentTool<any>, outcome: AgentToolCallOutcome): unknown { const { result } = outcome; if (tool.outputSchema && result.structuredContent !== undefined) return result.structuredContent; const text = textOf(result); if (outcome.isError) throw new Error(text || `Tool "${tool.name}" failed`); return text;}Rồi tới phần xin worker và tạo sandbox:
// The wait for a worker counts toward the script's deadline.const deadline = sourceOptions.timeoutMs ?? Number.POSITIVE_INFINITY;const waitStartedAt = performance.now();const admission = await reserveCodemodeWorker(signal, Math.min(deadline, CODEMODE_WORKER_WAIT_MS));const waitedMs = performance.now() - waitStartedAt;let reserved = true;let sandbox: CodemodeSandbox | undefined;let result: CodemodeResult;try { sandbox = new CodemodeSandbox({ tools: sandboxTools, globals: [ ...createDiscoveryGlobals(callable, samples, options), ...(options.models && ctx ? createModelGlobals(ctx.modelRegistry, toolCallId, calls, publish, addModelUsage, addGeneratedImages) : []), ], timeoutMs: Math.max(1, Math.round(deadline - waitedMs)), memoryLimitBytes: CODEMODE_MEMORY_LIMIT_BYTES, wasm: loadQuickJSWasm(getQuickJSWasmPath()), workerUrl: getCodemodeWorkerSpecifier(), acquireWorker: () => { if (!reserved) throw new Error("The script already settled"); // From here the sandbox releases the slot when the worker actually exits. reserved = false; return releaseCodemodeWorker; }, }); const store = ctx ? readCodemodeStore(ctx.sessionManager.getBranch()) : {}; result = await sandbox.execute(code, { signal, store });} finally { await sandbox?.close(); if (reserved) { reserved = false; releaseCodemodeWorker(); }}acquireWorker chuyển việc trả chỗ cho sandbox: chỗ chỉ được trả khi worker thật sự thoát, vì trên Bun một worker bị huỷ có thể còn chạy thêm (1.3.0). Hàng đợi chờ theo thứ tự đến, tối đa 60 giây (execute.ts:74–111).store truyền vào là kết quả phát lại các entry codemode-store trên nhánh hiện tại của session:
/** Values of `load()`: the `codemode-store` entries on the branch, applied from the root. */export function readCodemodeStore(branch: readonly SessionEntry[]): Record<string, unknown> { const store = new Map<string, unknown>(); for (const entry of branch) { if (entry.type !== "custom" || entry.customType !== CODEMODE_STORE_ENTRY_TYPE || !isStoreEntryData(entry.data)) { continue; } for (const key of entry.data.delete) store.delete(key); for (const [key, value] of Object.entries(entry.data.set)) store.set(key, value); } return Object.fromEntries(store);}/tree thì mỗi nhánh thấy các giá trị được ghi trên đường của nó.Cuối cùng, kết quả được trình bày cho mô hình:
// Calls still marked running were cut off by the script ending, a timeout, or an abort.for (const call of calls) { if (call.status === "running") call.status = "cancelled";}const scriptOutput = [...result.output];if (result.ok) { const { set, delete: deleted } = result.storeWrites; if (Object.keys(set).length > 0 || deleted.length > 0) { options.appendEntry?.(CODEMODE_STORE_ENTRY_TYPE, { set, delete: deleted }); } // pi extension: a returned value is appended like text(). if (result.value !== undefined) scriptOutput.push({ type: "text", text: valueText(result.value) });}const items = formatOutput(scriptOutput);if (!result.ok) items.push({ type: "text", text: `Script error:\n${formatError(result, calls)}` });const truncated = await truncateOutput( joinAdjacentText(items), sourceOptions.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,);// After truncation, which joins the text items and moves images after them, so each path stays// next to its image and is never cut.const output = joinAdjacentText(await saveImages(truncated.items));const wallTime = ((performance.now() - startedAt) / 1000).toFixed(1);const header = `${result.ok ? "Script completed" : "Script failed"}\nWall time ${wallTime} seconds\nOutput:\n`;const details = snapshot();if (truncated.fullOutputPath) details.fullOutputPath = truncated.fullOutputPath;return { content: [{ type: "text", text: header }, ...output], details, ...(modelUsage ? { usage: modelUsage } : {}), ...(result.ok ? {} : { isError: true }),};return được nối như một text(). Có nhiều item text thì mỗi item mở đầu bằng dòng ==> text N/M <==, và mọi dòng console gom vào một khối <console_output> ở cuối (execute.ts:316–341). Mỗi ảnh được lưu thêm vào một file tạm và tên file đứng trước ảnh, vì script không ghi file được và mô hình không có cách nào khác chạm tới bytes của ảnh (:409–442).Script lỗi vẫn trả output đến lúc lỗi, theo sau là Script error:, lỗi, và danh sách lời gọi đã chạy kèm câu “they are not undone” (execute.ts:374–390).
Các global models.* được thêm khi extension tạo tool với models: true. Lời gọi classify() và generateImages() chỉ dùng provider và id của mô hình mà script đưa vào, rồi tra lại catalog: một baseUrl hay headers do script cung cấp không bao giờ được nhận credential (execute.ts:753–790), và mục catalog trả cho script bị bỏ headers vì header trong models.json có thể chứa credential (:145–150). Đối số sai hình dạng bị từ chối với hình dạng mong đợi, để mô hình tự sửa (:172–235).
Lời gọi tool lồng nhau#
Vấn đề. Script gọi tools.bash({...}). Lời gọi đó phải được đối xử như một lời gọi thật: kiểm tra đối số theo schema, chạy qua hook tool_call mà một extension phân quyền có thể dùng để chặn, có sự kiện cho giao diện, có chi phí được tính. Nhưng nó không được vào transcript, vì cả mục đích của codemode là để mô hình không thấy các kết quả trung gian. Tool lấy cả hai qua ctx.executeTool():
/** * Run a call that the tool call `parentToolCallId` made through `ctx.executeTool()`. It goes * through the agent's tool pipeline with the session's hooks, against the callable tools. */private async _executeNestedToolCall( parentToolCallId: string, name: string, args: unknown, options: ExecuteToolOptions,): Promise<AgentToolCallOutcome> { this._nestedToolCalls ??= new NestedToolCallRunner({ getTools: () => this._getCallableTools(), isSequential: () => this.agent.toolExecution === "sequential", runToolCall: (toolCall, parentId, signal, onUpdate) => { const assistantMessage = this._findLastAssistantMessage(); if (!assistantMessage) { return Promise.resolve({ toolCall, result: { content: [{ type: "text", text: "No assistant message issued this call" }], details: {} }, isError: true, }); } return runToolCall(toolCall, { tools: this._getCallableTools(), assistantMessage, context: { messages: this.agent.state.messages, tools: this.agent.state.tools }, beforeToolCall: (context) => this._beforeToolCall(context, parentId), afterToolCall: (context) => this._afterToolCall(context, parentId), signal, onUpdate, }); }, emit: async (event) => { await this._extensionRunner.emit(event); this._emit(event); }, }); return this._nestedToolCalls.execute(parentToolCallId, name, args, options);}runToolCall() của pi-agent-core chạy đúng các bước của một lời gọi do mô hình phát ra, gồm chuẩn bị đối số, kiểm tra schema, beforeToolCall, chạy và afterToolCall, nhưng không phát sự kiện và không thêm message (agent-loop.ts:812–829). Tool được tra trong bộ gọi được, không phải bộ đang khai báo.NestedToolCallRunner đặt id, giữ thứ tự khi cần, và ghi lại:
let scope = this.scopes.get(callerId);if (!scope) { scope = { recorder: new NestedCallRecorder(), nextId: 1, holdsQueue: false }; this.scopes.set(callerId, scope);}const toolCall: AgentToolCall = { type: "toolCall", id: `${callerId}/${scope.nextId++}`, name, arguments: (args ?? {}) as AgentToolCall["arguments"],};const record = scope.recorder.start(toolCall);await this.host.emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: name, args: toolCall.arguments, parentToolCallId: callerId,});const exclusive = !scope.holdsQueue && (this.host.isSequential() || this.host.getTools().find((tool) => tool.name === name)?.executionMode === "sequential");let release: (() => void) | undefined;if (exclusive) { const previous = this.queueTail; this.queueTail = new Promise((resolve) => { release = resolve; }); await previous;}this.scopes.set(toolCall.id, { recorder: scope.recorder, nextId: 1, holdsQueue: scope.holdsQueue || exclusive,});let outcome: AgentToolCallOutcome;try { outcome = await this.host.runToolCall(toolCall, callerId, options.signal, async (partialResult) => {scope.recorder.finish(record, outcome.isError, textOf(outcome.result));// Nested results are not persisted, so their usage is only counted through the recorder.if (outcome.result.usage) scope.recorder.addUsage(outcome.result.usage);await this.host.emit({ type: "tool_execution_end", toolCallId: toolCall.id, toolName: name, result: outcome.result, isError: outcome.isError, ...(outcome.durationMs === undefined ? {} : { durationMs: outcome.durationMs }), parentToolCallId: callerId,});return outcome;<id của lời gọi codemode>/<n>. Khi agent chạy tool tuần tự, hoặc tool đích khai báo executionMode: "sequential", các lời gọi lồng nhau xếp vào một hàng đợi độc quyền; một lời gọi đang giữ hàng đợi thì các lời gọi con của nó không phải chờ chính nó (dòng 214–218).Bản ghi có giới hạn, vì một script có thể phát hàng nghìn lời gọi:
/** * Limits of the nested-call record on a tool result: arguments * over the per-call or total size are omitted, calls beyond the count are dropped, and the record * is marked incomplete when any of that happens. */export const NESTED_CALL_LIMITS = { maxCalls: 256, maxArgumentBytesPerCall: 8 * 1024, maxArgumentBytesTotal: 32 * 1024, maxErrorChars: 500,} as const;Khi kết quả của lời gọi codemode bắt đầu được phát, AgentSession lấy bản ghi ra và gắn vào chính message đó, cộng cả usage của các lời gọi lồng nhau (chúng không được lưu riêng, nên đây là nơi duy nhất chi phí của chúng được tính):
// Record the calls a tool made through ctx.executeTool() and their usage on its result message.if (this._nestedToolCalls) { if (event.type === "message_start" && event.message.role === "toolResult") { const message = event.message; const summary = this._nestedToolCalls.takeRecord(message.toolCallId); if (summary?.calls) message.nestedCalls = summary.calls; if (summary?.usage) { message.usage = message.usage ? combineUsage(message.usage, summary.usage) : summary.usage; } } else if (event.type === "agent_end") { this._nestedToolCalls.clear(); }}/** A tool call that another tool made while it ran, for example from a codemode script. */export interface NestedToolCallRecord { id: string; name: string; /** Omitted when over the size limits; `argumentsBytes` then gives their size. */ arguments?: JsonObject; /** UTF-8 size of the arguments as JSON, set when `arguments` is omitted. */ argumentsBytes?: number; /** `unfinished`: the call was still running when the calling tool finished. */ status: "ok" | "error" | "unfinished"; durationMs?: number; /** Error text, truncated. */ error?: string;}/** Bounded record of the nested calls a tool made. Results are not recorded. */export interface NestedToolCalls { calls: NestedToolCallRecord[]; /** False when calls were dropped, arguments omitted, or calls had not finished. */ complete: boolean;}ToolResultMessage, nestedCalls được ghi chú là “Kept for the session record; not sent to the model” (ai/types.ts:642–643). Kết quả của lời gọi lồng nhau không được ghi lại, chỉ có tên, đối số, trạng thái và thời gian.Sự kiện tool_call, tool_result và tool_execution_* của lời gọi lồng nhau mang parentToolCallId (nested-tool-calls.ts:110–129). Đó là cách một extension phân quyền phân biệt lời gọi của script với lời gọi của mô hình; tài liệu MCP nói rõ mọi lời gọi MCP, kể cả từ script, đều đi qua pipeline này (docs/mcp.md:250–252). Giao diện thì không vẽ chúng thành dòng tool riêng; renderer của codemode liệt kê chúng ngay trong dòng của nó, với biểu tượng trạng thái, thời gian và chi phí (renderer.ts:1–8).
pi-mcp: client, JSON-RPC và transport#
@earendil-works/pi-mcp là một client MCP nhỏ, độc lập, không phụ thuộc SDK MCP chính thức hay gói pi nào (mcp/README.md:3). Nó nói phiên bản giao thức 2025-11-25 và chấp nhận server đàm phán xuống 2025-06-18, 2025-03-26 hoặc 2024-11-05 (protocol/types.ts:4–10); batch JSON-RPC, HTTP+SSE kiểu cũ, sampling và tasks nằm ngoài phạm vi (README.md:132). Việc được chia đôi: transport lo đóng khung và I/O, client lo tương quan request, khởi tạo, timeout, huỷ và request từ server:
export interface McpTransportSendOptions { /** * Aborted once the sender no longer waits for a reply (the request was cancelled, timed out, answered, * or the client closed). A transport must not dispatch the message after that, for example when * retrying it after an authorization refresh. It does not undo a message already dispatched. */ signal?: AbortSignal;}export interface McpTransport { start(): Promise<void>; send(message: JsonRpcMessage, options?: McpTransportSendOptions): Promise<void>; close(): Promise<void>; onMessage(listener: McpTransportMessageListener): () => void; onError(listener: McpTransportErrorListener): () => void; onClose(listener: McpTransportCloseListener): () => void; setProtocolVersion?(version: string): void;}signal trong send() (1.2.0) bị huỷ khi người gửi không còn chờ trả lời; transport không được gửi message sau đó, kể cả khi đang thử lại sau một lần làm mới uỷ quyền. Trước đó, một lời gọi tool đã bị huỷ hay hết giờ vẫn có thể tới server sau khi token được làm mới (mcp/CHANGELOG.md:16).async connect(transport: McpTransport): Promise<InitializeResult> { if (this.state !== "idle") throw new Error(`Cannot connect MCP client in ${this.state} state`); this.state = "connecting"; this.transport = transport; this.disposers = [ transport.onMessage((message) => this.handleMessage(message)), // Transport errors are reported only. Pending requests fail when the transport closes. transport.onError((error) => this.emitError(error)), transport.onClose(() => this.handleTransportClose()), ]; try { await transport.start(); const capabilities: ClientCapabilities = { ...this.options.capabilities }; if (this.options.roots && capabilities.roots === undefined) capabilities.roots = {}; const result = validateInitializeResult( await this.requestInternal( "initialize", { protocolVersion: this.options.protocolVersion ?? LATEST_PROTOCOL_VERSION, capabilities, clientInfo: { name: this.options.name, version: this.options.version, ...(this.options.title === undefined ? {} : { title: this.options.title }), }, }, {}, true, ), ); if (!(SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(result.protocolVersion)) { throw new Error(`MCP server selected unsupported protocol version ${result.protocolVersion}`); } this.protocolVersionValue = result.protocolVersion; this.serverInfoValue = result.serverInfo; this.serverCapabilitiesValue = result.capabilities; this.instructionsValue = result.instructions; transport.setProtocolVersion?.(result.protocolVersion); await this.notifyInternal("notifications/initialized", undefined, true); this.state = "connected"; return result; } catch (error) { await this.close().catch(() => {}); throw error; }}instructions trong kết quả initialize là hướng dẫn của server cho cả nhóm tool; extension MCP dùng dòng đầu của nó làm mô tả server khi cấu hình không có description.const id = this.nextRequestId++;const progressToken = options.onProgress ? id : undefined;const requestParams = progressToken === undefined ? params : { ...params, _meta: { ...(isObject(params?._meta) ? params._meta : {}), progressToken } };const message: JsonRpcRequest = { jsonrpc: "2.0", id, method, ...(requestParams === undefined ? {} : { params: requestParams }),};return new Promise<unknown>((resolve, reject) => { const entry: PendingRequest = { resolve, reject, timeoutMs: options.timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, timer: undefined, signal: options.signal, // The spec forbids cancelling `initialize`. onAbort: () => this.cancelPending( id, new McpAbortError(), method !== "initialize", String(options.signal?.reason ?? "Aborted"), ), sendController: new AbortController(), cancellable: method !== "initialize", onProgress: options.onProgress, progressToken, }; this.pending.set(id, entry); if (progressToken !== undefined) this.progressRequests.set(progressToken, id); options.signal?.addEventListener("abort", entry.onAbort, { once: true }); this.armTimeout(id, entry); transport .send(message, { signal: entry.sendController.signal }) .catch((error) => this.cancelPending(id, error, false));});notifications/cancelled cho server, trừ initialize mà đặc tả cấm huỷ. Timeout mặc định của gói là 30 giây (coding agent đặt 60 giây, hoặc timeout của server trong mcp.json), và mỗi thông báo progress của server đặt lại nó (client.ts:536–559), nên một tool chạy lâu mà vẫn báo tiến độ không bị cắt.Kết quả tools/call được kiểm tra nới tay: content theo đặc tả là bắt buộc nhưng có server chỉ trả structuredContent, và từ bản đặc tả 2026-07-28 structuredContent có thể là mọi giá trị JSON, kể cả mảng và null (client.ts:144–153). Lỗi của tool MCP không phải lỗi giao thức: chúng nằm trong kết quả với isError: true.
stdio#
StdioTransport chạy server như một tiến trình con, gửi mỗi message một dòng JSON và đọc stdout theo dòng, mỗi message tối đa 16 MiB (stdio.ts:181–207). Server được đặt trong process group riêng, vì server hay được chạy qua npx hay uvx: giết tiến trình con trực tiếp sẽ để lại server thật chạy mồ côi. Đóng theo đúng đặc tả:
async close(): Promise<void> { if (this.closed) return; this.closed = true; const child = this.child; if (!child) { this.emitClose(); return; } if (child.exitCode !== null || child.signalCode !== null) { child.stdin?.end(); return; } // Shutdown per the spec: close stdin and let the server exit, then SIGTERM, then SIGKILL. const closeTimeoutMs = this.options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS; await new Promise<void>((resolve) => { const timers: ReturnType<typeof setTimeout>[] = []; child.once("close", () => { for (const timer of timers) clearTimeout(timer); // Children of the server that ignored stdin closing would otherwise outlive it. killProcessTree(child, "SIGTERM"); resolve(); }); const grace = Math.min(STDIN_CLOSE_GRACE_MS, closeTimeoutMs); timers.push(setTimeout(() => killProcessTree(child, "SIGTERM"), grace)); timers.push(setTimeout(() => killProcessTree(child, "SIGKILL"), grace + closeTimeoutMs)); child.stdin?.end(); });}exit của tiến trình còn gửi SIGTERM cho mọi nhóm tiến trình đang sống nếu pi thoát mà không kịp đóng (stdio.ts:43–53).Streamable HTTP#
Mỗi message JSON-RPC là một POST. Server trả lời bằng JSON hoặc bằng một luồng SSE:
async send(message: JsonRpcMessage, { signal }: McpTransportSendOptions = {}): Promise<void> { if (!this.started || this.closed) throw new McpConnectionClosedError(); const response = await this.authorizedFetch( "POST", { headers: { accept: "application/json, text/event-stream", "content-type": "application/json" }, body: JSON.stringify(message), }, signal, ); await this.checkResponse(response); this.captureSession(response); if (!isJsonRpcRequest(message)) { // Notifications and responses are acknowledged with 202 and carry no reply; ignore any body. await discard(response); // The server-to-client stream may only open once the session is initialized. if ("method" in message && message.method === "notifications/initialized") this.startGetStream(); return; } if (response.status === 202 || response.status === 204) { throw new McpHttpError(response.status, `MCP server accepted request ${message.method} without a response`); } const type = contentType(response); if (type === "application/json") { const body: unknown = await response.json(); for (const item of Array.isArray(body) ? body : [body]) this.emitMessage(parseJsonRpcMessage(item)); return; } if (type === "text/event-stream" && response.body) { void this.consumeResponseStream(response.body, message.id); return; } await discard(response); throw new McpHttpError(response.status, `Unsupported MCP response content type: ${type ?? "missing"}`);}notifications/initialized, và được mở lại với backoff khi rớt (streamable-http.ts:428–454). Luồng SSE trả lời một request mà đứt trước khi có câu trả lời thì được nối lại bằng GET với Last-Event-ID, nếu server có gán id cho sự kiện (:377–420).Mọi fetch đi qua authorizedFetch(): gắn Mcp-Session-Id, MCP-Protocol-Version và token, và khi gặp 401 hay 403 kèm insufficient_scope thì giao cho auth provider đúng một lần rồi thử lại (streamable-http.ts:287–317). Một chi tiết nhỏ cho thấy gói được viết cho nhiều runtime: fetch được gọi không có receiver, vì Cloudflare Workers từ chối fetch của nền tảng khi this là một object khác (“Illegal invocation”, :211–214).
pi-mcp: OAuth#
Server MCP từ xa thường được bảo vệ bằng OAuth: client phải tìm ra authorization server, tự định danh với nó, đưa người dùng qua trình duyệt để lấy mã, rồi đổi mã lấy token. Phần OAuth của pi-mcp được chuyển thể từ SDK TypeScript chính thức bản 1.29.0 (giấy phép MIT, README.md:112), bỏ Zod và các shim CORS, và bắt buộc kiểm tra issuer của authorization server (oauth/discovery.ts:1–5).
- 401Một request bị từ chối. Auth provider thử refresh token; không được thì ném
McpOAuthAuthorizationRequiredErrorvà kết nối chuyển sang trạng thái “needs sign-in”. Kết nối không bao giờ tự mở trình duyệt. - DiscoveryĐọc protected resource metadata (RFC 9728) của server, lấy authorization server đầu tiên, rồi đọc metadata của nó (RFC 8414 hoặc OpenID Connect) và đối chiếu
issuer. Cóoauth.authServerMetadataUrlthì dùng thẳng tài liệu đó (discovery.ts:148–194). - Định danh clientThông tin client đã lưu; nếu không có thì một Client ID Metadata Document (CIMD); nếu không thì đăng ký động (dynamic client registration).
- Uỷ quyềnURL uỷ quyền có PKCE S256,
statevàresource(flow.ts:170–200). Người dùng duyệt trong trình duyệt; callback về một server loopback, hoặc người dùng dán URL chuyển hướng khi trình duyệt ở máy khác. - Đổi mãKiểm tra
isstheo RFC 9207, đổi mã lấy token, lưu token kèm scope đã cấp.
export async function discoverAuthorizationServerMetadata( authorizationServerUrl: string | URL, options: { fetch?: McpFetch; protocolVersion?: string; skipIssuerValidation?: boolean; signal?: AbortSignal } = {},): Promise<AuthorizationServerMetadata | undefined> { const fetch = options.fetch ?? globalThis.fetch; for (const { url } of buildAuthorizationServerDiscoveryUrls(authorizationServerUrl)) { const response = await fetchMetadata( url, fetch, options.protocolVersion ?? LATEST_PROTOCOL_VERSION, options.signal, ); if (!response.ok) { discard(response); if (isDiscoveryMiss(response.status)) continue; throw new Error(`HTTP ${response.status} loading authorization server metadata from ${url}`); } const metadata = parseAuthorizationServerMetadata(await response.json()); if (!options.skipIssuerValidation) { const expected = String(authorizationServerUrl); // URL parsing adds a trailing slash to bare origins, so compare without one on either side. const trim = (value: string) => (value.endsWith("/") ? value.slice(0, -1) : value); if (trim(metadata.issuer) !== trim(expected)) throw new OAuthIssuerMismatchError(expected, metadata.issuer); } return metadata; } return undefined;}/.well-known/oauth-authorization-server, rồi openid-configuration, :106–117). Issuer khác URL mong đợi thì ném OAuthIssuerMismatchError.const stored = await provider.clientInformation();const clientDocument = stored ? undefined : provider.clientMetadataDocument?.(metadata);if (clientDocument) { const url = new URL(clientDocument.url); if (url.protocol !== "https:" || url.pathname === "/") throw new Error("Invalid OAuth client metadata URL");}let client = stored ?? (clientDocument && { client_id: clientDocument.url });if (!client) { if (options.authorizationCode) throw new Error("OAuth client information is missing during code exchange"); if (!provider.saveClientInformation) throw new Error("OAuth client information cannot be persisted"); client = await registerClient(discovered.authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, scope, fetch: options.fetch, signal: options.signal, }); await provider.saveClientInformation(client);}application_type suy từ redirect URI: native cho loopback và scheme tuỳ biến, không thì web (MCP SEP-837). Thiếu trường này, authorization server OpenID Connect mặc định là web và từ chối redirect URI http loopback (flow.ts:109–120).if (options.authorizationCode) { // RFC 9207: never send a code from another authorization server to this one. Without metadata there // is no issuer to compare with, so any `iss` is rejected. const iss = options.iss; if (iss !== undefined || metadata?.authorization_response_iss_parameter_supported) { if (iss !== metadata?.issuer) throw new OAuthIssuerMismatchError(metadata?.issuer, iss); } const tokens = await exchangeAuthorizationCode(discovered.authorizationServerUrl, { ...tokenOptions, code: options.authorizationCode, codeVerifier: await provider.codeVerifier(), redirectUrl, }); // A response without `scope` grants the requested scope (RFC 6749 §5.1). Recorded so a step-up can // keep it. Callers pass the options of the authorization request, so `scope` is what was requested. await provider.saveTokens(withScope(tokens, scope)); return "AUTHORIZED";iss, hoặc metadata hứa sẽ có, thì nó phải khớp issuer. Không có metadata thì không có gì để so, nên mọi iss đều bị từ chối; lỗ hổng này được vá ở 1.2.0 (mcp/CHANGELOG.md:15).Khi server đòi thêm scope (insufficient_scope), lần uỷ quyền mới xin scope bị thiếu cộng scope đã có, vì challenge có thể chỉ liệt kê phần thiếu, và một token chỉ có phần đó sẽ mất quyền mà token cũ có (flow.ts:309–318). adaptOAuthProvider() cho nhiều 401 đồng thời dùng chung một lần làm mới, và chỉ thử lại request nếu token của nó đã được thay: với refresh token xoay vòng, lần làm mới thứ hai bằng refresh token cũ sẽ thất bại và huỷ luôn grant mới (flow.ts:446–482).
Phía coding agent: credential và CIMD#
Extension MCP lưu trạng thái OAuth trong ~/.pi/agent/mcp-auth.json, theo khoá là tên namespace cộng URL, để hai server cùng URL dưới hai tên đăng nhập hai tài khoản khác nhau (1.0.0):
/** * Keys of a server's state: by name and URL, so servers sharing a URL keep separate accounts, and the * legacy key by URL alone, written by older versions. */function storeKeys(name: string, serverUrl: string): { key: string; legacyKey: string } { const legacyKey = String(new URL(serverUrl)); return { key: `${mcpNamespace(name)}|${legacyKey}`, legacyKey };}Với "clientRegistration": "cimd" (1.0.1), pi không đăng ký mà tự định danh bằng một tài liệu metadata trên pi.dev. Tài liệu và redirect URI có thể phải riêng cho từng server:
/** * pi's Client ID Metadata Document, for `clientRegistration: "cimd"`, chosen like Codex chooses its own. * The configuration ensures the default callback path. Without the `iss` parameter in authorization * responses (RFC 9207), the redirect URI and the document are specific to the MCP server, so a response * cannot be mixed up with one from another authorization server (RFC 9700 section 4.4.2.2). */function clientMetadataDocument( serverUrl: string, redirectUrl: string, metadata: AuthorizationServerMetadata | undefined,): OAuthClientMetadataDocument { if ( !metadata?.client_id_metadata_document_supported || !metadata.token_endpoint_auth_methods_supported?.includes("none") ) { throw new Error( 'The authorization server does not support Client ID Metadata Documents for public clients; remove oauth.clientRegistration "cimd"', ); } if (metadata.authorization_response_iss_parameter_supported) { return { url: `${CLIENT_METADATA_BASE_URL}/client.json`, redirectUrl }; } const id = callbackId(serverUrl); const redirect = new URL(redirectUrl); redirect.pathname = `${CALLBACK_PATH}/${id}`; return { url: `${CLIENT_METADATA_BASE_URL}/${id}/client.json`, redirectUrl: redirect.href };}iss trong phản hồi uỷ quyền, pi dùng tài liệu https://pi.dev/oauth/<id>/client.json và đường callback /callback/<id> riêng cho server MCP đó, để một phản hồi không thể bị nhầm với phản hồi của authorization server khác (RFC 9700 mục 4.4.2.2). Callback server từ chối phản hồi đến sai đường (callback.ts:89–102).Toàn bộ quá trình đăng nhập, gồm chọn cổng callback giữ đúng redirect URI đã đăng ký, chờ callback hoặc URL dán vào, giới hạn 15 giây cho mỗi request tới authorization server, nằm trong signInMcpServer() (oauth.ts:468–558).
Extension MCP có sẵn#
Cấu hình#
Server đến từ ~/.pi/agent/mcp.json và, khi dự án đã được tin cậy (project trust), từ .pi/mcp.json; định dạng mcpServers giống các client MCP khác nên cấu hình chép sang được (mcp/config.ts:1–30). Entry của dự án cùng tên thay entry toàn cục, trừ khi nó không có command, url hay type: khi đó nó chỉ ghi đè ba khoá (1.0.1):
for (const [name, value] of Object.entries(parsed.mcpServers ?? {})) { if (scope === "project" && isRecord(value) && isOverride(value)) { const base = servers.get(name); const extra = Object.keys(value).filter((key) => !OVERRIDE_KEYS.includes(key)); if (!base) { errors.push(`${path}: server "${name}" needs "command" or "url", or a global server to override`); } else if (extra.length > 0) { errors.push(`${path}: server "${name}": an override can only set ${OVERRIDE_KEYS.join(", ")}`); } else { const config = validateMcpServerConfig(name, { ...base.config, ...value }); if (typeof config === "string") errors.push(`${path}: ${config}`); else servers.set(name, { ...base, config, override: path }); } continue; }"auth": { "provider": ... }, gửi token của một provider đã /login làm bearer token, bị cấm trong file của dự án, để một repository không chọn được nơi credential của bạn bị gửi tới (config.ts:134–137).Exposure của server MCP có bốn giá trị, mặc định codemode:
/** * - `codemode`: tools are callable from codemode scripts but neither declared to the model nor * listed in the codemode description, which lists only the server's namespace. Scripts find them * with `searchTools()`. `codemode-deferred` is accepted as an alias. * - `deferred`: not declared to the model until the `tool_search` tool loads them; the model then * calls them directly. Does not need codemode. * - `direct`: tools are declared to the model like any other tool (and callable from codemode). * - `hidden`: tools are registered but unreachable. */export type McpExposure = "codemode" | "deferred" | "direct" | "hidden";ToolExposure của pi: codemode và deferred đều thành deferred, chỉ khác ở chỗ extension bật codemode hay tool_search để với tới chúng (mcp/tools.ts:40–46). toolExposure ghi đè cho từng tool, theo tên chính xác hoặc mẫu * (mcp-servers.ts:233–242).Tên tool và đăng ký#
/** * `mcp__<server>__<tool>`, sanitized and shortened with a hash suffix when too long. Like Codex, * everything but `[A-Za-z0-9_]` becomes `_`, so the name is also the identifier codemode scripts * call it by. `isTaken` reports names used by a different MCP tool: sanitizing can map two tools to * one name (`a-b` and `a_b`), which then get the hash suffix. */export function createMcpToolName( server: string, tool: string, isTaken: (name: string) => boolean = () => false,): string { const name = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_"); if (name.length <= MAX_TOOL_NAME_LENGTH && !isTaken(name)) return name; const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8); return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;}[A-Za-z0-9_] thành _, như Codex, nên tên tool cũng chính là identifier mà script gọi. Tên dài quá 64 ký tự, hoặc trùng sau khi làm sạch (read-file và read_file), được thêm hậu tố hash. Server có tên chỉ khác nhau ở - và _ bị từ chối, vì chúng sẽ chung một namespace (config.ts:128–133).const registerTools = (connection: McpServerConnection) => { const server = connection.entry.name; const entry = findServer(server)?.entry ?? connection.entry; const description = entry.config.description?.trim(); const namespace = { name: mcpNamespace(server), // `describeNamespace("docs")` finds `mcp__docs`. aliases: [server], ...(description ? { description } : {}), ...(connection.instructions ? { instructions: connection.instructions } : {}), }; for (const tool of connection.tools) { const definition = createMcpToolDefinition({ server, tool, name: assignName(tool.name, `${server}\0${tool.name}`), exposure: getMcpToolExposure(entry.config, tool.name), namespace, definitions.set(definition.name, definition); pi.registerTool(definition); } serverTools.set(server, current); // Tools cannot be unregistered, so tools the server dropped are re-registered as hidden. When // the server offers them again they are registered with their configured exposure above. for (const name of previous) { const definition = definitions.get(name); if (!current.has(name) && definition) pi.registerTool({ ...definition, exposure: "hidden" }); }describeNamespace("docs") tìm được mcp__docs, và mang instructions của server. Tool mà server bỏ đi (sau notifications/tools/list_changed) được đăng ký lại với exposure hidden.Kết quả MCP được dịch cho hai người đọc khác nhau:
/** Convert an MCP result. `isError` results become error results that keep the structured result. */export async function convertMcpResult( server: string, tool: string, result: CallToolResult, options: ConvertMcpResultOptions = {},): Promise<AgentToolResult<McpToolDetails>> { // Without content blocks, toLlmContent falls back to the structured content as JSON. const converted: (TextContent | ImageContent)[] = result.content.length > 0 ? await toModelContent(server, result.content, options) : toLlmContent(result); if (result.isError && textOf(converted) === "") { converted.push({ type: "text", text: `MCP tool ${server}/${tool} returned an error` }); } const { content, fullOutputPath } = await limitMcpContent(converted, options.saveOutput); const { _meta: _ignored, ...scriptResult } = result; return { content, details: { server, tool, ...(fullOutputPath ? { fullOutputPath } : {}) }, structuredContent: scriptResult as unknown as JsonValue, ...(result.isError ? { isError: true } : {}), };}CallToolResult trừ _meta, không bị cắt, làm structuredContent. isError là lỗi với mô hình, nhưng trong script lời gọi vẫn resolve.Kết nối nền và chờ đúng server#
Khi session bắt đầu, extension đọc cấu hình, bật codemode hoặc tool_search từ cấu hình (trước khi server nào kết nối), rồi chỉ nạp client MCP sau một vòng event loop, để lần vẽ đầu không phải chờ, và kết nối mọi server ở nền (mcp/index.ts:1114–1151). Prompt đầu chỉ chờ, tối đa 10 giây, những server có tool direct, vì tool của chúng phải được khai báo trong request đầu (:1153–1174). Các server khác được chờ đúng lúc cần, nhờ reachesTools:
// A tool that reaches other tools (`reachesTools`) waits for the servers it may reach, so their// tools are registered before it runs: a codemode script for the servers it names, or for every// server when it searches or enumerates tools, and tool_search for every server. The resource// tools reach every server, so they wait for all of them. Direct MCP calls wait only for their// own server, including a reconnect still closing its old transport.pi.on("tool_call", async (event, ctx) => { const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName); if (!tool) return; // Readiness can be pending while the display state is still connected: reconnect first // closes the old transport, then opens a new one. Settled promises return immediately. const readyServers = servers.filter((server) => isEnabled(server) && server.ready); if (readyServers.length === 0) return; let waiting: McpServer[] = []; const reach = tool.reachesTools?.(event.input); if (reach !== undefined) { waiting = readyServers.filter((server) => reachesServer(reach, server.entry.name)); } else if (RESOURCE_TOOL_NAMES.has(tool.name)) { waiting = readyServers; } else { const owner = toolOwners.get(event.toolName)?.split("\0", 1)[0]; waiting = readyServers.filter((server) => server.entry.name === owner); } await waitForServers(waiting, ctx.signal);});tool_search và tool resource chờ mọi server; lời gọi MCP trực tiếp chỉ chờ server của nó.Mỗi server là một McpServerConnection, kết nối lại lười biếng khi một lời gọi thấy kết nối đã mất:
private async connectOnce(): Promise<McpClient> { const client = new McpClient({ name: "pi", version: VERSION, requestTimeoutMs: this.timeoutMs, roots: [{ uri: pathToFileURL(this.cwd).href, name: basename(this.cwd) }], }); try { transport = this.createTransport(this.entry, this.cwd, this.authProvider); await client.connect(transport); client.onNotification("notifications/tools/list_changed", () => { void this.refreshTools(client); }); const stdio = transport instanceof StdioTransport ? transport : undefined; client.onClose(() => this.handleClientClose(client, stdio)); // Servers without the tools capability (prompts or resources only) do not answer tools/list. const tools = client.serverCapabilities?.tools ? await client.listTools() : []; if (this.closed) throw new Error("shut down while connecting"); if (client.connectionState !== "connected") throw new Error("connection closed during setup"); this.client = client; this.tools = tools; this.hasResources = client.serverCapabilities?.resources !== undefined; this.instructions = client.instructions?.trim() || undefined; this.state = "connected"; this.error = undefined; this.onTools(this); this.changed(); return client;tools không bị hỏi tools/list; resource không được liệt kê lúc kết nối, chỉ ghi nhận server có resource, vì liệt kê hết có thể rất lâu (1.2.0). Lời gọi tool không được thử lại sau lỗi HTTP tạm thời vì server có thể đã chạy nó; đọc resource thì được thử lại một lần, và session hết hạn (404) thì thử lại một lần trên session mới (runtime.ts:274–301).Mục prompt, resource, dòng lệnh#
Server có tool codemode hoặc deferred được liệt kê trong mục mcp_servers của system prompt, mỗi server một dòng gồm namespace, cách với tới và một câu tóm tắt, cả mục tối đa 4.096 ký tự (mcp/index.ts:155–221). Đây là nơi mô hình biết các server tồn tại mà không phải tìm, vì cả mô tả codemode lẫn tool_search đều không liệt kê chúng. Server có resource được với tới qua ba tool mang đúng tên mà Codex và opencode dùng, list_mcp_resources, list_mcp_resource_templates và read_mcp_resource, để mô hình đã được huấn luyện trên chúng dùng nguyên (resources.ts:1–11).
pi mcp add|remove|list|login|logout chạy ngoài session, không nạp extension. Đầu file ghi rõ đối tượng: agent chạy nó qua bash để cấu hình server, kiểm tra một mcp.json vừa viết và khởi động đăng nhập OAuth, còn người dùng chỉ việc duyệt trong trình duyệt (mcp/cli.ts:1–6). pi mcp list thoát với mã 1 khi có entry sai hoặc server không kết nối được. Session đang chạy nhận credential mới ở lượt kế tiếp, vì hook turn_start so token đã lưu với lúc server cần đăng nhập (mcp/index.ts:1211–1214). Extension cũng thêm server được bằng pi.registerMcpServer(); core chỉ kiểm tra và lưu đăng ký, việc kết nối thuộc về extension MCP (mcp-servers.ts:1–6).
Tool search#
Tool deferred tồn tại nhưng mô hình không biết tên chúng. Có hai cách tìm, dùng chung một bộ xếp hạng: searchTools() trong script codemode, và tool tool_search cho mô hình gọi tool trực tiếp. Văn bản được đánh chỉ mục cho mỗi tool gồm tên, tên với _ thay bằng dấu cách, mô tả, mô tả và tên thuộc tính trong schema, cùng tên, mô tả và instructions của namespace:
/** * Search text of a tool: the name, the name with `_` * as spaces, the description, schema descriptions and property names, and the namespace with its * description and instructions. */export function createToolSearchDocument( tool: Pick<ToolInfo, "name" | "description" | "parameters">, namespace?: ToolNamespace,): ToolSearchDocument { const parts = [tool.name, tool.name.replaceAll("_", " "), tool.description]; schemaText(tool.parameters, parts); if (namespace) parts.push(namespace.name, namespace.description ?? "", namespace.instructions ?? ""); return { name: tool.name, text: parts.filter((part) => part.trim()).join(" ") };}description của một server MCP trong mcp.json còn có tác dụng xếp hạng tool của nó. Từ được tách theo ranh giới camelCase và mọi ký tự ngoài chữ cái và chữ số, bỏ stop word, đưa về số ít một cách đơn giản (tool-search/tool.ts:63–80)./** Okapi BM25 with the usual parameters. Ties keep document order. */export class Bm25Ranker implements ToolRanker { private readonly k1: number; private readonly b: number; constructor(options: { k1?: number; b?: number } = {}) { this.k1 = options.k1 ?? 1.2; this.b = options.b ?? 0.75; } rank(query: string, documents: readonly ToolSearchDocument[], limit: number): ToolSearchMatch[] { const queryTerms = [...new Set(tokenize(query))]; if (queryTerms.length === 0 || documents.length === 0 || limit <= 0) return []; const termCounts = documents.map((document) => { const counts = new Map<string, number>(); for (const term of tokenize(document.text)) counts.set(term, (counts.get(term) ?? 0) + 1); return counts; }); const lengths = termCounts.map((counts) => [...counts.values()].reduce((sum, count) => sum + count, 0)); const averageLength = lengths.reduce((sum, length) => sum + length, 0) / documents.length || 1; const idf = new Map( queryTerms.map((term) => { const frequency = termCounts.filter((counts) => counts.has(term)).length; return [term, Math.log(1 + (documents.length - frequency + 0.5) / (frequency + 0.5))] as const; }), ); const matches: ToolSearchMatch[] = []; documents.forEach((document, index) => { let score = 0; for (const term of queryTerms) { const count = termCounts[index].get(term); if (!count) continue; const norm = this.k1 * (1 - this.b + (this.b * lengths[index]) / averageLength); score += (idf.get(term) ?? 0) * ((count * (this.k1 + 1)) / (count + norm)); } if (score > 0) matches.push({ name: document.name, score }); }); return matches.sort((a, b) => b.score - a.score).slice(0, limit); }}ToolRanker ghi rõ đây là bản hiện tại: một bộ xếp hạng lai có embedding có thể thay nó (:34–37).tool_search chỉ xếp hạng tool có thể tìm (exposure codemode hoặc deferred) mà chưa bật, rồi bật các kết quả:
/** * Rank the searchable tools that are not active yet and activate the matches, so the next model * call declares them. Activation is recorded in the transcript like any tool change. */function searchAndLoad( tools: NonNullable<ToolSearchToolOptions["tools"]>, query: string, limit: number,): ToolSearchResultTool[] { const active = tools.getActiveTools(); const candidates = tools.getAllTools().filter((tool) => isSearchable(tool.exposure) && !active.includes(tool.name)); const documents = candidates.map((tool) => createToolSearchDocument(tool, tool.namespace)); const matches = new Bm25Ranker().rank(query, documents, limit); if (matches.length > 0) tools.setActiveTools([...active, ...matches.map((match) => match.name)]); return matches.map((match) => ({ name: match.name, description: candidates.find((tool) => tool.name === match.name)?.description ?? "", }));}model-only và reachesTools: () => "all" (:217–244, index.ts:12–16). Nó tắt theo mặc định; extension MCP bật nó khi có server dùng exposure deferred, hoặc người dùng thêm "+tool_search" vào defaultTools (docs/cli.md:180–182).Vì sao cách nạp này giữ được prompt cache. Bài “Prompt Caching In Agents” chỉ ra rằng tên, mô tả và schema của tool là đầu vào của mô hình, nằm ở phần đầu prompt; thêm, bớt, sửa hay đổi thứ tự tool là sửa phần đầu, và trên hầu hết mô hình điều đó làm mất cache của cả hội thoại phía sau. Chỉ thay đổi thuần cộng, được neo vào một vị trí trong transcript, mới giữ được prefix (Prompt Caching In Agents). Thiết kế của tool_search làm đúng như vậy ở bốn điểm:
- Nạp là bậtKhông có cơ chế riêng: sau
setActiveTools(), ở lượt kế tiếp vòng lặp nối một system messagetoolsAddedsau kết quả củatool_search, và pi-ai gửi nó tại chỗ cho provider hỗ trợ (trace ở chương Pi 1.0). - Mô tả không đổiMô tả
tool_searchkhông liệt kê tool hay server nào, nên nó giống hệt nhau trước và sau khi server kết nối hay tool được nạp (tool-search/tool.ts:211–215). - Codemode không bị khai báo lạiMô tả
codemodeliệt kê theo exposure chứ không theo bộ đang bật, nên một lần nạp không làm đổi khai báo củacodemode(codemode/tool.ts:317–318). - Nạp một lần cho cả nhánhLần nạp nằm trong transcript, nên sau resume,
/treehay fork, tool vẫn được khai báo trên nhánh đó mà không phải tìm lại (docs/mcp.md:226).
Với provider chỉ ở mức "none", như Google, một lần nạp vẫn viết lại danh sách tool và làm trượt cache. Đó là giới hạn của API, không phải của thiết kế; trên các provider ấy, script codemode gọi tool deferred là cách duy nhất không đổi khai báo, vì lời gọi trong script không cần tool đang bật.
Trace: một script gọi song song hai tool MCP#
Kịch bản: hai server MCP linear và github, exposure mặc định codemode, đã kết nối. Từ một lần chạy describeNamespace() trước, mô hình biết tên hai tool cần dùng. Người dùng hỏi: “Có issue auth nào đang mở, và có PR nào liên quan không?”. Tên tool, đối số và hình dạng structuredContent dưới đây là giả định: chúng do server MCP định nghĩa, không phải do pi.
Ví dụ minh hoạ: script mô hình gửi trong đối số code của tool codemode
// @options: {"timeout_ms": 60000}
const [issues, prs] = await Promise.allSettled([
tools.mcp__linear__list_issues({ label: "auth", state: "open" }),
tools.mcp__github__search_pull_requests({ query: "auth is:open" }),
]);
const lines = [];
if (issues.status === "fulfilled" && !issues.value.isError) {
for (const issue of issues.value.structuredContent.issues) lines.push(`${issue.identifier}: ${issue.title}`);
} else {
lines.push(`Linear lỗi: ${issues.status === "rejected" ? issues.reason.message : "isError"}`);
}
if (prs.status === "fulfilled" && !prs.value.isError) {
lines.push(`${prs.value.structuredContent.total_count} PR liên quan`);
}
return lines.join("\n");
await, nên chúng chạy song song. Kết quả MCP có isError vẫn resolve trong script, nên script tự kiểm tra.- Lời gọi
codemodeTrên mô hình OpenAI hỗ trợ tool ngữ pháp, script đến như văn bản thô theo ngữ pháp Lark; ở provider khác nó là chuỗi trong{ code }. Vòng lặp kiểm tra đối số rồi gọi hooktool_callnhư mọi lời gọi của mô hình. - Chờ server
reachesTools({ code })không thấy global khám phá nên trả các từ của script;mcp__linear__list_issueschứamcp__linearvàmcp__github__search_pull_requestschứamcp__github, nên hook của extension MCP chờ đúng hai server đó (mcp/index.ts:227–231). Chúng đã kết nối, nên promise đã settle trả về ngay. executeCodemode()Tách dòng options (timeout_ms60.000), lấy các tool gọi được từctx.tools. Hai tool MCP có trong đó dù không được khai báo, vì exposuredeferredluôn gọi được (agent-session.ts:1582). Xin một chỗ worker, tạoCodemodeSandboxvới giới hạn 256 MB, đọc store từ nhánh.- Worker và VM
workerLauncher()tạo cờ ngắt và worker; worker gọistartVm(), chạy prelude (đóng băng built-in, dựngtools), bọc script vào hàm async và chạy. - Hai message
callMỗitools.*(...)chuyển đối số thành JSON, lấy id 1 và 2, gọibridge("call", ...); worker chuyển chúng thành{ type: "call", id, target: "tool", name, args }. Script chờ ởPromise.allSettled;stalled()thấy hai lời gọi đang chờ nên không kết thúc script. handleCall()chạy song songHost nhận hai message và chạy haihandleCall()mà không chờ nhau. Mỗi cái ghi một dòngCodemodeNestedCalltrạng tháirunning(giao diện hiện ngay) rồi gọictx.executeTool(name, args, { signal }).- Pipeline tool
NestedToolCallRunnerđặt id<id lời gọi codemode>/1và/2, pháttool_execution_startcóparentToolCallId. Không lời gọi nào phải xếp hàng độc quyền (agent chạy tool song song, tool MCP không khai báosequential).runToolCall()kiểm tra đối số theoinputSchemacủa tool MCP và chạy hook; một extension phân quyền ở đây thấy cả hai lời gọi kèmparentToolCallId. - Tới server
execute()của tool MCP lấyMcpServerConnectionvà gọiclient.callTool()với timeout của server và callback progress. Hai requesttools/callđi qua hai transport khác nhau, mỗi request có id JSON-RPC riêng. - Kết quả về
convertMcpResult()tạo nội dung cho mô hình (không ai dùng ở đây) vàstructuredContentlà cảCallToolResult.afterToolCallchạy hooktool_result. Runner ghi bản ghi, pháttool_execution_end.toScriptValue()trảstructuredContentvì tool MCP luôn có output schema. - Trở lại VMHost gửi
{ type: "result", id, ok: true, payload: JSON }; worker gọisettle(), promise trong VM resolve, VM chạy các job tới khi script chờ lại hoặc xong. Khi cả hai đã về, script dựnglinesvàreturn. - Kết thúcPrelude báo
donevới giá trị trả về dạng JSON và các lần ghi store (ở đây không có).finish()yêu cầu worker dừng: bật cờ ngắt,terminate(); chỗ worker được trả khi worker thoát. - Kết quả tool
executeCodemode()nối giá trị trả về như một item text, kiểm tra trần 10.000 token, thêm dòng đầuScript completed.AgentSessiongắnnestedCallsvào message kết quả lúc nó bắt đầu được phát, rồi lưu vào session.
Ví dụ minh hoạ: thứ mô hình nhận ở request sau toolResult codemode: Script completed Wall time 1.2 seconds Output: ENG-412: Refresh token bị mất khi huỷ request ENG-430: Đăng nhập treo khi cổng callback bận 2 PR liên quan
Ví dụ minh hoạ: bản ghi lưu trong session
"nestedCalls": {
"calls": [
{ "id": "call_7/1", "name": "mcp__linear__list_issues",
"arguments": { "label": "auth", "state": "open" },
"status": "ok", "durationMs": 412 },
{ "id": "call_7/2", "name": "mcp__github__search_pull_requests",
"arguments": { "query": "auth is:open" },
"status": "ok", "durationMs": 655 } ],
"complete": true }
Nếu kết nối tới server GitHub đứt giữa chừng, McpClient từ chối request đang chờ và execute() của tool MCP ném lỗi, runToolCall() biến nó thành kết quả lỗi không có structuredContent, toScriptValue() ném Error với văn bản lỗi, và trong script prs.status là "rejected". Script vẫn trả về các dòng của Linear; lời gọi codemode vẫn thành công; bản ghi của lời gọi thứ hai mang status: "error" và error đã cắt ngắn. Mô hình thấy đúng những gì script quyết định nói về lỗi đó.
Quyết định thiết kế#
Script chạy ở phía harness, trong một VM riêng. Phương án hiển nhiên là để mô hình viết script Node rồi chạy bằng bash. Nhưng khi đó tool của pi, server MCP đã đăng nhập, classifier với credential của session đều nằm ngoài tầm với, hoặc phải được phơi ra qua mạng. Codemode đặt script cạnh vòng lặp, nơi các thứ đó đã có, và bọc nó bằng một VM QuickJS mà khả năng duy nhất là gọi tool qua một cầu nối. Pi không có hệ thống phân quyền (chương Lịch sử); VM không thêm quyền gì, nó chỉ bảo đảm script không đi đường nào khác ngoài tool, và mọi lời gọi tool đi qua đúng pipeline có hook như lời gọi của mô hình.
Mỗi lần chạy một worker mới. Dựng worker và VM tốn khoảng 20 ms mỗi lần; dùng lại VM sẽ tiết kiệm khoản đó, nhưng một script quay vô hạn hay phá trạng thái sẽ đầu độc lần chạy sau. Worker mới cho phép kết thúc bằng terminate() và không để lại gì (host.ts:12–19). Giá phải trả là giới hạn số worker sống đồng thời và cờ ngắt cho Bun.
Cầu nối chỉ chở chuỗi JSON. Không có object nào được chia sẻ giữa VM và host; prelude tự JSON.parse phía VM, host tự kiểm tra phía host và coi payload hỏng là lỗi sandbox. Đóng băng built-in và Proxy trên tools là hai lớp bảo vệ thêm cho chính prelude, không phải cho host.
Chỉ output tới mô hình. Kết quả lồng nhau không vào transcript; transcript chỉ giữ một bản ghi có giới hạn. Đây là nguồn tiết kiệm token chính của codemode, và là lý do output có ngân sách mặc định 10.000 token cùng file tạm cho phần thừa. Trạng thái giữa các lần chạy, store(), cũng đi vào session dưới dạng entry, không phải file, để nó theo đúng nhánh của cây.
Khai báo TypeScript có ngân sách, cộng tìm kiếm. Liệt kê mọi tool trong mô tả thì quay lại đúng vấn đề MCP năm 2025. Pi liệt kê trong một ngân sách, giữ mô tả ổn định, và cho script bốn cách tìm phần còn lại (ALL_TOOLS, searchTools(), describeTool(), describeNamespace()).
Client MCP tự viết. pi-mcp không phụ thuộc SDK chính thức; phần OAuth được chuyển thể từ SDK, bỏ Zod, thêm kiểm tra issuer. Gói nhỏ, chạy được trên Cloudflare Workers, và các bản vá bảo mật như RFC 9207 nằm trong code của chính dự án. Tradeoff: tự theo kịp đặc tả, và một số phần của MCP (sampling, tasks, batch) chưa có.
Mặc định là gián tiếp. Tool MCP mặc định chỉ gọi được từ script; muốn mô hình gọi thẳng thì phải chọn deferred (qua tool_search) hoặc direct. Server kết nối ở nền, và chỉ server direct mới làm prompt đầu phải chờ.
Theo quy ước của Codex khi có lợi. Tên tool mcp__server__tool được làm sạch như Codex, kết quả dài bị cắt theo định dạng của Codex, tool resource mang đúng tên của Codex và opencode, CIMD chọn tài liệu như Codex chọn. Mô hình đã được huấn luyện trên các quy ước đó thì không phải học quy ước mới.
Built-in thay được. Một extension đăng ký /mcp, codemode hay tool_search thay chỗ bản có sẵn, và extension MCP chỉ bật một codemode hay tool_search thay thế nếu nó khai báo reachesTools (docs/mcp.md:264–268). Tính năng vào lõi nhưng không khoá người dùng vào cách cài của lõi.
Cạm bẫy và trường hợp biên#
- Không có deadline mặc định. Trong coding agent, script không có
timeout_mschạy cho tới khi xong hoặc bị huỷ; một tool treo giữ script treo (docs/codemode.md:16). - Lời gọi là thật. Script lỗi giữa chừng không hoàn tác những gì đã chạy; lời gọi còn dở khi script kết thúc bị huỷ, promise không được
awaitbị bỏ (docs/codemode.md:18). - Lỗi MCP không reject. Kết quả
isErrorvẫn resolve trong script; cũng vậy vớibashcó mã thoát khác 0, vì tool có output schema trả object kể cả khi lỗi. Tool không có output schema thì reject khi lỗi. typeof tools.xném lỗi vì Proxy; dùng"x" in tools.- Store chỉ lưu khi script thành công, theo nhánh, tối đa 262.144 ký tự JSON mỗi giá trị; không dùng để giữ ảnh.
- Hai worker cho cả tiến trình. Subagent chạy trong cùng tiến trình mà cũng dùng codemode có thể phải chờ tới 60 giây rồi thất bại mà chưa chạy (docs/codemode.md:228). Trên Bun, built-in không thăm dò cờ ngắt có thể chạy tiếp sau deadline.
- Dòng
// @options:phải là dòng đầu; ở chỗ khác lời gọi thất bại trước khi script chạy. - Tên MCP bị chuẩn hoá.
-thành_; tên dài hay trùng có hậu tố hash, nên đừng đoán tên, hãy tìm. Hai server chỉ khác nhau ở-/_không cùng tồn tại được. - Cấu hình dự án cần trust, và
auth.providerchỉ được đặt trong file toàn cục. OAuth chỉ áp dụng cho server HTTP không có headerAuthorization(runtime.ts:82–87). - Lời gọi tool MCP không được thử lại sau lỗi mạng tạm thời, vì server có thể đã thực hiện nó.
- SDK không nạp built-in extension. Session dựng bằng SDK phải tự thêm extension MCP, codemode và tool-search (docs/mcp.md:270–272).
- Tài liệu và code lệch nhẹ.
docs/mcp.mdnói pi bậtcodemode“when a server withcodemodeexposure connects” (docs/mcp.md:204); ở v1.4.0, code bật nó từ cấu hình ngay khi session bắt đầu, trước khi server nào kết nối (mcp/index.ts:501–503).
Đọc code ở đâu#
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | coding-agent/docs/codemode.md | Script nhìn thấy gì: global, giá trị trả về, store, models, giới hạn. Đọc trước code. |
| 2 | codemode/src/types.ts | API của gói: tool, kết quả, lỗi, các tuỳ chọn, lần chạy tất định. |
| 3 | runtime/execution.ts, protocol.ts, host.ts, worker.ts | Phía host, giao thức, worker launcher. |
| 4 | runtime/vm.ts, prelude-source.ts | VM, cầu nối, chế độ tất định; mọi thứ script chạm tới. |
| 5 | codemode/src/declarations.ts | JSON Schema thành TypeScript, tag ràng buộc. |
| 6 | inline.ts, remote.ts, cloudflare.ts | Chạy ngoài Node: ngân sách ngắt, RPC, Durable Object. |
| 7 | extensions/codemode/tool.ts | Định nghĩa tool, mô tả và ngân sách, prepareLoadout, reachesTools. |
| 8 | extensions/codemode/execute.ts | Một lần chạy: worker, tool cho sandbox, output, store, models.*, global khám phá. |
| 9 | core/nested-tool-calls.ts, agent-session.ts:726–764 | Lời gọi lồng nhau qua pipeline tool, bản ghi nestedCalls. |
| 10 | extensions/tool-search/tool.ts | BM25 và tool_search; ngắn, đọc hết. |
| 11 | mcp/README.md, mcp/src/client.ts | Phạm vi giao thức, vòng đời client, huỷ, progress. |
| 12 | transports/stdio.ts, streamable-http.ts | Đóng khung, nhóm tiến trình, SSE, nối lại, uỷ quyền. |
| 13 | oauth/discovery.ts, oauth/flow.ts | Discovery, đăng ký, PKCE, RFC 9207, step-up, làm mới dùng chung. |
| 14 | coding-agent/docs/mcp.md, core/mcp-servers.ts, extensions/mcp/config.ts | Cấu hình, exposure, kiểm tra hợp lệ, override của dự án. |
| 15 | extensions/mcp/index.ts, tools.ts, runtime.ts | Đăng ký tool, mục mcp_servers, chờ server, chuyển đổi kết quả, kết nối. |
| 16 | extensions/mcp/oauth.ts, resources.ts, cli.ts | Credential, CIMD, đăng nhập; tool resource; pi mcp. |
Tóm tắt#
- MCP vào lõi ở 0.99.0 vì hai vấn đề cũ đã có lời giải: chi phí ngữ cảnh được xử lý bằng exposure và nạp tool hoãn lại, còn việc ghép lời gọi được xử lý bằng codemode.
pi-codemodechạy script của mô hình trong một VM QuickJS/WASM mới cho mỗi lần chạy, trong một worker thread; host và VM chỉ trao đổi chuỗi JSON; prelude đóng băng built-in, dựngtoolstrên một Proxy và có trần cho output và store. Gói còn có lần chạy tất định cho Pi Durable và các sandbox cho Cloudflare.- Tool
codemodecủa coding agent chỉ dùng API chung của extension:prepareLoadoutdựng mô tả trong một ngân sách mà không bao giờ liệt kê tooldeferred,ctx.executeTool()chạy lời gọi lồng nhau qua đúng pipeline tool vớiparentToolCallId, và chỉ output của script tới mô hình. pi-mcplà client độc lập với stdio, Streamable HTTP và OAuth đã gia cố (issuer, RFC 9207, CIMD, step-up). Extension MCP kết nối ở nền, đặt tênmcp__server__tool, mặc định chỉ cho script gọi, liệt kê server trong mụcmcp_servers, và chờ đúng server mà một lời gọi cần.tool_searchxếp hạng BM25 các tool chưa khai báo và nạp chúng bằng cách bật chúng; nhờ system message nối thêm, mô tả cố định và bản ghi trong transcript, việc nạp giữ được prompt cache trên provider hỗ trợ.