AgentCanvas / Pages / Developer Guide / Design Docs / Surfaces / SDK Record / Replay
2026-07-12

A graph whose env / model nodes are server-mode normally needs a GPU, one conda env per nodeset, and downloaded weights. Record / replay removes all of that from the test: g.run(record=…) tees every server-node result of one real run into a cassette file; g.run(replay=…) then runs the same graph offline, answering each server call from the recording while local pure-Python nodes run for real. The hard promise is directional: a drift in the graph's topology or port interfaces away from what was recorded fails loudly (ReplayExhausted / GraphValidationError) — but replay does not re-verify the model outputs themselves (§6). Feature-level overview: Graph SDK capability page; the proxy seam being wrapped: server-mode.

RECORD — one real run (GPU + conda envs) GraphExecutor graph_executor.py · _fire_node proxy forward + Recorder tee proxy.py · _make_forward / Recorder.wrap auto_host server subprocess nodeset's conda env · GPU HTTP /call · msgpack append {node_type, result} per call, in order cassette .mpk — {version, manifests, calls} graph_sdk_replay.py · dump_cassette = pack_body → zlib written on a clean run only manifests read off the proxy classes _synthesize_manifests REPLAY — offline (no servers, no GPU, no conda envs) Graph.run(replay=…) graph_sdk.py · _drive skips nodeset loading entirely load_cassette — version check Player.install — rebuild proxies generate_proxy_nodes("http://replay.invalid") forward = queue.popleft() per-node_type FIFO deque local (non-__) nodes untouched run for real — wiring + ports exercised empty queue → ReplayExhausted

1. What it does

Every server-mode node call in a graph passes through one seam: the proxy class's forward(), generated by proxy.py · _make_forward, which POSTs to the nodeset's server subprocess and returns the decoded result. Record / replay treats that seam as the recording surface. During a real run, Recorder replaces each server-node class's forward with a tee that appends {node_type, result} to a list, in call order. On a clean finish, the list is written to a cassette together with a manifest of every recorded node type's ports and config schema. On replay, Player rebuilds those proxy classes from the saved manifests — no server, the base URL is the deliberately unresolvable http://replay.invalid — and replaces their forward with "pop the next recorded result for this node type". Everything else is the ordinary engine: the same GraphExecutor, and every local node's real forward.

The point is not to fake the graph — it is to run the real wiring, port interfaces, and local-node code against pinned server outputs. What the simulator and the model said once, they now say forever; what the graph does with it is re-executed every time.

2. The smallest replay

Three lines, runnable on a machine with none of the graph's environments (this is examples/07_habitat_sam.py's graph — Habitat in ac-vlnce, SAM in ac-fm, two GPUs' worth of servers in the real run):

g = build()                                    # the same builder the real run used
r = g.run(replay="tests/cassettes/habitat_sam_first_frame.mpk")
assert r["num_masks"] == 35                    # no GPU, no conda envs, no servers

The cassette is produced once, on a machine that does have the environments (this is a real GPU run):

# one-time, on a machine with the real envs (a real GPU run):
python examples/07_habitat_sam.py --record tests/cassettes/habitat_sam_first_frame.mpk

3. The cassette

A cassette is one file: a msgpack-encoded dict, zlib-compressed (level 9). The codec is serialization.pack_body — the same lossless encoding the proxy uses on the wire — so ndarray / torch / PIL results survive the round trip byte-exact (graph_sdk_replay.py · dump_cassette / load_cassette).

KeyContentUsed for
versionCASSETTE_VERSION = 1load_cassette hard-rejects any other value ("regenerate it")
manifestsone ServerManifest dict per recorded nodeset — every node type's input/output ports + config schema, read off the live proxy classes by _synthesize_manifestsrebuilding the proxy classes offline, so replay needs no workspace scan and no server
calls[{node_type, result}, …] in call orderthe per-node-type FIFO queues the replayed forwards pop from

The shipped Habitat→SAM cassette (tests/cassettes/habitat_sam_first_frame.mpk) is 525 KiB compressed — four server calls, one of them a 35-mask SAM envelope.

4. Record path

Record wraps only server-node classes, only for the duration of the run, and writes only on success. Graph.run(record=…) collects the graph's server node types — every NodeDef.type containing "__" (graph_sdk.py · _drive) — and Recorder.wrap swaps each class's forward in the global NODE_HANDLERS for a tee that awaits the original and appends the result. The run itself is a completely ordinary real run: nodesets load, servers spawn, GPU admission applies. In the finally block the order is fixed: unwrap() first (restore the original forwards, so manifests are synthesised off un-mutated classes), then write() — and only if the run reached success, so a crash mid-run never leaves a truncated cassette behind.

