Inside Pi

Part VI · pi-durable · Chapter 16

Execution environments: local, remote, watched, searched

20 min read · checked against pi@4954d76 on 2026-10-16

Learning goals. After this chapter you should be able to:

  • explain why Durable's tools never touch node:fs or child_process directly, and what the ExecutionEnv capability buys;
  • read a Result<T, E> API and say when failures are returned rather than thrown;
  • describe how a shell command is spawned, killed, streamed, windowed and spilled to disk;
  • explain how the file watcher turns unreliable OS events into a correct change feed by diffing snapshots;
  • follow a request from RemoteExecutionEnv over SSH to the Rust daemon and back, frame by frame;
  • explain how tool_search and code mode's searchTools() rank tools with BM25.

16.1 The problem: tools need a machine, and the machine might be elsewhere

In the coding agent (Chapter 8) the bash tool calls spawn and the read tool calls readFile. That ties the tools to the process they run in. Durable wants something stronger. The worker, its storage and its credentials stay on one machine. The files and commands the agent works on can be on another: a GPU box, a container, a Windows laptop.

The answer is a classic one, dependency injection of a capability. Tools receive an object that can do file and process operations, and they do nothing else. In Durable that object is ExecutionEnv, handed to every tool call as api.env:

// packages/durable/src/tools/env.ts
/** The call's execution environment; a tool without one fails with an ordinary error result. */
export function requireEnv(api: ToolExecutionApi): ExecutionEnv {
	if (api.env === undefined) throw new Error("No execution environment is configured");
	return api.env;
}

The interface is the union of two smaller ones:

// packages/durable/src/env/index.ts
export interface ExecutionEnv extends FileSystem, Shell {}

This is the interface segregation principle at work. A component that only reads files can depend on FileSystem. Only the tools that run commands need Shell.

16.2 Returned errors: Result<T, E>

Every operation in the interface returns a Result instead of throwing:

// packages/durable/src/env/index.ts
/** Result of a fallible operation. Expected failures are returned instead of thrown. */
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };

This is a discriminated union (Chapter 1). Checking result.ok narrows the type, so the compiler forces callers to handle the failure before they can read value. The style comes from ML-family languages (Rust's Result, Haskell's Either). It encodes a design rule: a missing file is an expected outcome, not an exceptional one. Exceptions remain for bugs and for aborts the caller asked for.

The error types are small, closed vocabularies:

export type FileErrorCode =
	| "aborted" | "not_found" | "permission_denied" | "not_directory"
	| "is_directory" | "invalid" | "not_supported" | "unknown";

export type ExecutionErrorCode =
	| "aborted" | "timeout" | "shell_unavailable" | "spawn_error" | "callback_error" | "unknown";

There is a reason to keep the codes portable rather than passing through ENOENT and friends. A remote Windows host and a local Linux host must produce the same code for the same situation. Otherwise tool code would need per-platform branches. Messages may differ between environments; only codes are part of the contract.

16.3 The filesystem surface

FileSystem looks like a cleaned-up node:fs, with a few ideas worth studying.

A namespace identity.

/**
 * The file namespace: equal ids see the same files at the same paths, whatever their `cwd`. Every local Node
 * environment shares one id; each container or remote host has its own.
 */
readonly id: string;

The file mutation queue (Chapter 8) serializes edits to the same file. With several environments, "the same file" must mean the same namespace plus path. /tmp/a on the GPU box is not /tmp/a locally. The id is the namespace key; NodeExecutionEnv uses "node:local".

Handles that pin a file. openBinaryReader returns a reader where "all calls see the same file even if its path is renamed". This is the Unix file-descriptor model: a descriptor names an inode, not a path. It avoids a race known as TOCTOU (time-of-check to time-of-use), where a path is checked and then swapped before it is used. The noFollow option goes further and refuses a symbolic link as the final path component (O_NOFOLLOW).

Streaming line scans. scanLines({ startLine, endLine }) finds the byte range of a line range in one pass, in bounded memory. The read tool (Chapter 8) uses it to show lines 4,000–4,200 of a 2 GB log without loading the log. The scanner sees the file as a stream of chunks and counts newline bytes:

