@yaag/runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +25 -0
  2. package/src/agent-names.ts +20 -0
  3. package/src/agent-usage.ts +72 -0
  4. package/src/agent.ts +130 -0
  5. package/src/args-validation.ts +11 -0
  6. package/src/ask-activity.ts +84 -0
  7. package/src/ask-contract-identity.ts +96 -0
  8. package/src/ask-exchange-events.ts +60 -0
  9. package/src/ask-exchange-options.ts +32 -0
  10. package/src/ask-exchange.ts +291 -0
  11. package/src/ask-hash.ts +86 -0
  12. package/src/ask-limit.ts +189 -0
  13. package/src/ask-output-steering.ts +69 -0
  14. package/src/ask-output-tail.ts +166 -0
  15. package/src/ask-output.ts +109 -0
  16. package/src/ask-settlement.ts +37 -0
  17. package/src/ask-turn.ts +70 -0
  18. package/src/cassette-loader.ts +131 -0
  19. package/src/cassette-publish.ts +55 -0
  20. package/src/cassette-replay.ts +178 -0
  21. package/src/cassette-schema.ts +152 -0
  22. package/src/cassette.ts +275 -0
  23. package/src/checkpoint-dir.ts +89 -0
  24. package/src/connection.ts +123 -0
  25. package/src/define-agent.ts +83 -0
  26. package/src/define-run.ts +69 -0
  27. package/src/errors.ts +115 -0
  28. package/src/events.ts +143 -0
  29. package/src/extension-package.ts +88 -0
  30. package/src/extension-paths.ts +66 -0
  31. package/src/extension-source.ts +60 -0
  32. package/src/fake-transport.ts +240 -0
  33. package/src/frame-gap.ts +41 -0
  34. package/src/frame-queue.ts +52 -0
  35. package/src/git-facts.ts +32 -0
  36. package/src/idle-watch.ts +154 -0
  37. package/src/index.ts +96 -0
  38. package/src/jsonl.ts +42 -0
  39. package/src/live-transport.ts +210 -0
  40. package/src/node-decoder-subagent.ts +67 -0
  41. package/src/node-decoder-workflow.ts +74 -0
  42. package/src/node-decoder.ts +23 -0
  43. package/src/node-decoders.ts +9 -0
  44. package/src/node-details.ts +70 -0
  45. package/src/node-path.ts +36 -0
  46. package/src/node-tracker.ts +143 -0
  47. package/src/pi-state.ts +108 -0
  48. package/src/prompt-gist.ts +13 -0
  49. package/src/prompt.ts +80 -0
  50. package/src/reap.ts +59 -0
  51. package/src/recording-transport.ts +97 -0
  52. package/src/replay-divergence.ts +155 -0
  53. package/src/replay-transport.ts +72 -0
  54. package/src/resume-preconditions.ts +59 -0
  55. package/src/resume-transport.ts +165 -0
  56. package/src/run-checkpoint.ts +93 -0
  57. package/src/run-context.ts +19 -0
  58. package/src/run.ts +274 -0
  59. package/src/skill-probe.ts +247 -0
  60. package/src/skill-restriction-transport.ts +76 -0
  61. package/src/spawn.ts +241 -0
  62. package/src/summary-agent.ts +310 -0
  63. package/src/summary-nodes.ts +77 -0
  64. package/src/summary.ts +213 -0
  65. package/src/tool-probe-extension.ts +17 -0
  66. package/src/tool-probe.ts +141 -0
  67. package/src/transport.ts +178 -0
  68. package/src/types.ts +130 -0
  69. package/src/validation-errors.ts +70 -0
  70. package/src/wire-constants.ts +24 -0
  71. package/src/worktree-transport.ts +125 -0