5. Replay path

Replay never touches the workspace registry. replay=… short-circuits nodeset loading entirely (graph_sdk.py · _drive: the load branch is skipped when replay is not None), so no scan, no conda-env resolution, no subprocess. Player.install then does two things (graph_sdk_replay.py · Player.install):

  1. Rebuild missing proxy classes — for each saved manifest, proxy.generate_proxy_nodes("http://replay.invalid", manifest) produces the same proxy classes a live server would have yielded, and any class the cassette needs that isn't already in NODE_HANDLERS is register_node()'d. The bogus URL is intentional: the rebuilt forward is replaced in step 2, so nothing ever dials it.
  2. Override forward on every replayed node type — rebuilt or pre-existing — with a closure that pops the next result from that node type's FIFO deque, or raises ReplayExhausted when the queue is empty.

Player.uninstall runs in the finally: pre-existing classes get their original forward back; classes the Player registered are removed from NODE_HANDLERS.

6. What fails loudly — and what doesn't

Replay is an order-based contract: it checks that the graph fires the same server-call sequence it fired when recorded, not that the inputs are the same. Concretely:

DriftBehaviour on replay
Graph fires more calls of a node type than recordedReplayExhausted from that node's forward — surfaces as a node error (§7)
Graph uses a node type the cassette has no manifest forunknown node type → GraphValidationError from the default-on check
Port renamed / removed on a local nodethe local node runs for real — its real failure mode fires
Cassette from an older CASSETTE_VERSIONValueError("regenerate it") at load_cassette
record= and replay= passed togetherValueError — mutually exclusive (graph_sdk.py · run)
Graph fires fewer calls than recordedwarning at the end of a clean run — leftover results are named and discarded, the run still passes (§7)
Same call order, different inputssilent — inputs are not matched against the recording (§7)

7. Where it deviates from the mental model

"A recording of the run" suggests more fidelity than the mechanism provides. Four seams:

Order-based, not input-matched. Results are keyed by node_type alone, FIFO. Unlike HTTP-VCR tools that match requests, the inputs of a replayed call are never compared to what was recorded — a graph that fires the same node types in the same order with different inputs replays "green" with stale data. Two node instances of the same type share one queue.
Under-consumption warns, but does not fail. ReplayExhausted only fires on over-consumption — a graph that makes fewer server calls than the cassette holds (e.g. a branch got disconnected) has no forward to raise in. A clean run ends with a warning naming the leftover results per node type (graph_sdk_replay.py · Player.leftover, logged by Graph.run); the run itself still passes, so a test that only checks outputs can miss this drift.
Exhaustion does not raise out of run(). ReplayExhausted is raised inside the node's forward; the executor records it as a node error and completes the run with that node's outputs missing. Assert on the event stream or the outputs — test_graph_sdk.py · test_replay_exhausted_on_extra_call collects on_event and checks for a node_error event, not pytest.raises.
Global, single-run state. Both Recorder and Player mutate class attributes in the global NODE_HANDLERS for the duration of one run and restore them in finally. One in-process record-or-replay run at a time; concurrent runs would tee/pop each other's calls.

8. CI usage

The recorded cassette is committed (tests/cassettes/habitat_sam_first_frame.mpk), and the guard test skips rather than fails when it is absent — so the suite passes with or without the artifact, and runs the full offline replay when it is there (it does, in CI):

@pytest.mark.skipif(not _cassette_path().exists(), reason="habitat-sam cassette not present")
def test_habitat_sam_replay():
    g = _load_example07().build()
    r = g.run(replay=str(_cassette_path()))
    assert r["num_masks"] == 35        # recorded SAM output flows through the real overlay

Regeneration is deliberate, not automatic: rerun the example's --record flag on a machine with the real environments whenever the recorded interfaces genuinely change, and commit the new cassette. A version bump of CASSETTE_VERSION invalidates every existing cassette at load time.

9. Key files

FileRole
app/graph_sdk_replay.pyRecorder / Player / dump_cassette / load_cassette / _synthesize_manifests / ReplayExhausted — the whole mechanism, zero edits to proxy / registry / executor
app/graph_sdk.py · Graph.run / _driverecord= / replay= orchestration: mutual exclusion, load-skip on replay, unwrap-then-write-on-success
app/server/proxy.py · _make_forward / generate_proxy_nodesthe seam being teed, and the factory replay reuses to rebuild proxy classes
app/server/serialization.py · pack_body / unpack_bodythe lossless codec cassettes persist through
app/test_graph_sdk.py · tests/cassettes/round-trip / exhaustion / mutual-exclusion tests + the committed Habitat→SAM cassette and its CI guard
AgentCanvas docs