// packages/durable/src/env/line-scan.ts
#process(chunk: Uint8Array): void {
	const base = this.#position;
	let from = 0;
	for (let index = chunk.indexOf(NEWLINE); index !== -1; index = chunk.indexOf(NEWLINE, index + 1)) {
		// The newline ends line `this.#newlines`. It belongs to the selection between selected lines only.
		this.#feed(chunk, base, from, index);
		...
		this.#newlines++;
		this.#lineStart = position + 1;
		if (this.#newlines === this.#startLine) this.#begin(this.#lineStart);
		...

This is a finite-state machine over a byte stream: the only state is the position, the newline count and a few marks. Uint8Array.indexOf lets the engine use a fast native search for the newline byte.

Byte-order marks and chunk boundaries. UTF-8 can start with an optional mark EF BB BF. decode.ts records a real pitfall:

// packages/durable/src/env/decode.ts
/**
 * Decoding that matches `new TextDecoder().decode(bytes)` of the whole input when the input arrives in pieces. Node's
 * streaming decoder with BOM handling can drop a U+FEFF that follows a chunk boundary, not only a leading byte-order
 * mark, so these decoders turn BOM handling off and drop a leading mark themselves.
 */

The property being protected is chunking invariance: decoding a stream in pieces must give exactly the same text as decoding it whole. A streaming decoder must also hold back an incomplete multi-byte character until its remaining bytes arrive. decode(bytes, { stream: true }) does this.

Bounded listings. The optional listFiles returns the tree under a directory, skipping .git and what .gitignore ignores. It is bounded by maxEntries (50,000), maxBytes (4 MiB) and timeoutMs (10 s), with a truncated flag. Any operation that walks user data needs bounds like these, or one node_modules can stall the agent. When an environment cannot list, it returns not_supported and callers fall back to their own walk. That is graceful degradation via feature detection.

16.4 The shell: spawn, stream, window, spill, kill

Shell.exec takes either a string, run through the environment's shell, or an argv array, run directly:

/**
 * Run a command. A string runs through the environment's shell. An array runs `command[0]` directly with the rest
 * as its arguments, without a shell, so they reach the program unparsed. Aborting the context or a timeout kills
 * only this command's processes.
 */

The argv form matters for security and correctness. A shell string is parsed: spaces split words, $ expands, ; starts a new command. An argv array goes to execve untouched. Whenever code builds a command from data (a file name, a search pattern), the argv form makes shell injection impossible.

Process groups. NodeExecutionEnv.exec spawns with detached: process.platform !== "win32". On POSIX this makes the child the leader of a new process group. Killing then targets the whole group:

// packages/durable/src/env/node.ts
function killProcessTree(pid: number): void {
	if (process.platform === "win32") {
		// taskkill.exe /T /F ...
	}
	...
		process.kill(-pid, "SIGKILL");

The negative pid means "every process in group pid". Without it, npm test would die and leave its spawned test runner orphaned. Chapter 8 met the same trick in the coding agent.

When is a process finished? Node emits exit when the process ends, but its stdout pipe can still hold data. If a grandchild inherited the pipe, the pipe may never close at all. waitForChildProcess therefore finishes on whichever comes first:

  • close: all stdio closed;
  • exit and both streams ended;
  • exit followed by 100 ms (EXIT_STDIO_GRACE_MS) of output silence. The timer re-arms while data still arrives or the spill file is still draining.

This is a quiescence timeout, a common way to end a wait on something that has no clean termination signal.

Exit codes for signals. A process killed by a signal has no exit code. The env maps it to the shell convention 128 + signal number:

// A process killed by a signal (e.g. OOM killer) has no exit code; map it
// to the conventional 128 + signal number so callers do not mistake it
// for a successful exit.
const exitCode = code ?? (exitSignal ? 128 + (osConstants.signals[exitSignal] ?? 0) : 1);

So a process killed by SIGKILL (9) reports 137, exactly as bash would.

Spilling. Output above a threshold is written to a temporary file, and the result carries spillPath. The bash tool asks for spilling at the same limits it truncates to:

// packages/durable/src/tools/bash.ts
onOutput: (text, _context, info) => api.output(text, info.skipped),
spill: { afterBytes: DEFAULT_MAX_BYTES, afterLines: DEFAULT_MAX_LINES },
// An environment may then omit output outside the retained tail and report the omission.
...(api.outputWindow === undefined ? {} : { window: api.outputWindow }),

Output below the threshold is buffered as a prefix. When the threshold is crossed, the prefix is written first and then everything else. The file therefore always holds the complete output. The spill stream respects backpressure: when write returns false (the kernel buffer is full), the code waits for the drain event before writing more.

The output window. The model only ever sees the tail of a long command's output (the harness keeps it in a bounded buffer, read back by the bash tool through api.retainedOutput()). A remote environment can use this. If the caller keeps only the last maxBytes / maxLines, the environment may skip whatever is guaranteed to fall outside that tail, and report what it skipped:

export interface ShellOutputSkip {
	/** UTF-8 byte length of the omitted text. */
	bytes: number;
	/** Newlines (U+000A) in the omitted text. */
	newlines: number;
	/** Whether the omitted text ends with a newline. */
	endsWithNewline: boolean;
}

The skip record is a sufficient statistic. It keeps exactly the numbers the caller needs (byte and line counts for the truncation notice) and drops the text. Over a slow SSH link, yes | head -c 1G then costs a few frames instead of a gigabyte. minIntervalMs and bytesPerSecond also let the environment send no faster than the caller commits. This is flow control shaped to the consumer.

16.5 Watching files correctly

Hosts need to know when files change, for example when skills, prompts and extensions are hot-reloaded (Chapter 15's registry). Operating-system watch APIs are unreliable:

  • inotify (Linux) can overflow;
  • FSEvents (macOS) starts late;
  • Windows locks watched directories;
  • network file systems never report changes made elsewhere.

NodeFileWatcher does not trust events. Its design comment states the idea:

// packages/durable/src/env/node-watch.ts
/**
 * Watches by snapshots: native events only trigger a debounced rescan, and changes are the difference between
 * snapshots plus the event paths. A replaced file, a renamed or recreated ancestor, or a directory created with its
 * contents therefore never depends on which events an operating system sends. New directories get their watchers
 * before they are scanned again, so nothing written into them before the watcher existed is missed.
 */

This is level-triggered rather than edge-triggered design. An edge-triggered watcher reacts to each event and is wrong when an event is lost. A level-triggered watcher treats an event only as a hint to re-observe the state, then computes the difference itself. Lost or duplicated events cannot make it wrong; at worst it is late. The same idea appears in Kubernetes controllers ("reconcile loops") and in epoll's level-triggered mode.

The pieces:

  • Debounce (50 ms). A burst of events, such as git checkout touching 3,000 files, causes one rescan, not 3,000.
  • Snapshots. Each path maps to { kind, dev, ino, size, mtimeMs, hash? }. Comparing dev/ino (device and inode numbers) detects a file replaced by another with the same name. Editors that save via write-to-temp-then-rename do exactly that.
  • Content hashes for recent small files. Some file systems store timestamps coarsely. Two writes within the same tick can leave size and mtime identical. In polling mode, files under 256 KiB modified in the last 5 seconds are also hashed.
  • Install, then scan, then repeat. #sync loops up to 10 rounds. New directories get watchers, and the next round reports anything written into them before their watcher existed. This closes a classic race window.
  • macOS settle rescan. On macOS, fs.watch returns before FSEvents is live, and about 2% of changes made in that gap are lost, so a rescan runs 500 ms later.
  • Detecting unreliable file systems. statfs magic numbers (NFS 0x6969, FUSE 0x65735546, 9P for WSL2 drives, and others) select polling mode up front. A watcher that fails at runtime switches to polling and delivers { overflow: true }, telling the host to rescan everything.
  • A budget. At most 10,000 directories per watcher; past that, the watcher reports error and stops instead of eating the machine.

The contract to the host is honest about these limits:

/**
 * - `paths`: something at or below each path may have changed ... Calls may be
 *   spurious; a change is never missed while the watcher is healthy.
 * - `overflow`: coverage was uncertain for a while (lost events, reconnect); rescan everything that is watched.
 * - `error`: the watcher stopped ...; no calls follow.
 */

"May be spurious, never missed" is the same trade-off a Bloom filter makes: false positives allowed, false negatives forbidden. For cache invalidation that is the correct side to err on.

The ordering is the subtle part. watch() resolves only once coverage is established: watchers are installed before the first snapshot is taken. A host that calls watch and then loads its resources therefore cannot miss a change made during the load. Either the change happened before the snapshot, in which case the load sees it, or after, in which case the watcher reports it. This is the same "subscribe, then read" ordering that avoids lost updates in any cache built on change notifications.

16.6 Remote environments: pi-env

@earendil-works/pi-env makes a remote machine look like a local ExecutionEnv. Its README sets the goal:

RemoteExecutionEnv: a Durable ExecutionEnv that talks to the daemon through a Connection. Its results match NodeExecutionEnv running on the remote machine; only error messages may differ.

pi-env architecture · one interface, two implementations, one daemonpi-env architecture · one interface, two implementations, one daemon

The tools never know which implementation they hold. NodeExecutionEnv acts on the local machine. RemoteExecutionEnv keeps the rules (paths, aborts, error codes) locally and sends system calls through a Connection to the daemon.

Architecture. A small Rust program, the daemon pi-env, runs on the remote host and does the system calls. The TypeScript client RemoteExecutionEnv runs in the Durable worker. They talk over the stdin and stdout of an ssh process. Work is split deliberately (packages/env/docs/semantics.md):

  • the client does path resolution, abort checkpoints and error-code mapping;
  • the daemon does system calls, processes, spill files and line scans, and runs a port of NodeFileWatcher.

The rule is to keep policy in one place, the client, and do mechanism remotely. That way the TypeScript reference implementation and the remote version share the same rules.

Deploying a binary safely. deployDaemon names the remote binary after its SHA-256 (pi-env-<first 32 hex chars>) and verifies the hash on the remote machine before every start:

// packages/env/src/ssh.ts
/** Where a daemon with this content lives on the remote machine: named by its SHA-256, so versions never collide. */
function daemonPath(remote: RemotePlatform, sha256: string): string {
	const name = `pi-env-${sha256.slice(0, 32)}`;

This is content addressing, the same idea as Git objects and Nix store paths (Chapter 2). Two versions can never overwrite each other, and a tampered or truncated upload is detected ("pi-env upload is corrupt"). If the remote host has no sha256sum, shasum or openssl, deployment refuses rather than running an unverified binary.

Hardening ssh. The ssh invocation turns off everything an agent should not inherit:

// packages/env/src/ssh.ts (abridged)
...option("BatchMode=yes"),            // never prompt for a password
...option("ClearAllForwardings=yes"),  // no port forwarding
...option("ForwardAgent=no"),          // the remote cannot use your keys
...option("ControlMaster=no"),         // no shared connections
...option("SendEnv=-*"),               // do not leak local environment
...option(`StrictHostKeyChecking=${strictHostKeys ? "yes" : "accept-new"}`),
...option(`HostKeyAlias=${target.hostKeyAlias}`),

Host keys are checked against the application's own known-hosts file under a fixed alias, after a one-time out-of-band fingerprint check (scanHostKey / acceptHostKey). That is trust on first use (TOFU) made explicit: a human compares the fingerprint once, and every later connection must present the same key. Agent forwarding is the dangerous one to leave on. With it, anyone with root on the remote host could use your SSH keys to log into other machines as you.

Finding the protocol in a noisy stream. Shell startup files (.bashrc) may print banners to stdout before the daemon starts. The daemon's first output is therefore a sync line containing a random token from its command line, and the client discards everything before it:

// packages/env/src/connection.ts
// Shell startup files may print before the daemon runs; skip everything before its sync line.
const marker = Buffer.from(`PI-ENV ${session.token}\n`, "utf8");
const all = session.peek(session.buffered);
const index = all.indexOf(marker);

Because the token is random, a banner cannot fake the marker by accident.

Framing. After sync, both directions carry binary frames (packages/env/docs/protocol.md):

u32 length      bytes after this field
u8  type
u32 id          request id; 0 for frames that belong to no request
u32 jsonLength
json            UTF-8 JSON object, jsonLength bytes
bytes           raw payload, the remaining length - 9 - jsonLength bytes

This is length-prefixed framing, the alternative to Chapter 7's newline-delimited JSON. Newline framing cannot carry raw bytes, because a file's contents may contain newlines. A length prefix tells the reader exactly how many bytes to wait for. The hybrid layout, JSON header plus raw payload, keeps metadata readable while file contents and command output travel without base64's 33% overhead. Integers are big-endian ("network byte order"), and frames are capped at 16 MiB so a corrupt length cannot make the reader allocate gigabytes.

The parser is a textbook incremental decoder. It buffers chunks without concatenating each time (peek concatenates only when it must), waits until 4 + length bytes are present, validates, dispatches, consumes and loops. Any malformed frame tears the session down: "framing errors end the connection". Once a byte stream is out of sync, nothing after it can be trusted.

Multiplexing. Each request carries an id, and several requests can be in flight on one connection. Frame types are request, result, error, event (progress such as exec output), cancel and ping. The client keeps a Map<id, pending> and resolves the matching promise on result or error. This is the same request/response correlation pattern as JSON-RPC (Chapter 18) and HTTP/2 streams.

Head-of-line blocking. The daemon prioritises its outgoing queue:

Results, errors, pings and watch events are sent before queued command output, and results with payloads above 64 KiB queue behind output too, so a slow link delays bulk data, not replies.

On one ordered byte stream, a large transfer delays everything behind it. Putting small control messages first is the standard fix, the same idea as QoS priority queues.

Liveness. Both sides ping every 5 seconds. The client treats 30 seconds of silence as a dead connection. The daemon kills every process group it started and exits after 30 seconds without input, or when stdin ends. A dropped Wi-Fi link therefore cannot leave orphaned builds running on the GPU box. This is a heartbeat with a lease.

Requests in flight when the connection drops fail with { code: "unknown", lost: true }. For mutations the outcome is then unknown: the write may or may not have happened. That is the fundamental ambiguity of distributed systems (the Two Generals problem). Durable's answer is the replay policies from Chapter 15, not anything in the transport. The next request starts a fresh daemon. Handles belong to the daemon that opened them, so a stale handle fails instead of silently reaching a different process.

Cancellation without races. "The daemon registers each request before running it, so a cancel sent right after its request is never lost." Without that rule, a cancel could arrive between "request received" and "request registered" and be ignored.

Differential testing. Durable exports registerEnvConformance, a test suite any ExecutionEnv must pass. pi-env runs it against RemoteExecutionEnv. Its test/differential.test.ts also runs random sequences of operations against both the remote and the local implementation and compares the results. With a reference implementation available, this differential, property-based testing finds disagreements that hand-written tests would never think to check.

LocalExecutionEnv reuses all of this: it is NodeExecutionEnv plus listFiles served by a daemon on the same machine. Local and remote listings then come from the same Rust code, which uses the ignore crate from fd and ripgrep.

16.7 Finding tools: BM25

As MCP servers connect, an agent may have hundreds of tools. Sending every schema in every request wastes context (Chapter 10's tool exposure levels). Durable therefore marks some tools as deferred and gives the model a tool_search tool:

// packages/durable/src/tool-search/tool-search.ts
const candidates = (await api.agent(context)).deferred;
const documents = candidates.map((tool) => ({ name: tool.name, text: searchText(tool) }));
const loaded = rankDocuments(query, documents, max);
...
return {
	output: [{ type: "text", text }],
	details,
	...(loaded.length === 0 ? {} : { control: { addTools: loaded } }),
};

The result has two parts. The model reads the text. The control.addTools field changes the conversation's modelTools, so the next request offers the matched tools. Because this writes durable state, loaded tools survive a restart and a compaction. The tool description is a fixed string so that registering tools does not change the system prompt, which would invalidate the prompt cache (Chapter 4).

The ranking function is Okapi BM25, the classic information-retrieval formula behind Lucene and Elasticsearch defaults:

// packages/durable/src/search.ts
/** Okapi BM25 (k1 1.2, b 0.75): the names of the best matches, best first; ties keep document order. */
...
return [term, Math.log(1 + (documents.length - frequency + 0.5) / (frequency + 0.5))] as const;
...
const norm = k1 * (1 - b + (b * lengths[index]!) / averageLength);
score += (idf.get(term) ?? 0) * ((count * (k1 + 1)) / (count + norm));

In words, for each query term:

  • IDF (inverse document frequency) rewards rare terms. "github" appearing in 3 of 200 tools is informative; "file" in 150 is not.
  • Term-frequency saturation count·(k1+1) / (count + norm) grows with repetitions but levels off. A tool that says "issue" ten times is not ten times as relevant.
  • Length normalization (b = 0.75) discounts long documents, which match more terms by chance.

Tokenization splits camelCase (createIssue → create issue), drops stop words, and applies a naive stemmer (issues → issue, searches → search). Each tool's search text is its name, its name with underscores as spaces, its description, and recursively every parameter name and description in its JSON Schema.

16.8 Code mode in Durable

Chapter 15 covered how a code-mode script survives a restart through deterministic replay and its journal. Two practical concerns remain.

What the model sees. The codemode tool's description is fixed. A separate system-prompt section lists callable tools within a token budget (DEFAULT_CODEMODE_INLINE_BUDGET = 3000, estimated at 4 characters per token). selectCatalog fills the budget fairly:

// packages/durable/src/codemode/describe.ts
/**
 * Pick the tool sections that fit the budget: 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.
 */

This is round-robin allocation, cheapest-first within each group. It prevents one MCP server with 80 tools from crowding out a server with three. Tools that do not fit can still be found from inside the script with searchTools(), the same BM25 ranking, or describeTool(name).

What the script can do. Scripts run in QuickJS with "No Node, file system, network, or timers". The only way out is tools.<name>(...), and those calls go through the same ExecutionEnv and replay policies as model-issued tool calls. The env is therefore the single choke point for side effects, which is what makes the sandbox meaningful. Model helpers (models.classify, models.generateImages) are capped at MAX_CONCURRENT_MODEL_CALLS = 4 so a Promise.all over 500 items cannot fire 500 simultaneous API calls.

Concepts in this chapter

  • Capability / dependency injection. Code receives an object granting specific powers instead of reaching for globals. This enables testing with fakes, remote execution, and auditing at one point.
  • Interface segregation. Small interfaces (FileSystem, Shell) composed into larger ones, so dependents need only what they use.
  • Result types. Expected failures as values, checked by the type system; exceptions reserved for bugs and requested aborts.
  • TOCTOU. A race between checking a resource and using it. File handles and O_NOFOLLOW close it by binding to an inode.
  • Process groups and signals. setsid/detached spawn, kill(-pgid), and the 128 + n exit-code convention.
  • Backpressure. A writer waits for drain when the consumer's buffer is full, bounding memory.
  • Sufficient statistics. Summarise omitted data by exactly the numbers the consumer needs (bytes, newlines).
  • Level- vs edge-triggered. Re-observe state on any hint rather than trusting each event; robust to lost events.
  • Debouncing. Collapse a burst of triggers into one action after a quiet period.
  • Content addressing. Name an object by its hash, so versions never collide and integrity is checkable.
  • TOFU host keys. Verify a server key once out of band, then pin it.
  • Length-prefixed framing and multiplexing. Self-delimiting binary messages with request ids, allowing many concurrent requests over one byte stream.
  • Head-of-line blocking. Small urgent messages queued behind bulk data on an ordered stream; fixed by priority queues.
  • Heartbeats and leases. Periodic pings plus a timeout turn silence into a detectable failure.
  • Differential testing. Compare a new implementation against a reference on random inputs.
  • BM25. IDF × saturated term frequency × length normalization.

Exercises

  1. Result ergonomics. Write a helper andThen(result, f) for Result<T, E> that calls f only on success. Rewrite a three-step file operation (stat, read, parse JSON) with it. What do you gain over try/catch, and what do you lose?
  2. Orphan hunting. In a scratch directory, run sh -c 'sleep 100 & sleep 100' through Node's spawn once with detached: false and once with detached: true. Kill the child with process.kill(pid) versus process.kill(-pid), and observe which sleep processes survive with ps -o pid,pgid,cmd.
  3. Quiescence. Construct a command whose exit event fires long before its stdout closes. Hint: a background grandchild that inherits stdout. Explain why waitForChildProcess needs both the 100 ms grace timer and the spillIsDraining check.
  4. Lost events. Write a naive watcher that keeps a Set of changed paths from fs.watch events. Use mv dir dir2 && mkdir dir && echo x > dir/f to show a change it misses. Then explain step by step why the snapshot design reports it.
  5. Frame parser. Implement the pi-env frame encoder and an incremental decoder that accepts arbitrary chunk splits. Property-test it: for random frames and random split points, decode(concat(splits)) equals the frames.
  6. BM25 by hand. For three tools — github_create_issue, github_list_issues, fs_read_file — compute the BM25 score of the query "create github issue". Then change b to 0 and to 1 and explain the effect.
  7. Design. The output window lets a remote env skip output the caller would drop. Argue whether the skipped text should still be written to the spill file, and check node.ts and protocol.md to see what the implementation does.

Source files for this chapter

Every quote is checked against these files at 4954d76. A change under packages/durable, packages/env flags this chapter for review.