package/src/jsonl.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { Frame } from "./transport.ts";
2
+
3
+ /**
4
+ * Decodes a byte stream into frames using pi's strict JSONL framing: split on
5
+ * `\n` only, strip a trailing `\r`. A generic line reader is not
6
+ * protocol-compliant, because U+2028/U+2029 are valid inside JSON strings
7
+ * (pi `docs/rpc.md`, framing note).
8
+ *
9
+ * Non-JSON lines and JSON that is not a frame object are skipped rather than
10
+ * thrown on: pi emits undocumented frame types and occasional noise (ticket 01).
11
+ */
12
+ export async function* decodeFrames(chunks: AsyncIterable<Uint8Array>): AsyncGenerator<Frame> {
13
+ const decoder = new TextDecoder();
14
+ let buffer = "";
15
+ for await (const chunk of chunks) {
16
+ buffer += decoder.decode(chunk, { stream: true });
17
+ let newline = buffer.indexOf("\n");
18
+ while (newline !== -1) {
19
+ const frame = parseFrame(buffer.slice(0, newline));
20
+ if (frame) yield frame;
21
+ buffer = buffer.slice(newline + 1);
22
+ newline = buffer.indexOf("\n");
23
+ }
24
+ }
25
+ const last = parseFrame(buffer);
26
+ if (last) yield last;
27
+ }
28
+
29
+ /** Returns null for anything that is not a JSON object with a string `type`. */
30
+ export function parseFrame(line: string): Frame | null {
31
+ const text = line.endsWith("\r") ? line.slice(0, -1) : line;
32
+ if (text.trim() === "") return null;
33
+ let value: unknown;
34
+ try {
35
+ value = JSON.parse(text);
36
+ } catch {
37
+ return null;
38
+ }
39
+ if (typeof value !== "object" || value === null) return null;
40
+ if (!("type" in value) || typeof value.type !== "string") return null;
41
+ return value as Frame; // narrowed above: an object with a string `type`
42
+ }
@@ -0,0 +1,210 @@
1
+ import { YaagError } from "./errors.ts";
2
+ import { FrameQueue } from "./frame-queue.ts";
3
+ import { decodeFrames } from "./jsonl.ts";
4
+ import { piCommand, readModel, readSessionFile, readStats } from "./pi-state.ts";
5
+ import { reap } from "./reap.ts";
6
+ import {
7
+ createToolProbe,
8
+ requestedToolNames,
9
+ TOOL_PROBE_EXTENSION_PATH,
10
+ type ToolProbe,
11
+ verifyEffectiveTools,
12
+ } from "./tool-probe.ts";
13
+ import type {
14
+ AgentStats,
15
+ AgentTransport,
16
+ AskMarker,
17
+ Frame,
18
+ OpenOptions,
19
+ TransportFactory,
20
+ TransportStartupObserver,
21
+ } from "./transport.ts";
22
+
23
+ /** How long the startup `get_state` round-trip may take before spawn fails. */
24
+ const READY_TIMEOUT_MS = 30_000;
25
+ /** Cost is a local read answering in ~0ms, but the kill must never block on it. */
26
+ const STATS_TIMEOUT_MS = 2_000;
27
+
28
+ /** Agents are real `pi --mode rpc` child processes (ADR-0001). */
29
+ export const liveTransport: TransportFactory = {
30
+ async open(
31
+ options: OpenOptions,
32
+ observeStartup?: TransportStartupObserver,
33
+ ): Promise<AgentTransport> {
34
+ const transport = new LiveTransport(options);
35
+ await transport.ready(observeStartup);
36
+ return transport;
37
+ },
38
+ };
39
+
40
+ class LiveTransport implements AgentTransport {
41
+ model = "";
42
+
43
+ readonly #name: string;
44
+ readonly #process: Bun.Subprocess<"pipe", "pipe", "ignore">;
45
+ readonly #queue = new FrameQueue();
46
+ readonly #pending = new Map<string, (frame: Frame) => void>();
47
+ readonly #toolProbe: ToolProbe | null;
48
+ readonly #promisedTools: readonly string[] | null;
49
+ #nextId = 0;
50
+ #closing: Promise<AgentStats> | null = null;
51
+
52
+ constructor(options: OpenOptions) {
53
+ this.#name = options.name;
54
+ this.#promisedTools = requestedToolNames(options.tools, options.disallowedTools);
55
+ this.#toolProbe = createToolProbe(options.tools, options.disallowedTools);
56
+ this.#process = Bun.spawn({
57
+ cmd: piCommand(options, this.#toolProbe ? TOOL_PROBE_EXTENSION_PATH : undefined),
58
+ cwd: options.cwd,
59
+ stdin: "pipe",
60
+ stdout: "pipe",
61
+ stderr: "ignore",
62
+ // Its own process group, so the Agent sees stdin EOF rather than a signal
63
+ // when the Orchestrator dies — EOF is the path that reaps grandchildren.
64
+ detached: true,
65
+ });
66
+ void this.#read();
67
+ }
68
+
69
+ /** Confirms the Agent is up and speaking the protocol, and resolves its model. */
70
+ async ready(observeStartup?: TransportStartupObserver): Promise<void> {
71
+ try {
72
+ const state = await this.#command({ type: "get_state" }, READY_TIMEOUT_MS);
73
+ const model = readModel(state);
74
+ if (!model) throw new Error(`agent "${this.#name}" reported no model`);
75
+ if (this.#toolProbe && this.#promisedTools) {
76
+ verifyEffectiveTools(this.#promisedTools, await this.#toolProbe.wait());
77
+ }
78
+ this.model = model;
79
+ const sessionFile = readSessionFile(state);
80
+ observeStartup?.(sessionFile === null ? {} : { sessionFile });
81
+ } catch (error) {
82
+ // Startup has not returned this transport to spawn.ts, so cleanup belongs here.
83
+ // A secondary stats or reap failure must not hide the startup diagnostic.
84
+ try {
85
+ await this.close();
86
+ } catch {
87
+ // The original startup error remains the useful failure.
88
+ }
89
+ if (error instanceof YaagError) throw error;
90
+ const message = error instanceof Error ? error.message : "agent startup failed";
91
+ throw new YaagError("SPAWN_FAILED", message, this.#name);
92
+ }
93
+ }
94
+
95
+ send(frame: Frame): void {
96
+ if (this.#closing) return;
97
+ this.#process.stdin.write(`${JSON.stringify(frame)}\n`);
98
+ this.#process.stdin.flush();
99
+ }
100
+
101
+ frames(): AsyncIterable<Frame> {
102
+ return this.#queue.frames();
103
+ }
104
+
105
+ beginAsk(_marker: AskMarker): undefined {
106
+ // Nothing to do live; a recording implementation groups frames by this.
107
+ return undefined;
108
+ }
109
+
110
+ finishAsk(): void {
111
+ // Live transport has no Cassette outcome to complete.
112
+ }
113
+
114
+ close(): Promise<AgentStats> {
115
+ this.#closing ??= this.#shutdown();
116
+ return this.#closing;
117
+ }
118
+
119
+ async #shutdown(): Promise<AgentStats> {
120
+ // Stats first: they are unavailable once the process is gone (ticket 02).
121
+ const stats = await this.#readStats();
122
+ const pid = this.#process.pid;
123
+ await reap({
124
+ exited: this.#process.exited,
125
+ endStdin: () => this.#process.stdin.end(),
126
+ terminate: () => this.#process.kill("SIGTERM"),
127
+ destroyGroup: () => {
128
+ try {
129
+ process.kill(-pid, "SIGKILL");
130
+ } catch {
131
+ this.#process.kill("SIGKILL");
132
+ }
133
+ },
134
+ });
135
+ this.#queue.end();
136
+ this.#toolProbe?.fail(
137
+ new Error(`agent "${this.#name}" exited before tool verification completed`),
138
+ );
139
+ return stats;
140
+ }
141
+
142
+ async #readStats(): Promise<AgentStats> {
143
+ try {
144
+ const response = await this.#command({ type: "get_session_stats" }, STATS_TIMEOUT_MS);
145
+ return readStats(response);
146
+ } catch {
147
+ return { tokens: null, cost: null };
148
+ }
149
+ }
150
+
151
+ /** A command the transport issues for itself; its response never reaches the seam. */
152
+ #command(frame: Frame, timeoutMs: number): Promise<Frame> {
153
+ const id = `yaag-transport-${this.#nextId++}`;
154
+ const response = new Promise<Frame>((resolve) => {
155
+ this.#pending.set(id, resolve);
156
+ });
157
+ this.#process.stdin.write(`${JSON.stringify({ ...frame, id })}\n`);
158
+ this.#process.stdin.flush();
159
+
160
+ let timer: ReturnType<typeof setTimeout> | undefined;
161
+ const failure = new Promise<never>((_resolve, reject) => {
162
+ timer = setTimeout(
163
+ () =>
164
+ reject(
165
+ new YaagError(
166
+ "SPAWN_FAILED",
167
+ `agent "${this.#name}" did not answer ${frame.type}`,
168
+ this.#name,
169
+ ),
170
+ ),
171
+ timeoutMs,
172
+ );
173
+ });
174
+ const death = this.#process.exited.then((code) => {
175
+ throw new YaagError("SPAWN_FAILED", `agent "${this.#name}" exited (${code})`, this.#name);
176
+ });
177
+ return Promise.race([response, failure, death]).finally(() => clearTimeout(timer));
178
+ }
179
+
180
+ async #read(): Promise<void> {
181
+ for await (const frame of decodeFrames(readChunks(this.#process.stdout))) {
182
+ if (this.#toolProbe?.accept(frame)) continue;
183
+ const id = typeof frame.id === "string" ? frame.id : "";
184
+ const waiting = frame.type === "response" ? this.#pending.get(id) : undefined;
185
+ if (waiting) {
186
+ this.#pending.delete(id);
187
+ waiting(frame);
188
+ continue;
189
+ }
190
+ this.#queue.push(frame);
191
+ }
192
+ this.#queue.end();
193
+ this.#toolProbe?.fail(
194
+ new Error(`agent "${this.#name}" exited before tool verification completed`),
195
+ );
196
+ }
197
+ }
198
+
199
+ async function* readChunks(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
200
+ const reader = stream.getReader();
201
+ try {
202
+ for (;;) {
203
+ const { done, value } = await reader.read();
204
+ if (done) return;
205
+ if (value) yield value;
206
+ }
207
+ } finally {
208
+ reader.releaseLock();
209
+ }
210
+ }
@@ -0,0 +1,67 @@
1
+ import type { NodeState } from "./events.ts";
2
+ import type { DecodedNode, NodeDecoder } from "./node-decoder.ts";
3
+ import { isRecord, nodeGist, nodeUsage } from "./node-details.ts";
4
+
5
+ /**
6
+ * Decodes the reference `subagent` tool's `SubagentDetails`: one Nested Node per
7
+ * `results[]` entry, in the order the tool reports them. `exitCode === -1` means
8
+ * "still running" in that tool, so it stays a running node.
9
+ */
10
+ export const subagentNodeDecoder: NodeDecoder = {
11
+ id: "subagent",
12
+ matches(toolName: string): boolean {
13
+ return toolName === "subagent";
14
+ },
15
+ decode(details: unknown): readonly DecodedNode[] | null {
16
+ if (!isRecord(details) || !Array.isArray(details.results)) return null;
17
+ const nodes: DecodedNode[] = [];
18
+ for (const entry of details.results) {
19
+ const node = decodeResult(entry);
20
+ if (node !== null) nodes.push(node);
21
+ }
22
+ return nodes;
23
+ },
24
+ };
25
+
26
+ function decodeResult(entry: unknown): DecodedNode | null {
27
+ if (!isRecord(entry) || typeof entry.agent !== "string") return null;
28
+ const state = resultState(entry);
29
+ const gist = resultGist(entry);
30
+ const usage = nodeUsage(entry.usage);
31
+ return {
32
+ segments: [entry.agent],
33
+ state,
34
+ ...(gist === undefined ? {} : { activityGist: gist }),
35
+ ...(usage === undefined ? {} : { usage }),
36
+ };
37
+ }
38
+
39
+ function resultState(entry: Readonly<Record<string, unknown>>): NodeState {
40
+ const exitCode = typeof entry.exitCode === "number" ? entry.exitCode : -1;
41
+ const stopReason = typeof entry.stopReason === "string" ? entry.stopReason : undefined;
42
+ if (stopReason === "error" || stopReason === "aborted") return "failed";
43
+ if (exitCode < 0) return "running";
44
+ return exitCode === 0 ? "exited" : "failed";
45
+ }
46
+
47
+ function resultGist(entry: Readonly<Record<string, unknown>>): string | undefined {
48
+ const failure =
49
+ typeof entry.errorMessage === "string" && entry.errorMessage !== ""
50
+ ? entry.errorMessage
51
+ : undefined;
52
+ const text = failure ?? lastAssistantText(entry.messages);
53
+ return text === undefined ? undefined : nodeGist(text);
54
+ }
55
+
56
+ function lastAssistantText(messages: unknown): string | undefined {
57
+ if (!Array.isArray(messages)) return undefined;
58
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
59
+ const message: unknown = messages[index];
60
+ if (!isRecord(message) || message.role !== "assistant") continue;
61
+ if (!Array.isArray(message.content)) continue;
62
+ for (const part of message.content) {
63
+ if (isRecord(part) && part.type === "text" && typeof part.text === "string") return part.text;
64
+ }
65
+ }
66
+ return undefined;
67
+ }
@@ -0,0 +1,74 @@
1
+ import type { NodeState } from "./events.ts";
2
+ import type { DecodedNode, NodeDecoder } from "./node-decoder.ts";
3
+ import { isRecord, nodeGist, nodeUsage } from "./node-details.ts";
4
+
5
+ /**
6
+ * Decodes pi-extensible-workflows' progress shape: `details.run.agents[]`, each
7
+ * an `AgentRecord` with a state, an optional structural path, activity and
8
+ * accounting.
9
+ *
10
+ * Tool-name matching is best-effort (spec §3): a workflow tool may register
11
+ * under `workflow` or a `workflow_`-prefixed name, and a tool that matches but
12
+ * reports another shape still yields no nodes. A backgrounded run
13
+ * carries no `run`, so it yields no nodes — its facts leave the RPC stream.
14
+ */
15
+ export const workflowNodeDecoder: NodeDecoder = {
16
+ id: "workflow",
17
+ matches(toolName: string): boolean {
18
+ return toolName === "workflow" || toolName.startsWith("workflow_");
19
+ },
20
+ decode(details: unknown): readonly DecodedNode[] | null {
21
+ if (!isRecord(details)) return null;
22
+ const run: unknown = details.run;
23
+ if (!isRecord(run)) return typeof details.runId === "string" ? [] : null;
24
+ if (!Array.isArray(run.agents)) return [];
25
+ const nodes: DecodedNode[] = [];
26
+ for (const entry of run.agents) {
27
+ const node = decodeAgent(entry);
28
+ if (node !== null) nodes.push(node);
29
+ }
30
+ return nodes;
31
+ },
32
+ };
33
+
34
+ const NODE_STATE_BY_AGENT_STATE: Readonly<Record<string, NodeState>> = {
35
+ queued: "running",
36
+ running: "running",
37
+ waiting_for_child: "running",
38
+ paused: "running",
39
+ retrying: "running",
40
+ completed: "exited",
41
+ failed: "failed",
42
+ cancelled: "failed",
43
+ };
44
+
45
+ function decodeAgent(entry: unknown): DecodedNode | null {
46
+ if (!isRecord(entry) || typeof entry.name !== "string") return null;
47
+ if (typeof entry.state !== "string") return null;
48
+ const state = NODE_STATE_BY_AGENT_STATE[entry.state];
49
+ if (state === undefined) return null;
50
+ const gist = agentGist(entry);
51
+ const usage = nodeUsage(entry.accounting);
52
+ return {
53
+ segments: segments(entry),
54
+ state,
55
+ ...(gist === undefined ? {} : { activityGist: gist }),
56
+ ...(usage === undefined ? {} : { usage }),
57
+ };
58
+ }
59
+
60
+ function segments(entry: Readonly<Record<string, unknown>>): readonly string[] {
61
+ const name = typeof entry.label === "string" && entry.label !== "" ? entry.label : entry.name;
62
+ if (typeof name !== "string") return [];
63
+ if (!Array.isArray(entry.structuralPath)) return [name];
64
+ const parents = entry.structuralPath.filter(
65
+ (part: unknown): part is string => typeof part === "string" && part !== "",
66
+ );
67
+ return [...parents, name];
68
+ }
69
+
70
+ function agentGist(entry: Readonly<Record<string, unknown>>): string | undefined {
71
+ const activity: unknown = entry.activity;
72
+ if (!isRecord(activity) || typeof activity.text !== "string") return undefined;
73
+ return nodeGist(activity.text);
74
+ }
@@ -0,0 +1,23 @@
1
+ import type { NodeState, NodeUsage } from "./events.ts";
2
+
3
+ /** One decoded Nested Node, relative to the tool call that produced it. */
4
+ export interface DecodedNode {
5
+ /** Names from the tool call downwards, e.g. `["test-writer", "1", "fixture-gen"]`. */
6
+ readonly segments: readonly string[];
7
+ readonly state: NodeState;
8
+ readonly activityGist?: string;
9
+ readonly usage?: NodeUsage;
10
+ }
11
+
12
+ /**
13
+ * Knows one nesting tool's detail shape.
14
+ *
15
+ * `matches` is best-effort tool-name matching, not a protocol guarantee, so an
16
+ * unrecognised tool yields no decoder and therefore no nodes. `decode` returns
17
+ * null when the details do not carry this tool's shape, and never throws.
18
+ */
19
+ export interface NodeDecoder {
20
+ readonly id: string;
21
+ matches(toolName: string): boolean;
22
+ decode(details: unknown): readonly DecodedNode[] | null;
23
+ }
@@ -0,0 +1,9 @@
1
+ import type { NodeDecoder } from "./node-decoder.ts";
2
+ import { subagentNodeDecoder } from "./node-decoder-subagent.ts";
3
+ import { workflowNodeDecoder } from "./node-decoder-workflow.ts";
4
+
5
+ /** The decoders the runtime consults, in order, for every observed tool call. */
6
+ export const DEFAULT_NODE_DECODERS: readonly NodeDecoder[] = [
7
+ subagentNodeDecoder,
8
+ workflowNodeDecoder,
9
+ ];
@@ -0,0 +1,70 @@
1
+ import type { NodeUsage } from "./events.ts";
2
+ import type { TokenBreakdown } from "./transport.ts";
3
+ import { NODE_GIST_MAX_CHARS } from "./wire-constants.ts";
4
+
5
+ /**
6
+ * Reading untrusted nesting-tool detail payloads. Every decoder narrows from
7
+ * `unknown` through here, so a malformed payload is skipped, never thrown on.
8
+ */
9
+
10
+ /** Narrows a detail payload to a plain object, the shape every decoder needs. */
11
+ export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+
15
+ /**
16
+ * Flattens decoded text to one capped line for a Nested Node's `activityGist`.
17
+ * Prompts never reach this function (ticket 09).
18
+ */
19
+ export function nodeGist(value: string): string {
20
+ return value.replaceAll(/\s+/g, " ").trim().slice(0, NODE_GIST_MAX_CHARS);
21
+ }
22
+
23
+ /**
24
+ * Reads one nesting tool's accounting record into a Nested Node's `usage`.
25
+ *
26
+ * `NodeUsage` holds optional facts, so this function emits validated facts
27
+ * only. It emits a token breakdown when all four counts are finite and not
28
+ * negative, and it emits a cost when the cost is finite and not negative. It
29
+ * emits each fact independently, and it returns `undefined` when the record
30
+ * holds no valid fact. It never reports a missing or malformed field as zero.
31
+ */
32
+ export function nodeUsage(value: unknown): NodeUsage | undefined {
33
+ if (!isRecord(value)) return undefined;
34
+ const tokens = tokenBreakdown(value);
35
+ const cost = count(value.cost);
36
+ if (tokens === undefined && cost === undefined) return undefined;
37
+ return {
38
+ ...(tokens === undefined ? {} : { tokens }),
39
+ ...(cost === undefined ? {} : { cost }),
40
+ };
41
+ }
42
+
43
+ function tokenBreakdown(value: Readonly<Record<string, unknown>>): TokenBreakdown | undefined {
44
+ const input = count(value.input);
45
+ const output = count(value.output);
46
+ const cacheRead = count(value.cacheRead);
47
+ const cacheWrite = count(value.cacheWrite);
48
+ if (
49
+ input === undefined ||
50
+ output === undefined ||
51
+ cacheRead === undefined ||
52
+ cacheWrite === undefined
53
+ ) {
54
+ return undefined;
55
+ }
56
+ const reported = count(value.total);
57
+ return {
58
+ input,
59
+ output,
60
+ cacheRead,
61
+ cacheWrite,
62
+ total: reported ?? input + output + cacheRead + cacheWrite,
63
+ };
64
+ }
65
+
66
+ /** A finite, not-negative number, or `undefined` for every other value. */
67
+ function count(value: unknown): number | undefined {
68
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
69
+ return value;
70
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The Nested Node path grammar `name(:askIndex(/childName…))` (spec §1).
3
+ *
4
+ * The Ask index is the wire index carried by `ask_start.index` — 0-based, so a
5
+ * path always equals a fact already on the wire. Renderers keep displaying
6
+ * `#index + 1`; they never re-derive identity from the display form.
7
+ */
8
+
9
+ /** One Nested Node's identity, e.g. `dev:3/test-writer:1/fixture-gen`. */
10
+ export type NodePath = string;
11
+
12
+ /** Builds the path of one Agent's Ask from its name and the wire Ask index. */
13
+ export function agentAskPath(agent: string, askIndex: number): NodePath {
14
+ return `${sanitizeNodeName(agent)}:${askIndex}`;
15
+ }
16
+
17
+ /** Appends one child segment below a parent path, sanitizing the child name. */
18
+ export function childPath(parent: NodePath, childName: string): NodePath {
19
+ return `${parent}/${sanitizeNodeName(childName)}`;
20
+ }
21
+
22
+ /**
23
+ * Removes the path separators `:` and `/`, control characters, and newlines
24
+ * from an untrusted node name, so a decoded name cannot forge another node's
25
+ * path. Returns `node` when nothing printable remains.
26
+ */
27
+ export function sanitizeNodeName(name: string): string {
28
+ let text = "";
29
+ for (const character of name) {
30
+ const code = character.codePointAt(0) ?? 0;
31
+ if (code < 32 || code === 127 || (code >= 0x80 && code <= 0x9f)) continue;
32
+ text += character === ":" || character === "/" ? "-" : character;
33
+ }
34
+ const collapsed = text.replaceAll(/\s+/g, " ").trim();
35
+ return collapsed === "" ? "node" : collapsed;
36
+ }
@@ -0,0 +1,143 @@
1
+ import type { NodeState, NodeUsage } from "./events.ts";
2
+ import type { DecodedNode, NodeDecoder } from "./node-decoder.ts";
3
+ import { DEFAULT_NODE_DECODERS } from "./node-decoders.ts";
4
+ import { isRecord } from "./node-details.ts";
5
+ import { sanitizeNodeName } from "./node-path.ts";
6
+ import type { Frame } from "./transport.ts";
7
+
8
+ /** One Nested Node observation, relative to the tool call that produced it. */
9
+ export interface NodeSnapshot {
10
+ readonly segments: readonly string[];
11
+ readonly state: NodeState;
12
+ readonly activityGist?: string;
13
+ readonly usage?: NodeUsage;
14
+ }
15
+
16
+ interface TrackedCall {
17
+ readonly decoder: NodeDecoder;
18
+ /** First-seen disambiguation of duplicate child names inside one tool call. */
19
+ readonly names: Map<string, string>;
20
+ readonly reported: Map<string, NodeSnapshot>;
21
+ }
22
+
23
+ export interface NodeTrackerOptions {
24
+ readonly report: (node: NodeSnapshot) => void;
25
+ readonly decoders?: readonly NodeDecoder[];
26
+ }
27
+
28
+ /**
29
+ * Reports Nested Node snapshots decoded from an Agent's own `tool_execution_*`
30
+ * frames — a sibling of `AskActivityTracker`, above the transport seam, so it
31
+ * knows frames only (architecture §2).
32
+ *
33
+ * Coalescing is dedupe-only, with no timer: a snapshot is reported only when it
34
+ * differs from the last one reported for that node. That keeps the event stream
35
+ * a pure function of the frames, so a Cassette replay re-derives it exactly
36
+ * (ADR-0013).
37
+ */
38
+ export class NodeTracker {
39
+ readonly #report: (node: NodeSnapshot) => void;
40
+ readonly #decoders: readonly NodeDecoder[];
41
+ readonly #calls = new Map<string, TrackedCall>();
42
+
43
+ constructor(options: NodeTrackerOptions) {
44
+ this.#report = options.report;
45
+ this.#decoders = options.decoders ?? DEFAULT_NODE_DECODERS;
46
+ }
47
+
48
+ /** Inspects one Agent frame and ignores malformed or unsupported shapes. */
49
+ observe(frame: Frame): void {
50
+ const id = typeof frame.toolCallId === "string" ? frame.toolCallId : null;
51
+ if (id === null) return;
52
+ if (frame.type === "tool_execution_start") this.#start(id, frame);
53
+ else if (frame.type === "tool_execution_update") this.#update(id, frame.partialResult);
54
+ else if (frame.type === "tool_execution_end") this.#end(id, frame);
55
+ }
56
+
57
+ #start(id: string, frame: Frame): void {
58
+ const toolName = frame.toolName;
59
+ if (typeof toolName !== "string") return;
60
+ const decoder = this.#decoders.find((candidate) => candidate.matches(toolName));
61
+ if (decoder === undefined) return;
62
+ this.#calls.set(id, { decoder, names: new Map(), reported: new Map() });
63
+ }
64
+
65
+ #update(id: string, payload: unknown): void {
66
+ const call = this.#calls.get(id);
67
+ if (call === undefined || !isRecord(payload)) return;
68
+ this.#decode(call, payload.details);
69
+ }
70
+
71
+ #end(id: string, frame: Frame): void {
72
+ const call = this.#calls.get(id);
73
+ if (call === undefined) return;
74
+ if (isRecord(frame.result)) this.#decode(call, frame.result.details);
75
+ this.#settle(call, frame.isError === true);
76
+ this.#calls.delete(id);
77
+ }
78
+
79
+ #decode(call: TrackedCall, details: unknown): void {
80
+ let decoded: readonly DecodedNode[] | null = null;
81
+ try {
82
+ decoded = call.decoder.decode(details);
83
+ } catch {
84
+ return;
85
+ }
86
+ if (decoded === null) return;
87
+ const seen = new Map<string, number>();
88
+ for (const node of decoded) this.#emit(call, node, seen);
89
+ }
90
+
91
+ #emit(call: TrackedCall, node: DecodedNode, seen: Map<string, number>): void {
92
+ const segments = this.#uniqueSegments(call, node.segments, seen);
93
+ if (segments === null) return;
94
+ const snapshot: NodeSnapshot = {
95
+ segments,
96
+ state: node.state,
97
+ ...(node.activityGist === undefined || node.activityGist === ""
98
+ ? {}
99
+ : { activityGist: node.activityGist }),
100
+ ...(node.usage === undefined ? {} : { usage: node.usage }),
101
+ };
102
+ const key = segments.join("/");
103
+ if (same(call.reported.get(key), snapshot)) return;
104
+ call.reported.set(key, snapshot);
105
+ this.#report(snapshot);
106
+ }
107
+
108
+ /**
109
+ * Two calls to the same nested agent inside one tool call are distinct nodes,
110
+ * but the path grammar has no occurrence slot, so the second and later get a
111
+ * `-2`, `-3`, … suffix in first-seen order.
112
+ */
113
+ #uniqueSegments(
114
+ call: TrackedCall,
115
+ segments: readonly string[],
116
+ seen: Map<string, number>,
117
+ ): readonly string[] | null {
118
+ if (segments.length === 0) return null;
119
+ const sanitized = segments.map(sanitizeNodeName);
120
+ const base = sanitized.join("/");
121
+ const occurrence = (seen.get(base) ?? 0) + 1;
122
+ seen.set(base, occurrence);
123
+ const key = `${base}#${occurrence}`;
124
+ const assigned = call.names.get(key) ?? (occurrence === 1 ? base : `${base}-${occurrence}`);
125
+ call.names.set(key, assigned);
126
+ return assigned.split("/");
127
+ }
128
+
129
+ /** A tool call that ends with a node still running settles that node (spec §3). */
130
+ #settle(call: TrackedCall, isError: boolean): void {
131
+ for (const [key, snapshot] of call.reported) {
132
+ if (snapshot.state !== "running") continue;
133
+ const settled: NodeSnapshot = { ...snapshot, state: isError ? "failed" : "exited" };
134
+ call.reported.set(key, settled);
135
+ this.#report(settled);
136
+ }
137
+ }
138
+ }
139
+
140
+ function same(left: NodeSnapshot | undefined, right: NodeSnapshot): boolean {
141
+ if (left === undefined) return false;
142
+ return JSON.stringify(left) === JSON.stringify(right);
143
+ }