@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.
- package/package.json +25 -0
- package/src/agent-names.ts +20 -0
- package/src/agent-usage.ts +72 -0
- package/src/agent.ts +130 -0
- package/src/args-validation.ts +11 -0
- package/src/ask-activity.ts +84 -0
- package/src/ask-contract-identity.ts +96 -0
- package/src/ask-exchange-events.ts +60 -0
- package/src/ask-exchange-options.ts +32 -0
- package/src/ask-exchange.ts +291 -0
- package/src/ask-hash.ts +86 -0
- package/src/ask-limit.ts +189 -0
- package/src/ask-output-steering.ts +69 -0
- package/src/ask-output-tail.ts +166 -0
- package/src/ask-output.ts +109 -0
- package/src/ask-settlement.ts +37 -0
- package/src/ask-turn.ts +70 -0
- package/src/cassette-loader.ts +131 -0
- package/src/cassette-publish.ts +55 -0
- package/src/cassette-replay.ts +178 -0
- package/src/cassette-schema.ts +152 -0
- package/src/cassette.ts +275 -0
- package/src/checkpoint-dir.ts +89 -0
- package/src/connection.ts +123 -0
- package/src/define-agent.ts +83 -0
- package/src/define-run.ts +69 -0
- package/src/errors.ts +115 -0
- package/src/events.ts +143 -0
- package/src/extension-package.ts +88 -0
- package/src/extension-paths.ts +66 -0
- package/src/extension-source.ts +60 -0
- package/src/fake-transport.ts +240 -0
- package/src/frame-gap.ts +41 -0
- package/src/frame-queue.ts +52 -0
- package/src/git-facts.ts +32 -0
- package/src/idle-watch.ts +154 -0
- package/src/index.ts +96 -0
- package/src/jsonl.ts +42 -0
- package/src/live-transport.ts +210 -0
- package/src/node-decoder-subagent.ts +67 -0
- package/src/node-decoder-workflow.ts +74 -0
- package/src/node-decoder.ts +23 -0
- package/src/node-decoders.ts +9 -0
- package/src/node-details.ts +70 -0
- package/src/node-path.ts +36 -0
- package/src/node-tracker.ts +143 -0
- package/src/pi-state.ts +108 -0
- package/src/prompt-gist.ts +13 -0
- package/src/prompt.ts +80 -0
- package/src/reap.ts +59 -0
- package/src/recording-transport.ts +97 -0
- package/src/replay-divergence.ts +155 -0
- package/src/replay-transport.ts +72 -0
- package/src/resume-preconditions.ts +59 -0
- package/src/resume-transport.ts +165 -0
- package/src/run-checkpoint.ts +93 -0
- package/src/run-context.ts +19 -0
- package/src/run.ts +274 -0
- package/src/skill-probe.ts +247 -0
- package/src/skill-restriction-transport.ts +76 -0
- package/src/spawn.ts +241 -0
- package/src/summary-agent.ts +310 -0
- package/src/summary-nodes.ts +77 -0
- package/src/summary.ts +213 -0
- package/src/tool-probe-extension.ts +17 -0
- package/src/tool-probe.ts +141 -0
- package/src/transport.ts +178 -0
- package/src/types.ts +130 -0
- package/src/validation-errors.ts +70 -0
- package/src/wire-constants.ts +24 -0
- package/src/worktree-transport.ts +125 -0
package/src/pi-state.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { AgentStats, Frame, OpenOptions, TokenBreakdown } from "./transport.ts";
|
|
2
|
+
|
|
3
|
+
/** The command line for one Agent. */
|
|
4
|
+
export function piCommand(options: OpenOptions, toolProbeExtensionPath?: string): string[] {
|
|
5
|
+
const cmd = ["pi", "--mode", "rpc"];
|
|
6
|
+
if (options.model) cmd.push("--model", options.model);
|
|
7
|
+
if (options.systemPrompt) cmd.push("--system-prompt", options.systemPrompt);
|
|
8
|
+
if (options.thinking !== undefined) cmd.push("--thinking", options.thinking);
|
|
9
|
+
if (options.appendSystemPrompt !== undefined) {
|
|
10
|
+
cmd.push("--append-system-prompt", options.appendSystemPrompt);
|
|
11
|
+
}
|
|
12
|
+
appendCapabilities(cmd, options, toolProbeExtensionPath);
|
|
13
|
+
if (options.sessionDir) cmd.push("--session-dir", options.sessionDir);
|
|
14
|
+
if (options.sessionFile) cmd.push("--session", options.sessionFile);
|
|
15
|
+
// No --approve: Agents inherit the user's saved project-trust decision (ADR-0009).
|
|
16
|
+
return cmd;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function appendCapabilities(
|
|
20
|
+
cmd: string[],
|
|
21
|
+
options: OpenOptions,
|
|
22
|
+
toolProbeExtensionPath: string | undefined,
|
|
23
|
+
): void {
|
|
24
|
+
const hermetic = options.inherit !== true;
|
|
25
|
+
if (options.tools === undefined) {
|
|
26
|
+
if (hermetic) cmd.push("--no-tools");
|
|
27
|
+
} else if (options.tools.length === 0) {
|
|
28
|
+
cmd.push("--no-tools");
|
|
29
|
+
} else {
|
|
30
|
+
cmd.push("--tools", options.tools.join(","));
|
|
31
|
+
}
|
|
32
|
+
if (options.resolvedSkillPaths === undefined) {
|
|
33
|
+
if (hermetic) cmd.push("--no-skills");
|
|
34
|
+
} else {
|
|
35
|
+
cmd.push("--no-skills");
|
|
36
|
+
for (const path of options.resolvedSkillPaths) cmd.push("--skill", path);
|
|
37
|
+
}
|
|
38
|
+
if (hermetic) {
|
|
39
|
+
cmd.push("--no-extensions", "--no-context-files", "--no-prompt-templates");
|
|
40
|
+
}
|
|
41
|
+
for (const path of options.resolvedExtensionPaths ?? []) cmd.push("-e", path);
|
|
42
|
+
if (
|
|
43
|
+
options.tools !== undefined &&
|
|
44
|
+
options.tools.length > 0 &&
|
|
45
|
+
toolProbeExtensionPath !== undefined
|
|
46
|
+
) {
|
|
47
|
+
cmd.push("-e", toolProbeExtensionPath);
|
|
48
|
+
}
|
|
49
|
+
if (options.disallowedTools !== undefined) {
|
|
50
|
+
cmd.push("--exclude-tools", options.disallowedTools.join(","));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `provider/id` from a `get_state` response — what the Agent actually resolved
|
|
56
|
+
* to, not the pattern that was requested. Null when the payload has no model.
|
|
57
|
+
*/
|
|
58
|
+
export function readModel(response: Frame): string | null {
|
|
59
|
+
const data: unknown = response.data;
|
|
60
|
+
if (typeof data !== "object" || data === null || !("model" in data)) return null;
|
|
61
|
+
const model: unknown = data.model;
|
|
62
|
+
if (typeof model !== "object" || model === null) return null;
|
|
63
|
+
if (!("id" in model) || typeof model.id !== "string") return null;
|
|
64
|
+
const provider =
|
|
65
|
+
"provider" in model && typeof model.provider === "string" ? model.provider : null;
|
|
66
|
+
return provider ? `${provider}/${model.id}` : model.id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Returns pi's persisted session path from a startup `get_state` response, when valid. */
|
|
70
|
+
export function readSessionFile(response: Frame): string | null {
|
|
71
|
+
const data: unknown = response.data;
|
|
72
|
+
if (typeof data !== "object" || data === null || !("sessionFile" in data)) return null;
|
|
73
|
+
return typeof data.sessionFile === "string" ? data.sessionFile : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The full token breakdown and cost from a `get_session_stats` response.
|
|
78
|
+
* Missing fields become null rather than 0, so "unknown" and "free" stay
|
|
79
|
+
* distinguishable (ADR-0012); a partial breakdown is treated as unknown.
|
|
80
|
+
*/
|
|
81
|
+
export function readStats(response: Frame): AgentStats {
|
|
82
|
+
const data: unknown = response.data;
|
|
83
|
+
if (typeof data !== "object" || data === null) return { tokens: null, cost: null };
|
|
84
|
+
const cost = "cost" in data && typeof data.cost === "number" ? data.cost : null;
|
|
85
|
+
return { tokens: readBreakdown("tokens" in data ? data.tokens : null), cost };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readBreakdown(tokens: unknown): TokenBreakdown | null {
|
|
89
|
+
if (typeof tokens !== "object" || tokens === null) return null;
|
|
90
|
+
const record: Record<string, unknown> = { ...tokens };
|
|
91
|
+
const read = (key: string): number | null =>
|
|
92
|
+
typeof record[key] === "number" ? record[key] : null;
|
|
93
|
+
const input = read("input");
|
|
94
|
+
const output = read("output");
|
|
95
|
+
const cacheRead = read("cacheRead");
|
|
96
|
+
const cacheWrite = read("cacheWrite");
|
|
97
|
+
const total = read("total");
|
|
98
|
+
if (
|
|
99
|
+
input === null ||
|
|
100
|
+
output === null ||
|
|
101
|
+
cacheRead === null ||
|
|
102
|
+
cacheWrite === null ||
|
|
103
|
+
total === null
|
|
104
|
+
) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return { input, output, cacheRead, cacheWrite, total };
|
|
108
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PROMPT_GIST_MAX_CHARS } from "./wire-constants.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Condenses an Ask prompt for the `ask_start` Lifecycle Event.
|
|
5
|
+
*
|
|
6
|
+
* Selects the first non-blank line, collapses its whitespace, and caps it at
|
|
7
|
+
* `PROMPT_GIST_MAX_CHARS`; it never includes content from later lines. Returns
|
|
8
|
+
* `""` for an all-whitespace prompt and never throws.
|
|
9
|
+
*/
|
|
10
|
+
export function promptGist(prompt: string): string {
|
|
11
|
+
const firstLine = prompt.trimStart().split("\n", 1)[0] ?? "";
|
|
12
|
+
return firstLine.replace(/\s+/g, " ").trim().slice(0, PROMPT_GIST_MAX_CHARS);
|
|
13
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const BLANK_LINE = /^[\t ]*$/;
|
|
2
|
+
const INDENTATION = /^[\t ]*/;
|
|
3
|
+
const INTERPOLATION_MARKER = "x";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders an indented template literal as a clean prompt string.
|
|
7
|
+
*
|
|
8
|
+
* Dedents static template text only and inserts interpolated values verbatim.
|
|
9
|
+
* Strings pass through, numbers and booleans stringify, and arrays render their
|
|
10
|
+
* items recursively separated by newlines. Throws `TypeError` for unsupported
|
|
11
|
+
* values or static indentation that mixes tabs and spaces.
|
|
12
|
+
*/
|
|
13
|
+
export function prompt(strings: TemplateStringsArray, ...values: unknown[]): string {
|
|
14
|
+
const indent = commonIndent(strings.raw);
|
|
15
|
+
let result = "";
|
|
16
|
+
|
|
17
|
+
for (let index = 0; index < strings.raw.length; index += 1) {
|
|
18
|
+
result += dedentChunk(strings.raw[index] ?? "", indent);
|
|
19
|
+
if (index < values.length) {
|
|
20
|
+
result += renderValue(values[index]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return normalizeBlankLines(result);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function commonIndent(chunks: readonly string[]): number {
|
|
28
|
+
const lines = chunks.join(INTERPOLATION_MARKER).split("\n").slice(1);
|
|
29
|
+
let shortest: number | undefined;
|
|
30
|
+
let indentationKind: "spaces" | "tabs" | undefined;
|
|
31
|
+
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
if (BLANK_LINE.test(line)) continue;
|
|
34
|
+
|
|
35
|
+
const indent = INDENTATION.exec(line)?.[0] ?? "";
|
|
36
|
+
const kind = indentationKindFor(indent);
|
|
37
|
+
if (kind !== undefined) {
|
|
38
|
+
if (indentationKind !== undefined && indentationKind !== kind) {
|
|
39
|
+
throw new TypeError("prompt: mixed tabs and spaces in indentation");
|
|
40
|
+
}
|
|
41
|
+
indentationKind = kind;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (shortest === undefined || indent.length < shortest) shortest = indent.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return shortest ?? 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function indentationKindFor(indent: string): "spaces" | "tabs" | undefined {
|
|
51
|
+
const hasSpaces = indent.includes(" ");
|
|
52
|
+
const hasTabs = indent.includes("\t");
|
|
53
|
+
if (hasSpaces && hasTabs) {
|
|
54
|
+
throw new TypeError("prompt: mixed tabs and spaces in indentation");
|
|
55
|
+
}
|
|
56
|
+
if (hasSpaces) return "spaces";
|
|
57
|
+
if (hasTabs) return "tabs";
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function dedentChunk(chunk: string, indent: number): string {
|
|
62
|
+
return chunk
|
|
63
|
+
.split("\n")
|
|
64
|
+
.map((line, index) => (index === 0 ? line : line.slice(indent)))
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderValue(value: unknown): string {
|
|
69
|
+
if (typeof value === "string") return value;
|
|
70
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
71
|
+
if (Array.isArray(value)) return value.map(renderValue).join("\n");
|
|
72
|
+
throw new TypeError(`prompt: cannot interpolate ${value === null ? "null" : typeof value}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeBlankLines(text: string): string {
|
|
76
|
+
const lines = text.split("\n");
|
|
77
|
+
if (BLANK_LINE.test(lines[0] ?? "")) lines.shift();
|
|
78
|
+
if (BLANK_LINE.test(lines.at(-1) ?? "")) lines.pop();
|
|
79
|
+
return lines.map((line) => (BLANK_LINE.test(line) ? "" : line)).join("\n");
|
|
80
|
+
}
|
package/src/reap.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** The process facts reaping needs. Kept behind an interface so it is testable. */
|
|
2
|
+
export interface ReapTarget {
|
|
3
|
+
/** Resolves when the Agent's process is gone. */
|
|
4
|
+
readonly exited: Promise<unknown>;
|
|
5
|
+
/** Close the Agent's stdin. */
|
|
6
|
+
endStdin(): void;
|
|
7
|
+
/** SIGTERM to the pid — pi handles it and still reaps its own children. */
|
|
8
|
+
terminate(): void;
|
|
9
|
+
/** SIGKILL to the process group. Last resort; may leak bash-tool children. */
|
|
10
|
+
destroyGroup(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ReapPath = "stdin-close" | "sigterm" | "sigkill";
|
|
14
|
+
|
|
15
|
+
const reaping = new WeakMap<ReapTarget, Promise<ReapPath>>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shuts an Agent down (ticket 02's shutdown contract).
|
|
19
|
+
*
|
|
20
|
+
* Repeated requests for the same process share the initial attempt and its
|
|
21
|
+
* outcome. stdin EOF first, because pi's graceful shutdown is the only thing
|
|
22
|
+
* that reaps the bash-tool children it spawned into their own process groups —
|
|
23
|
+
* a group SIGKILL kills pi and leaves the user's `npm run dev` running. SIGINT
|
|
24
|
+
* is never sent: RPC mode registers SIGTERM and SIGHUP only.
|
|
25
|
+
*/
|
|
26
|
+
export function reap(target: ReapTarget, graceMs = 2000): Promise<ReapPath> {
|
|
27
|
+
const existing = reaping.get(target);
|
|
28
|
+
if (existing !== undefined) return existing;
|
|
29
|
+
|
|
30
|
+
// Queue the ladder after caching it: endStdin() can synchronously trigger a
|
|
31
|
+
// second close request through an embedding process's callbacks.
|
|
32
|
+
const current = Promise.resolve().then(() => reapOnce(target, graceMs));
|
|
33
|
+
reaping.set(target, current);
|
|
34
|
+
return current;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function reapOnce(target: ReapTarget, graceMs: number): Promise<ReapPath> {
|
|
38
|
+
target.endStdin();
|
|
39
|
+
if (await exitedWithin(target.exited, graceMs)) return "stdin-close";
|
|
40
|
+
|
|
41
|
+
target.terminate();
|
|
42
|
+
if (await exitedWithin(target.exited, graceMs)) return "sigterm";
|
|
43
|
+
|
|
44
|
+
target.destroyGroup();
|
|
45
|
+
await target.exited;
|
|
46
|
+
return "sigkill";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function exitedWithin(exited: Promise<unknown>, ms: number): Promise<boolean> {
|
|
50
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
51
|
+
const pending = new Promise<false>((resolve) => {
|
|
52
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
return await Promise.race([exited.then(() => true), pending]);
|
|
56
|
+
} finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { CassetteSink } from "./cassette.ts";
|
|
2
|
+
import type { AskLimitOutcome, AskStalledOutcome } from "./errors.ts";
|
|
3
|
+
import { readGitFacts } from "./git-facts.ts";
|
|
4
|
+
import type {
|
|
5
|
+
AgentStats,
|
|
6
|
+
AgentTransport,
|
|
7
|
+
AskMarker,
|
|
8
|
+
AskPlayback,
|
|
9
|
+
Frame,
|
|
10
|
+
OpenOptions,
|
|
11
|
+
TransportFactory,
|
|
12
|
+
TransportStartup,
|
|
13
|
+
TransportStartupObserver,
|
|
14
|
+
} from "./transport.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Wraps a transport factory and records frames at the public transport seam.
|
|
18
|
+
*
|
|
19
|
+
* Delegation is unchanged; failures from the wrapped factory and transport are
|
|
20
|
+
* preserved. The supplied sink holds the artifact until its owner serializes it.
|
|
21
|
+
*/
|
|
22
|
+
export function recordingTransport(inner: TransportFactory, sink: CassetteSink): TransportFactory {
|
|
23
|
+
return {
|
|
24
|
+
async open(
|
|
25
|
+
options: OpenOptions,
|
|
26
|
+
observeStartup?: TransportStartupObserver,
|
|
27
|
+
): Promise<AgentTransport> {
|
|
28
|
+
const recorder = sink.reserve(options);
|
|
29
|
+
let startup: TransportStartup = {};
|
|
30
|
+
try {
|
|
31
|
+
const transport = await inner.open(options, (report) => {
|
|
32
|
+
startup = { ...startup, ...report };
|
|
33
|
+
observeStartup?.(report);
|
|
34
|
+
});
|
|
35
|
+
recorder.opened({
|
|
36
|
+
model: transport.model,
|
|
37
|
+
...startup,
|
|
38
|
+
git: await readGitFacts(startup.worktree?.cwd ?? options.cwd),
|
|
39
|
+
});
|
|
40
|
+
return new RecordingTransport(transport, recorder);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
recorder.failed();
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class RecordingTransport implements AgentTransport {
|
|
50
|
+
readonly model: string;
|
|
51
|
+
readonly #inner: AgentTransport;
|
|
52
|
+
readonly #recorder: ReturnType<CassetteSink["reserve"]>;
|
|
53
|
+
#close: Promise<AgentStats> | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(inner: AgentTransport, recorder: ReturnType<CassetteSink["reserve"]>) {
|
|
56
|
+
this.model = inner.model;
|
|
57
|
+
this.#inner = inner;
|
|
58
|
+
this.#recorder = recorder;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
send(frame: Frame): void {
|
|
62
|
+
this.#recorder.sent(frame);
|
|
63
|
+
this.#inner.send(frame);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async *frames(): AsyncIterable<Frame> {
|
|
67
|
+
for await (const frame of this.#inner.frames()) {
|
|
68
|
+
this.#recorder.received(frame);
|
|
69
|
+
yield frame;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
beginAsk(marker: AskMarker): AskPlayback | undefined {
|
|
74
|
+
this.#recorder.beginAsk(marker);
|
|
75
|
+
return this.#inner.beginAsk(marker);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
finishAsk(
|
|
79
|
+
outcome?: AskLimitOutcome,
|
|
80
|
+
stalled?: AskStalledOutcome,
|
|
81
|
+
invalidOutput?: import("./transport.ts").AskInvalidOutputPlayback,
|
|
82
|
+
): void {
|
|
83
|
+
this.#recorder.finishAsk(outcome, stalled, invalidOutput);
|
|
84
|
+
this.#inner.finishAsk(outcome, stalled, invalidOutput);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
close(): Promise<AgentStats> {
|
|
88
|
+
this.#close ??= this.#closeAndRecord();
|
|
89
|
+
return this.#close;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async #closeAndRecord(): Promise<AgentStats> {
|
|
93
|
+
const stats = await this.#inner.close();
|
|
94
|
+
this.#recorder.closed(stats);
|
|
95
|
+
return stats;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { CassetteAgent, CassetteAsk, CassetteSpawn } from "./cassette.ts";
|
|
2
|
+
import { YaagError } from "./errors.ts";
|
|
3
|
+
import type { AskMarker, AskMarkerContext, OpenOptions } from "./transport.ts";
|
|
4
|
+
|
|
5
|
+
/** A pure description of the first strict replay identity mismatch. */
|
|
6
|
+
export interface ReplayMismatch {
|
|
7
|
+
readonly kind: "unexpected-spawn" | "spawn-options" | "changed-ask" | "extra-ask";
|
|
8
|
+
readonly agent: string;
|
|
9
|
+
readonly index?: number;
|
|
10
|
+
readonly expectedHash: string;
|
|
11
|
+
readonly actualHash: string;
|
|
12
|
+
readonly definitionName?: string;
|
|
13
|
+
readonly changedFields?: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Pure identity comparisons that ADR-0014 can reuse with a lenient reaction. */
|
|
17
|
+
export const replayMismatch = {
|
|
18
|
+
spawn(expected: CassetteAgent | null, actual: OpenOptions): ReplayMismatch | null {
|
|
19
|
+
if (expected === null) {
|
|
20
|
+
return {
|
|
21
|
+
kind: "unexpected-spawn",
|
|
22
|
+
agent: actual.name,
|
|
23
|
+
expectedHash: "<none>",
|
|
24
|
+
actualHash: spawnHash(actual),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const expectedHash = spawnHash(expected.spawn);
|
|
28
|
+
const actualHash = spawnHash(actual);
|
|
29
|
+
return expectedHash === actualHash
|
|
30
|
+
? null
|
|
31
|
+
: { kind: "spawn-options", agent: actual.name, expectedHash, actualHash };
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
ask(agent: CassetteAgent, cursor: number, actual: AskMarker): ReplayMismatch | null {
|
|
35
|
+
const expected = agent.asks[cursor];
|
|
36
|
+
if (!expected) {
|
|
37
|
+
return {
|
|
38
|
+
kind: "extra-ask",
|
|
39
|
+
agent: agent.spawn.name,
|
|
40
|
+
index: actual.index,
|
|
41
|
+
expectedHash: "<none>",
|
|
42
|
+
actualHash: actual.hash,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return askMatches(expected, actual)
|
|
46
|
+
? null
|
|
47
|
+
: {
|
|
48
|
+
kind: "changed-ask",
|
|
49
|
+
agent: agent.spawn.name,
|
|
50
|
+
index: actual.index,
|
|
51
|
+
expectedHash: expected.hash,
|
|
52
|
+
actualHash: actual.hash,
|
|
53
|
+
...(expected.definitionName === undefined
|
|
54
|
+
? {}
|
|
55
|
+
: {
|
|
56
|
+
definitionName: expected.definitionName,
|
|
57
|
+
...(expected.context === undefined || actual.context === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: { changedFields: changedFields(expected.context, actual.context) }),
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Converts a detected mismatch into strict replay's public failure. */
|
|
66
|
+
export function strictReplay(mismatch: ReplayMismatch): never {
|
|
67
|
+
if (mismatch.kind === "changed-ask" && mismatch.definitionName !== undefined) {
|
|
68
|
+
const fields = mismatch.changedFields?.join(", ") ?? "hash inputs";
|
|
69
|
+
throw new YaagError(
|
|
70
|
+
"REPLAY_DIVERGED",
|
|
71
|
+
`replay diverged for agent "${mismatch.agent}" at ask #${mismatch.index}: definition "${mismatch.definitionName}" changed since recording (${fields})`,
|
|
72
|
+
mismatch.agent,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const at = mismatch.index === undefined ? "spawn" : `Ask ${mismatch.index}`;
|
|
76
|
+
throw new YaagError(
|
|
77
|
+
"REPLAY_DIVERGED",
|
|
78
|
+
`replay diverged for agent "${mismatch.agent}" at ${at}: expected ${mismatch.expectedHash}, actual ${mismatch.actualHash}`,
|
|
79
|
+
mismatch.agent,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function askMatches(expected: CassetteAsk, actual: AskMarker): boolean {
|
|
84
|
+
return (
|
|
85
|
+
expected.index === actual.index &&
|
|
86
|
+
expected.hash === actual.hash &&
|
|
87
|
+
JSON.stringify(expected.outputSchema) === JSON.stringify(actual.outputSchema) &&
|
|
88
|
+
expected.maxSteers === actual.maxSteers &&
|
|
89
|
+
expected.extractionPolicy === actual.extractionPolicy
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function changedFields(expected: AskMarkerContext, actual: AskMarkerContext): readonly string[] {
|
|
94
|
+
const fields: string[] = [];
|
|
95
|
+
if (expected.prompt !== actual.prompt) fields.push("prompt");
|
|
96
|
+
for (const field of [
|
|
97
|
+
"cwd",
|
|
98
|
+
"model",
|
|
99
|
+
"systemPrompt",
|
|
100
|
+
"thinking",
|
|
101
|
+
"appendSystemPrompt",
|
|
102
|
+
"tools",
|
|
103
|
+
"disallowedTools",
|
|
104
|
+
"skills",
|
|
105
|
+
"disallowedSkills",
|
|
106
|
+
"worktree",
|
|
107
|
+
] as const) {
|
|
108
|
+
if (JSON.stringify(expected.spawn[field]) !== JSON.stringify(actual.spawn[field])) {
|
|
109
|
+
fields.push(field);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const field of [
|
|
113
|
+
"maxTurns",
|
|
114
|
+
"maxToolCalls",
|
|
115
|
+
"maxDurationMs",
|
|
116
|
+
"idleMs",
|
|
117
|
+
"wrapUpPrompt",
|
|
118
|
+
"outputSchema",
|
|
119
|
+
"maxSteers",
|
|
120
|
+
"extractionPolicy",
|
|
121
|
+
] as const) {
|
|
122
|
+
const unchanged =
|
|
123
|
+
field === "outputSchema"
|
|
124
|
+
? JSON.stringify(expected.ask.outputSchema) === JSON.stringify(actual.ask.outputSchema)
|
|
125
|
+
: expected.ask[field] === actual.ask[field];
|
|
126
|
+
if (!unchanged) fields.push(field);
|
|
127
|
+
}
|
|
128
|
+
return fields;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function spawnHash(options: CassetteSpawn | OpenOptions): string {
|
|
132
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
133
|
+
hasher.update(
|
|
134
|
+
JSON.stringify({
|
|
135
|
+
name: options.name,
|
|
136
|
+
cwd: options.cwd,
|
|
137
|
+
model: options.model ?? null,
|
|
138
|
+
systemPrompt: options.systemPrompt ?? null,
|
|
139
|
+
...(options.tools === undefined ? {} : { tools: options.tools }),
|
|
140
|
+
...(options.disallowedTools === undefined
|
|
141
|
+
? {}
|
|
142
|
+
: { disallowedTools: options.disallowedTools }),
|
|
143
|
+
...(options.skills === undefined ? {} : { skills: options.skills }),
|
|
144
|
+
...(options.disallowedSkills === undefined
|
|
145
|
+
? {}
|
|
146
|
+
: { disallowedSkills: options.disallowedSkills }),
|
|
147
|
+
...(options.worktree === true ? { worktree: true } : {}),
|
|
148
|
+
...(options.thinking === undefined ? {} : { thinking: options.thinking }),
|
|
149
|
+
...(options.appendSystemPrompt === undefined
|
|
150
|
+
? {}
|
|
151
|
+
: { appendSystemPrompt: options.appendSystemPrompt }),
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
return hasher.digest("hex");
|
|
155
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Cassette } from "./cassette.ts";
|
|
2
|
+
import { CassetteReplay } from "./cassette-replay.ts";
|
|
3
|
+
import { replayMismatch, strictReplay } from "./replay-divergence.ts";
|
|
4
|
+
import type {
|
|
5
|
+
AgentStats,
|
|
6
|
+
AgentTransport,
|
|
7
|
+
AskMarker,
|
|
8
|
+
AskPlayback,
|
|
9
|
+
Frame,
|
|
10
|
+
OpenOptions,
|
|
11
|
+
TransportFactory,
|
|
12
|
+
TransportStartupObserver,
|
|
13
|
+
} from "./transport.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates a strict Cassette-backed transport factory without starting pi.
|
|
17
|
+
*
|
|
18
|
+
* Each opened transport owns its Ask and frame cursors, so independent Agents
|
|
19
|
+
* may replay their recorded conversations in a different interleaving.
|
|
20
|
+
*/
|
|
21
|
+
export function replayTransport(cassette: Cassette): TransportFactory {
|
|
22
|
+
let spawnCursor = 0;
|
|
23
|
+
return {
|
|
24
|
+
async open(
|
|
25
|
+
options: OpenOptions,
|
|
26
|
+
observeStartup?: TransportStartupObserver,
|
|
27
|
+
): Promise<AgentTransport> {
|
|
28
|
+
const agent = cassette.agents[spawnCursor] ?? null;
|
|
29
|
+
const mismatch = replayMismatch.spawn(agent, options);
|
|
30
|
+
if (mismatch) strictReplay(mismatch);
|
|
31
|
+
spawnCursor += 1;
|
|
32
|
+
if (agent.worktree !== undefined) observeStartup?.({ worktree: agent.worktree });
|
|
33
|
+
return new ReplayTransport(new CassetteReplay(agent));
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class ReplayTransport implements AgentTransport {
|
|
39
|
+
readonly model: string;
|
|
40
|
+
readonly #replay: CassetteReplay;
|
|
41
|
+
#askCursor = 0;
|
|
42
|
+
#closed: Promise<AgentStats> | null = null;
|
|
43
|
+
|
|
44
|
+
constructor(replay: CassetteReplay) {
|
|
45
|
+
this.#replay = replay;
|
|
46
|
+
this.model = replay.model;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
send(frame: Frame): void {
|
|
50
|
+
this.#replay.send(frame);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
frames(): AsyncIterable<Frame> {
|
|
54
|
+
return this.#replay.frames();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beginAsk(marker: AskMarker): AskPlayback {
|
|
58
|
+
const mismatch = replayMismatch.ask(this.#replay.agent, this.#askCursor, marker);
|
|
59
|
+
if (mismatch) strictReplay(mismatch);
|
|
60
|
+
this.#askCursor += 1;
|
|
61
|
+
return this.#replay.beginAsk(marker);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
finishAsk(): void {
|
|
65
|
+
// A completed replay does not alter the recorded Cassette.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
close(): Promise<AgentStats> {
|
|
69
|
+
this.#closed ??= Promise.resolve(this.#replay.stats).finally(() => this.#replay.finish());
|
|
70
|
+
return this.#closed;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import type { CassetteAgent } from "./cassette.ts";
|
|
3
|
+
import { YaagError } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validates that a recorded Agent can safely continue from its persisted session.
|
|
7
|
+
*
|
|
8
|
+
* Rejects with RESUME_REFUSED before replaying any frames when its session or
|
|
9
|
+
* working-tree facts cannot establish the recorded continuation point.
|
|
10
|
+
*/
|
|
11
|
+
export async function checkResumePreconditions(agent: CassetteAgent): Promise<void> {
|
|
12
|
+
const { name } = agent.spawn;
|
|
13
|
+
const cwd = continuationCwd(agent);
|
|
14
|
+
let state: Awaited<ReturnType<typeof stat>>;
|
|
15
|
+
try {
|
|
16
|
+
state = await stat(cwd);
|
|
17
|
+
} catch {
|
|
18
|
+
refuse(name, `recorded cwd "${cwd}" no longer exists`);
|
|
19
|
+
}
|
|
20
|
+
if (!state.isDirectory()) refuse(name, `recorded cwd "${cwd}" is not a directory`);
|
|
21
|
+
|
|
22
|
+
if (agent.git) {
|
|
23
|
+
const current = await readHead(cwd);
|
|
24
|
+
if (current !== agent.git.head) {
|
|
25
|
+
refuse(
|
|
26
|
+
name,
|
|
27
|
+
`recorded branch ${agent.git.branch}, recorded HEAD ${agent.git.head}, current HEAD ${current ?? "unavailable"}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!agent.sessionFile)
|
|
32
|
+
refuse(name, "has no recorded session file; re-record this Run before resuming");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readHead(cwd: string): Promise<string | null> {
|
|
36
|
+
const process = Bun.spawn({
|
|
37
|
+
cmd: ["git", "-C", cwd, "rev-parse", "HEAD"],
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "ignore",
|
|
40
|
+
});
|
|
41
|
+
const [code, stdout] = await Promise.all([process.exited, new Response(process.stdout).text()]);
|
|
42
|
+
const head = stdout.trim();
|
|
43
|
+
return code === 0 && head !== "" ? head : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function continuationCwd(agent: CassetteAgent): string {
|
|
47
|
+
if (agent.spawn.worktree !== true) return agent.spawn.cwd;
|
|
48
|
+
if (agent.worktree === undefined) {
|
|
49
|
+
refuse(
|
|
50
|
+
agent.spawn.name,
|
|
51
|
+
"has no recorded worktree resolution; re-record this Run before resuming",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return agent.worktree.cwd;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function refuse(agent: string, detail: string): never {
|
|
58
|
+
throw new YaagError("RESUME_REFUSED", `resume refused for agent "${agent}": ${detail}`, agent);
|
|
59
|
+
}
|