@yaag/runtime 0.10.0 → 0.11.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 +1 -1
- package/src/agent/agent-compaction.ts +80 -0
- package/src/agent/agent-usage.ts +11 -0
- package/src/agent/agent.ts +94 -2
- package/src/agent/fork.ts +74 -0
- package/src/agent/index.ts +1 -0
- package/src/agent/spawn-open.ts +249 -0
- package/src/agent/spawn-request.ts +12 -1
- package/src/agent/spawn.ts +25 -197
- package/src/cassette/cassette-replay.ts +33 -1
- package/src/cassette/cassette-schema.ts +20 -0
- package/src/cassette/cassette.ts +69 -6
- package/src/cassette/index.ts +1 -0
- package/src/cassette/recording-transport.ts +13 -0
- package/src/cassette/replay-divergence.ts +51 -2
- package/src/cassette/replay-transport.ts +22 -1
- package/src/cassette/resume-transport.ts +27 -2
- package/src/errors.ts +4 -0
- package/src/events.ts +26 -1
- package/src/index.ts +6 -0
- package/src/summary/index.ts +1 -0
- package/src/summary/summary-agent.ts +38 -0
- package/src/summary/summary.ts +20 -0
- package/src/transport/fake-transport.ts +35 -1
- package/src/transport/index.ts +9 -1
- package/src/transport/live-transport.ts +9 -0
- package/src/transport/pi-state.ts +51 -1
- package/src/transport/transport.ts +63 -0
- package/src/transport/worktree-transport.ts +39 -6
- package/src/types.ts +42 -0
package/src/agent/spawn.ts
CHANGED
|
@@ -1,29 +1,13 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
1
|
import type { ConfigExtension } from "../config/index.ts";
|
|
3
2
|
import { YaagError } from "../errors.ts";
|
|
4
3
|
import type { EventSink } from "../events.ts";
|
|
5
|
-
import {
|
|
6
|
-
ModelErrorHistory,
|
|
7
|
-
type ModelSelection,
|
|
8
|
-
normalizeModelResolution,
|
|
9
|
-
resolveModel,
|
|
10
|
-
resolveRecordedModel,
|
|
11
|
-
} from "../model/index.ts";
|
|
12
4
|
import type { RunContext } from "../run/index.ts";
|
|
13
|
-
import type {
|
|
14
|
-
import type {
|
|
15
|
-
|
|
16
|
-
Handle,
|
|
17
|
-
ResolvedSpawnOptions,
|
|
18
|
-
SpawnOptions,
|
|
19
|
-
SpawnOverrides,
|
|
20
|
-
} from "../types.ts";
|
|
21
|
-
import { Agent } from "./agent.ts";
|
|
22
|
-
import { uniqueAgentName } from "./agent-names.ts";
|
|
5
|
+
import type { TransportFactory } from "../transport/index.ts";
|
|
6
|
+
import type { Handle, SpawnOptions, SpawnOverrides } from "../types.ts";
|
|
7
|
+
import type { Agent } from "./agent.ts";
|
|
23
8
|
import { type AgentDefinition, agentDefinitionConfig, isAgentDefinition } from "./define-agent.ts";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import { openRequest, withSelection } from "./spawn-request.ts";
|
|
9
|
+
import { type ForkSpawner, forkRequest } from "./fork.ts";
|
|
10
|
+
import { type OpenAgentContext, type OpenAgentRequest, openAgent } from "./spawn-open.ts";
|
|
27
11
|
|
|
28
12
|
/** Dependencies for one Run's Agent-spawn gate. */
|
|
29
13
|
export interface SpawnDependencies {
|
|
@@ -51,130 +35,30 @@ export interface SpawnGate {
|
|
|
51
35
|
export function makeSpawn(deps: SpawnDependencies): SpawnGate {
|
|
52
36
|
const taken = new Set<string>();
|
|
53
37
|
let closed = false;
|
|
38
|
+
// Declared before use: every Agent this gate opens is handed the same fork
|
|
39
|
+
// spawner, so a fork is gated and named exactly like a spawn.
|
|
40
|
+
const forkSpawner: ForkSpawner = async (source, overrides) => {
|
|
41
|
+
if (closed) throw new YaagError("RUN_CLOSED", "run has already settled");
|
|
42
|
+
const request = forkRequest(source, overrides);
|
|
43
|
+
const handle = await openAgent(context, {
|
|
44
|
+
spawnOptions: request.spawnOptions,
|
|
45
|
+
parent: request.parent,
|
|
46
|
+
askDefaults: undefined,
|
|
47
|
+
definitionName: undefined,
|
|
48
|
+
fork: request.fork,
|
|
49
|
+
});
|
|
50
|
+
if (request.compact !== undefined && request.compact !== false) {
|
|
51
|
+
await handle.compact(typeof request.compact === "string" ? request.compact : undefined);
|
|
52
|
+
}
|
|
53
|
+
return handle;
|
|
54
|
+
};
|
|
55
|
+
const context: OpenAgentContext = { ...deps, taken, forkSpawner };
|
|
54
56
|
const spawn: RunContext["spawn"] = async (
|
|
55
57
|
definitionOrOptions: SpawnOptions | AgentDefinition = {},
|
|
56
58
|
overrides?: SpawnOverrides,
|
|
57
59
|
): Promise<Handle> => {
|
|
58
60
|
if (closed) throw new YaagError("RUN_CLOSED", "run has already settled");
|
|
59
|
-
|
|
60
|
-
const cwd = resolve(request.spawnOptions.cwd ?? process.cwd());
|
|
61
|
-
const name = uniqueAgentName(request.spawnOptions.name, deps.agents.length, taken);
|
|
62
|
-
// Resolved before any transport work, so a bad Parent Link costs nothing.
|
|
63
|
-
const parent = resolveParent(request.parent, deps.agents);
|
|
64
|
-
const resolution = normalizeModelResolution(request.spawnOptions);
|
|
65
|
-
// One history per Agent: its spawn loop and every mid-Ask fallback loop
|
|
66
|
-
// append to it, so a resolver sees every candidate that already failed.
|
|
67
|
-
const history = new ModelErrorHistory();
|
|
68
|
-
try {
|
|
69
|
-
// Extension paths do not vary per candidate, so a bad path fails once, generically.
|
|
70
|
-
const extensions = await resolveSpawnExtensions({
|
|
71
|
-
configExtensions: deps.configExtensions,
|
|
72
|
-
declared: request.spawnOptions.extensions,
|
|
73
|
-
useConfigExtensions: request.spawnOptions.configExtensions !== false,
|
|
74
|
-
...(deps.programFile === undefined ? {} : { programFile: deps.programFile }),
|
|
75
|
-
projectRoot: cwd,
|
|
76
|
-
}).catch((error: unknown) => {
|
|
77
|
-
if (error instanceof YaagError) throw error;
|
|
78
|
-
throw new YaagError("SPAWN_FAILED", `agent "${name}": ${String(error)}`, name);
|
|
79
|
-
});
|
|
80
|
-
// One local for both open sites, so the launch arguments and the recorded
|
|
81
|
-
// identity cannot drift apart.
|
|
82
|
-
const extensionFields =
|
|
83
|
-
extensions === undefined
|
|
84
|
-
? {}
|
|
85
|
-
: {
|
|
86
|
-
resolvedExtensionPaths: extensions.resolvedPaths,
|
|
87
|
-
declaredExtensions: extensions.declared,
|
|
88
|
-
};
|
|
89
|
-
const attempt = async (
|
|
90
|
-
selection: ModelSelection,
|
|
91
|
-
): Promise<{ opened: OpenedTransport; spawnOptions: ResolvedSpawnOptions }> => {
|
|
92
|
-
const settled = withSelection(request.spawnOptions, selection);
|
|
93
|
-
return {
|
|
94
|
-
opened: await openTransport({
|
|
95
|
-
factory: deps.factory,
|
|
96
|
-
name,
|
|
97
|
-
cwd,
|
|
98
|
-
spawnOptions: settled,
|
|
99
|
-
...extensionFields,
|
|
100
|
-
...(parent === undefined ? {} : { parent }),
|
|
101
|
-
sessionDir: deps.sessionDir,
|
|
102
|
-
}),
|
|
103
|
-
spawnOptions: settled,
|
|
104
|
-
};
|
|
105
|
-
};
|
|
106
|
-
// The peek must stay in the same synchronous block as the first open: a
|
|
107
|
-
// Cassette-backed factory claims Agents in open order (ADR-0013).
|
|
108
|
-
const recorded = deps.factory.recordedSpawn?.(
|
|
109
|
-
openRequest({
|
|
110
|
-
name,
|
|
111
|
-
cwd,
|
|
112
|
-
spawnOptions: request.spawnOptions,
|
|
113
|
-
...extensionFields,
|
|
114
|
-
...(parent === undefined ? {} : { parent }),
|
|
115
|
-
sessionDir: deps.sessionDir,
|
|
116
|
-
}),
|
|
117
|
-
);
|
|
118
|
-
// A Cassette-backed spawn adopts the recorded resolved selection and skips
|
|
119
|
-
// the loop, so a replayed Run emits no spawn-time fallback (ADR-0039).
|
|
120
|
-
const adopted =
|
|
121
|
-
recorded === undefined ? undefined : resolveRecordedModel({ resolution, recorded });
|
|
122
|
-
// The adopted outcome carries the attempts the recording already spent, so
|
|
123
|
-
// a later mid-Ask fallback re-resolves from that attempt index (ADR-0039).
|
|
124
|
-
for (const skipped of adopted?.skipped ?? []) {
|
|
125
|
-
history.record(skipped.reason, skipped.failedModel);
|
|
126
|
-
}
|
|
127
|
-
const { opened, spawnOptions } =
|
|
128
|
-
adopted === undefined
|
|
129
|
-
? await resolveModel({
|
|
130
|
-
resolution,
|
|
131
|
-
agent: name,
|
|
132
|
-
history,
|
|
133
|
-
onFallback: (fallback) => {
|
|
134
|
-
deps.emit({ type: "model_fallback", agent: name, ...fallback });
|
|
135
|
-
},
|
|
136
|
-
attempt,
|
|
137
|
-
})
|
|
138
|
-
: await attempt(adopted.selection);
|
|
139
|
-
const resolvedCwd = opened.startup.worktree?.cwd ?? cwd;
|
|
140
|
-
const branch = opened.startup.worktree?.branch;
|
|
141
|
-
const sessionFile = opened.startup.sessionFile;
|
|
142
|
-
const agent = new Agent({
|
|
143
|
-
name,
|
|
144
|
-
cwd: resolvedCwd,
|
|
145
|
-
branch,
|
|
146
|
-
transport: opened.transport,
|
|
147
|
-
emit: deps.emit,
|
|
148
|
-
spawnOptions,
|
|
149
|
-
// An Agent that named no candidate inherits pi's default model, so a
|
|
150
|
-
// failing Ask has nothing to fall back from and stays an Ask failure.
|
|
151
|
-
...(spawnOptions.model === undefined
|
|
152
|
-
? {}
|
|
153
|
-
: { modelFallback: { resolution, history, candidate: spawnOptions.model } }),
|
|
154
|
-
...(request.askDefaults === undefined ? {} : { askDefaults: request.askDefaults }),
|
|
155
|
-
...(request.definitionName === undefined ? {} : { definitionName: request.definitionName }),
|
|
156
|
-
});
|
|
157
|
-
deps.agents.push(agent);
|
|
158
|
-
deps.emit({
|
|
159
|
-
type: "agent_spawn",
|
|
160
|
-
agent: name,
|
|
161
|
-
model: agent.model,
|
|
162
|
-
cwd: resolvedCwd,
|
|
163
|
-
...(branch === undefined ? {} : { branch }),
|
|
164
|
-
...(sessionFile === undefined ? {} : { sessionFile }),
|
|
165
|
-
...(parent === undefined ? {} : { parent }),
|
|
166
|
-
});
|
|
167
|
-
return agent;
|
|
168
|
-
} catch (error) {
|
|
169
|
-
if (request.definitionName !== undefined && isSpawnFailure(error)) {
|
|
170
|
-
throw new YaagError(
|
|
171
|
-
"SPAWN_FAILED",
|
|
172
|
-
`definition "${request.definitionName}": ${error.message}`,
|
|
173
|
-
name,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
throw error;
|
|
177
|
-
}
|
|
61
|
+
return openAgent(context, resolveRequest(definitionOrOptions, overrides));
|
|
178
62
|
};
|
|
179
63
|
return {
|
|
180
64
|
spawn,
|
|
@@ -195,20 +79,10 @@ interface ValidatedOverrides {
|
|
|
195
79
|
readonly parent: unknown;
|
|
196
80
|
}
|
|
197
81
|
|
|
198
|
-
interface SpawnRequest {
|
|
199
|
-
/** Never carries `parent`: a Handle must not reach the recorded options. */
|
|
200
|
-
readonly spawnOptions: SpawnOptions;
|
|
201
|
-
/** The raw Parent Link option, validated by `resolveParent`. */
|
|
202
|
-
readonly parent: unknown;
|
|
203
|
-
readonly askDefaults: AskOptions | undefined;
|
|
204
|
-
/** Definition identity stays separate from a topology-overridden Agent name. */
|
|
205
|
-
readonly definitionName: string | undefined;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
82
|
function resolveRequest(
|
|
209
83
|
definitionOrOptions: SpawnOptions | AgentDefinition,
|
|
210
84
|
overrides: SpawnOverrides | undefined,
|
|
211
|
-
):
|
|
85
|
+
): OpenAgentRequest {
|
|
212
86
|
if (!isAgentDefinition(definitionOrOptions)) {
|
|
213
87
|
const { parent, ...spawnOptions } = definitionOrOptions;
|
|
214
88
|
return { spawnOptions, parent, askDefaults: undefined, definitionName: undefined };
|
|
@@ -284,49 +158,3 @@ function validateOverrides(overrides: unknown): ValidatedOverrides {
|
|
|
284
158
|
}
|
|
285
159
|
return { topology, parent };
|
|
286
160
|
}
|
|
287
|
-
|
|
288
|
-
function isSpawnFailure(error: unknown): error is YaagError {
|
|
289
|
-
return error instanceof YaagError && error.code === "SPAWN_FAILED";
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
interface OpenTransportOptions {
|
|
293
|
-
readonly factory: TransportFactory;
|
|
294
|
-
readonly name: string;
|
|
295
|
-
readonly cwd: string;
|
|
296
|
-
readonly spawnOptions: ResolvedSpawnOptions;
|
|
297
|
-
readonly resolvedExtensionPaths?: readonly string[];
|
|
298
|
-
readonly declaredExtensions?: readonly string[];
|
|
299
|
-
readonly parent?: string;
|
|
300
|
-
readonly sessionDir: string | undefined;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
interface OpenedTransport {
|
|
304
|
-
readonly transport: AgentTransport;
|
|
305
|
-
readonly startup: TransportStartup;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async function openTransport(options: OpenTransportOptions): Promise<OpenedTransport> {
|
|
309
|
-
const startup: TransportStartup = {};
|
|
310
|
-
try {
|
|
311
|
-
const transport = await options.factory.open(
|
|
312
|
-
openRequest({
|
|
313
|
-
name: options.name,
|
|
314
|
-
cwd: options.cwd,
|
|
315
|
-
spawnOptions: options.spawnOptions,
|
|
316
|
-
...(options.resolvedExtensionPaths === undefined
|
|
317
|
-
? {}
|
|
318
|
-
: { resolvedExtensionPaths: options.resolvedExtensionPaths }),
|
|
319
|
-
...(options.declaredExtensions === undefined
|
|
320
|
-
? {}
|
|
321
|
-
: { declaredExtensions: options.declaredExtensions }),
|
|
322
|
-
...(options.parent === undefined ? {} : { parent: options.parent }),
|
|
323
|
-
sessionDir: options.sessionDir,
|
|
324
|
-
}),
|
|
325
|
-
(report) => Object.assign(startup, report),
|
|
326
|
-
);
|
|
327
|
-
return { transport, startup };
|
|
328
|
-
} catch (error) {
|
|
329
|
-
if (error instanceof YaagError) throw error;
|
|
330
|
-
throw new YaagError("SPAWN_FAILED", `agent "${options.name}": ${String(error)}`, options.name);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { ReportedResult } from "../ask-contract/index.ts";
|
|
2
2
|
import { isReportResultCommandFrame, ReportResultCall } from "../ask-contract/index.ts";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AgentStats,
|
|
5
|
+
AskMarker,
|
|
6
|
+
AskPlayback,
|
|
7
|
+
CompactionMarker,
|
|
8
|
+
CompactionPlayback,
|
|
9
|
+
Frame,
|
|
10
|
+
} from "../transport/index.ts";
|
|
4
11
|
import { FrameQueue } from "../transport/index.ts";
|
|
5
12
|
import type { CassetteAgent } from "./cassette.ts";
|
|
6
13
|
|
|
@@ -92,6 +99,31 @@ export class CassetteReplay {
|
|
|
92
99
|
};
|
|
93
100
|
}
|
|
94
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Moves playback to a compaction its caller has already identity-checked.
|
|
104
|
+
*
|
|
105
|
+
* A compaction lives between two Asks, and `beginAsk` never switches the
|
|
106
|
+
* cursors back to the Agent-level streams, so the recorded compaction owns
|
|
107
|
+
* its own streams (ADR-0043).
|
|
108
|
+
*/
|
|
109
|
+
beginCompaction(marker: CompactionMarker): CompactionPlayback {
|
|
110
|
+
const compaction = this.#agent.compactions?.[marker.index];
|
|
111
|
+
if (!compaction) throw new Error("unreachable replay compaction cursor");
|
|
112
|
+
this.#sentCursor = 0;
|
|
113
|
+
this.#receivedCursor = 0;
|
|
114
|
+
this.#sent = compaction.sentFrames;
|
|
115
|
+
this.#received = compaction.receivedFrames;
|
|
116
|
+
this.#openAttempt(0);
|
|
117
|
+
return {
|
|
118
|
+
result: compaction.result ?? {
|
|
119
|
+
tokensBefore: null,
|
|
120
|
+
tokensAfter: null,
|
|
121
|
+
tokens: null,
|
|
122
|
+
cost: null,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
95
127
|
/** Ends the stream cleanly, allowing a caller to switch to another source. */
|
|
96
128
|
finish(): void {
|
|
97
129
|
this.#queue.end();
|
|
@@ -39,6 +39,9 @@ const CassetteSpawnSchema = Type.Object({
|
|
|
39
39
|
worktree: Type.Optional(Type.Literal(true)),
|
|
40
40
|
declaredExtensions: Type.Optional(StringArray),
|
|
41
41
|
parent: Type.Optional(Type.String()),
|
|
42
|
+
origin: Type.Optional(Type.Literal("fork")),
|
|
43
|
+
forkOf: Type.Optional(Type.String()),
|
|
44
|
+
forkAsks: Type.Optional(Type.Number()),
|
|
42
45
|
});
|
|
43
46
|
|
|
44
47
|
const ContextSpawnSchema = Type.Object({
|
|
@@ -119,6 +122,22 @@ const TokenBreakdownSchema = Type.Object({
|
|
|
119
122
|
total: Type.Number(),
|
|
120
123
|
});
|
|
121
124
|
|
|
125
|
+
const CompactionResultSchema = Type.Object({
|
|
126
|
+
tokensBefore: Type.Union([Type.Null(), Type.Number()]),
|
|
127
|
+
tokensAfter: Type.Union([Type.Null(), Type.Number()]),
|
|
128
|
+
tokens: Type.Union([Type.Null(), TokenBreakdownSchema]),
|
|
129
|
+
cost: Type.Union([Type.Null(), Type.Number()]),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const CassetteCompactionSchema = Type.Object({
|
|
133
|
+
index: Type.Number(),
|
|
134
|
+
hash: Type.String(),
|
|
135
|
+
afterAsks: Type.Number(),
|
|
136
|
+
result: Type.Optional(CompactionResultSchema),
|
|
137
|
+
sentFrames: Type.Array(FrameSchema),
|
|
138
|
+
receivedFrames: Type.Array(FrameSchema),
|
|
139
|
+
});
|
|
140
|
+
|
|
122
141
|
const CassetteAgentSchema = Type.Object({
|
|
123
142
|
spawn: CassetteSpawnSchema,
|
|
124
143
|
model: Type.String(),
|
|
@@ -128,6 +147,7 @@ const CassetteAgentSchema = Type.Object({
|
|
|
128
147
|
sentFrames: Type.Array(FrameSchema),
|
|
129
148
|
receivedFrames: Type.Array(FrameSchema),
|
|
130
149
|
asks: Type.Array(CassetteAskSchema),
|
|
150
|
+
compactions: Type.Optional(Type.Array(CassetteCompactionSchema)),
|
|
131
151
|
stats: Type.Object({
|
|
132
152
|
tokens: Type.Union([Type.Null(), TokenBreakdownSchema]),
|
|
133
153
|
cost: Type.Union([Type.Null(), Type.Number()]),
|
package/src/cassette/cassette.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
AskInvalidOutputPlayback,
|
|
8
8
|
AskMarker,
|
|
9
9
|
AskMarkerContext,
|
|
10
|
+
CompactionMarker,
|
|
11
|
+
CompactionResult,
|
|
10
12
|
Frame,
|
|
11
13
|
OpenOptions,
|
|
12
14
|
WorktreeResolution,
|
|
@@ -60,9 +62,23 @@ export interface CassetteAgent {
|
|
|
60
62
|
readonly sentFrames: readonly Frame[];
|
|
61
63
|
readonly receivedFrames: readonly Frame[];
|
|
62
64
|
readonly asks: readonly CassetteAsk[];
|
|
65
|
+
/** Compactions between this Agent's Asks; absent for an Agent that compacted none. */
|
|
66
|
+
readonly compactions?: readonly CassetteCompaction[];
|
|
63
67
|
readonly stats: AgentStats;
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
/** The frames attributed to one compaction marker (ADR-0043). */
|
|
71
|
+
export interface CassetteCompaction {
|
|
72
|
+
readonly index: number;
|
|
73
|
+
readonly hash: string;
|
|
74
|
+
/** Settled Asks of this Agent at the compaction point. */
|
|
75
|
+
readonly afterAsks: number;
|
|
76
|
+
/** What the recorded compaction reported; absent when the exchange failed. */
|
|
77
|
+
readonly result?: CompactionResult;
|
|
78
|
+
readonly sentFrames: readonly Frame[];
|
|
79
|
+
readonly receivedFrames: readonly Frame[];
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
/** Behavioural identity supplied when opening a recorded Agent. */
|
|
67
83
|
export interface CassetteSpawn {
|
|
68
84
|
readonly name: string;
|
|
@@ -93,6 +109,12 @@ export interface CassetteSpawn {
|
|
|
93
109
|
* Part of spawn identity; absent for a root Agent and for older Cassettes.
|
|
94
110
|
*/
|
|
95
111
|
readonly parent?: string;
|
|
112
|
+
/** How the Agent came to be; absent means an ordinary spawn. Spawn identity. */
|
|
113
|
+
readonly origin?: "fork";
|
|
114
|
+
/** Name of the Agent this one was forked from. Spawn identity. */
|
|
115
|
+
readonly forkOf?: string;
|
|
116
|
+
/** Settled Asks of the fork source at the fork point. Spawn identity. */
|
|
117
|
+
readonly forkAsks?: number;
|
|
96
118
|
}
|
|
97
119
|
|
|
98
120
|
/** The frames attributed to one Ask marker. */
|
|
@@ -142,6 +164,8 @@ export interface CassetteRecorder {
|
|
|
142
164
|
received(frame: Frame): void;
|
|
143
165
|
beginAsk(marker: AskMarker): void;
|
|
144
166
|
finishAsk(completion: AskCompletion): void;
|
|
167
|
+
beginCompaction(marker: CompactionMarker): void;
|
|
168
|
+
finishCompaction(result: CompactionResult | undefined): void;
|
|
145
169
|
closed(stats: AgentStats): void;
|
|
146
170
|
}
|
|
147
171
|
|
|
@@ -161,6 +185,15 @@ interface MutableAsk {
|
|
|
161
185
|
recovered?: true;
|
|
162
186
|
}
|
|
163
187
|
|
|
188
|
+
interface MutableCompaction {
|
|
189
|
+
readonly index: number;
|
|
190
|
+
readonly hash: string;
|
|
191
|
+
readonly afterAsks: number;
|
|
192
|
+
readonly sentFrames: Frame[];
|
|
193
|
+
readonly receivedFrames: Frame[];
|
|
194
|
+
result?: CompactionResult;
|
|
195
|
+
}
|
|
196
|
+
|
|
164
197
|
interface MutableAgent {
|
|
165
198
|
readonly spawn: CassetteSpawn;
|
|
166
199
|
readonly model: string;
|
|
@@ -170,8 +203,10 @@ interface MutableAgent {
|
|
|
170
203
|
readonly sentFrames: Frame[];
|
|
171
204
|
readonly receivedFrames: Frame[];
|
|
172
205
|
readonly asks: MutableAsk[];
|
|
206
|
+
readonly compactions: MutableCompaction[];
|
|
173
207
|
stats: AgentStats;
|
|
174
|
-
|
|
208
|
+
/** The slot later frames belong to: one Ask, one compaction, or the Agent. */
|
|
209
|
+
active: MutableAsk | MutableCompaction | null;
|
|
175
210
|
}
|
|
176
211
|
|
|
177
212
|
/** Collects a Cassette in memory; `executeRun` serializes it at Run settlement. */
|
|
@@ -194,6 +229,7 @@ export class CassetteCollector implements CassetteSink {
|
|
|
194
229
|
sentFrames: [],
|
|
195
230
|
receivedFrames: [],
|
|
196
231
|
asks: [],
|
|
232
|
+
compactions: [],
|
|
197
233
|
stats: { tokens: null, cost: null },
|
|
198
234
|
active: null,
|
|
199
235
|
};
|
|
@@ -211,11 +247,24 @@ export class CassetteCollector implements CassetteSink {
|
|
|
211
247
|
agent.active = ask;
|
|
212
248
|
},
|
|
213
249
|
finishAsk: (completion): void => {
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
if (completion.
|
|
217
|
-
if (completion.
|
|
218
|
-
if (completion.
|
|
250
|
+
const ask = activeAsk(agent);
|
|
251
|
+
if (!ask) return;
|
|
252
|
+
if (completion.limit !== undefined) ask.limit = completion.limit;
|
|
253
|
+
if (completion.stalled !== undefined) ask.stalled = completion.stalled;
|
|
254
|
+
if (completion.invalidOutput !== undefined) ask.outcome = completion.invalidOutput;
|
|
255
|
+
if (completion.recovered === true) ask.recovered = true;
|
|
256
|
+
},
|
|
257
|
+
beginCompaction: (marker): void => {
|
|
258
|
+
if (!agent) return;
|
|
259
|
+
const compaction = { ...marker, sentFrames: [], receivedFrames: [] };
|
|
260
|
+
agent.compactions.push(compaction);
|
|
261
|
+
agent.active = compaction;
|
|
262
|
+
},
|
|
263
|
+
finishCompaction: (result): void => {
|
|
264
|
+
const compaction = agent?.compactions.at(-1);
|
|
265
|
+
if (compaction !== undefined && result !== undefined) compaction.result = result;
|
|
266
|
+
// Later frames belong to the Agent again, not to the compaction.
|
|
267
|
+
if (agent) agent.active = null;
|
|
219
268
|
},
|
|
220
269
|
closed: (stats): void => {
|
|
221
270
|
if (agent) agent.stats = stats;
|
|
@@ -239,6 +288,7 @@ export class CassetteCollector implements CassetteSink {
|
|
|
239
288
|
sentFrames,
|
|
240
289
|
receivedFrames,
|
|
241
290
|
asks,
|
|
291
|
+
compactions,
|
|
242
292
|
stats,
|
|
243
293
|
}) => ({
|
|
244
294
|
spawn,
|
|
@@ -249,6 +299,9 @@ export class CassetteCollector implements CassetteSink {
|
|
|
249
299
|
sentFrames,
|
|
250
300
|
receivedFrames,
|
|
251
301
|
asks,
|
|
302
|
+
// Absent for an Agent that compacted nothing, so every Cassette
|
|
303
|
+
// recorded before ADR-0043 keeps its byte-identical shape.
|
|
304
|
+
...(compactions.length === 0 ? {} : { compactions }),
|
|
252
305
|
stats,
|
|
253
306
|
}),
|
|
254
307
|
),
|
|
@@ -278,10 +331,20 @@ function spawnIdentity(options: OpenOptions): CassetteSpawn {
|
|
|
278
331
|
? {}
|
|
279
332
|
: { declaredExtensions: [...options.declaredExtensions] }),
|
|
280
333
|
...(options.parent === undefined ? {} : { parent: options.parent }),
|
|
334
|
+
...(options.origin === undefined ? {} : { origin: options.origin }),
|
|
335
|
+
...(options.forkOf === undefined ? {} : { forkOf: options.forkOf }),
|
|
336
|
+
...(options.forkAsks === undefined ? {} : { forkAsks: options.forkAsks }),
|
|
281
337
|
...(options.worktree === true ? { worktree: true } : {}),
|
|
282
338
|
};
|
|
283
339
|
}
|
|
284
340
|
|
|
341
|
+
/** The Ask that later frames belong to, or nothing when a compaction owns them. */
|
|
342
|
+
function activeAsk(agent: MutableAgent | null): MutableAsk | null {
|
|
343
|
+
const active = agent?.active;
|
|
344
|
+
if (!active || "afterAsks" in active) return null;
|
|
345
|
+
return active;
|
|
346
|
+
}
|
|
347
|
+
|
|
285
348
|
function append(
|
|
286
349
|
agent: MutableAgent | null,
|
|
287
350
|
direction: "sentFrames" | "receivedFrames",
|
package/src/cassette/index.ts
CHANGED
|
@@ -4,6 +4,9 @@ import type {
|
|
|
4
4
|
AskCompletion,
|
|
5
5
|
AskMarker,
|
|
6
6
|
AskPlayback,
|
|
7
|
+
CompactionMarker,
|
|
8
|
+
CompactionPlayback,
|
|
9
|
+
CompactionResult,
|
|
7
10
|
Frame,
|
|
8
11
|
OpenOptions,
|
|
9
12
|
RecordedSpawnSelection,
|
|
@@ -87,6 +90,16 @@ class RecordingTransport implements AgentTransport {
|
|
|
87
90
|
this.#inner.finishAsk(completion);
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
beginCompaction(marker: CompactionMarker): CompactionPlayback | undefined {
|
|
94
|
+
this.#recorder.beginCompaction(marker);
|
|
95
|
+
return this.#inner.beginCompaction(marker);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
finishCompaction(result: CompactionResult | undefined): void {
|
|
99
|
+
this.#recorder.finishCompaction(result);
|
|
100
|
+
this.#inner.finishCompaction(result);
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
recordedExtractionPolicy(index: number): string | undefined {
|
|
91
104
|
return this.#inner.recordedExtractionPolicy?.(index);
|
|
92
105
|
}
|
|
@@ -2,6 +2,7 @@ import { YaagError } from "../errors.ts";
|
|
|
2
2
|
import type {
|
|
3
3
|
AskMarker,
|
|
4
4
|
AskMarkerContext,
|
|
5
|
+
CompactionMarker,
|
|
5
6
|
OpenOptions,
|
|
6
7
|
RecordedSpawnSelection,
|
|
7
8
|
} from "../transport/index.ts";
|
|
@@ -9,7 +10,13 @@ import type { CassetteAgent, CassetteAsk, CassetteSpawn } from "./cassette.ts";
|
|
|
9
10
|
|
|
10
11
|
/** A pure description of the first strict replay identity mismatch. */
|
|
11
12
|
export interface ReplayMismatch {
|
|
12
|
-
readonly kind:
|
|
13
|
+
readonly kind:
|
|
14
|
+
| "unexpected-spawn"
|
|
15
|
+
| "spawn-options"
|
|
16
|
+
| "changed-ask"
|
|
17
|
+
| "extra-ask"
|
|
18
|
+
| "changed-compaction"
|
|
19
|
+
| "extra-compaction";
|
|
13
20
|
readonly agent: string;
|
|
14
21
|
readonly index?: number;
|
|
15
22
|
readonly expectedHash: string;
|
|
@@ -71,6 +78,32 @@ export const replayMismatch = {
|
|
|
71
78
|
}),
|
|
72
79
|
};
|
|
73
80
|
},
|
|
81
|
+
|
|
82
|
+
compaction(
|
|
83
|
+
agent: CassetteAgent,
|
|
84
|
+
cursor: number,
|
|
85
|
+
actual: CompactionMarker,
|
|
86
|
+
): ReplayMismatch | null {
|
|
87
|
+
const expected = agent.compactions?.[cursor];
|
|
88
|
+
if (!expected) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "extra-compaction",
|
|
91
|
+
agent: agent.spawn.name,
|
|
92
|
+
index: actual.index,
|
|
93
|
+
expectedHash: "<none>",
|
|
94
|
+
actualHash: actual.hash,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return expected.index === actual.index && expected.hash === actual.hash
|
|
98
|
+
? null
|
|
99
|
+
: {
|
|
100
|
+
kind: "changed-compaction",
|
|
101
|
+
agent: agent.spawn.name,
|
|
102
|
+
index: actual.index,
|
|
103
|
+
expectedHash: expected.hash,
|
|
104
|
+
actualHash: actual.hash,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
74
107
|
};
|
|
75
108
|
|
|
76
109
|
/** Where a user reads what a Divergence means and what to do about it. */
|
|
@@ -86,7 +119,8 @@ export function strictReplay(mismatch: ReplayMismatch): never {
|
|
|
86
119
|
mismatch.agent,
|
|
87
120
|
);
|
|
88
121
|
}
|
|
89
|
-
const at =
|
|
122
|
+
const at =
|
|
123
|
+
compactionAt(mismatch) ?? (mismatch.index === undefined ? "spawn" : `Ask ${mismatch.index}`);
|
|
90
124
|
const changed =
|
|
91
125
|
mismatch.kind === "spawn-options" && mismatch.changedFields !== undefined
|
|
92
126
|
? ` changed ${mismatch.changedFields.join(", ")};`
|
|
@@ -98,6 +132,14 @@ export function strictReplay(mismatch: ReplayMismatch): never {
|
|
|
98
132
|
);
|
|
99
133
|
}
|
|
100
134
|
|
|
135
|
+
/** Names the compaction seam of a compaction mismatch, or nothing for other kinds. */
|
|
136
|
+
function compactionAt(mismatch: ReplayMismatch): string | undefined {
|
|
137
|
+
if (mismatch.kind !== "changed-compaction" && mismatch.kind !== "extra-compaction") {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return `compaction #${mismatch.index}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
101
143
|
/** The identity fields a changed spawn altered, for the Divergence report (ADR-0039). */
|
|
102
144
|
function spawnChangedFields(expected: CassetteSpawn, actual: OpenOptions): readonly string[] {
|
|
103
145
|
return changedAmong(SPAWN_OPEN_FIELDS, expected, actual);
|
|
@@ -150,6 +192,9 @@ const SPAWN_OPEN_FIELDS = [
|
|
|
150
192
|
...SPAWN_IDENTITY_FIELDS,
|
|
151
193
|
"declaredExtensions",
|
|
152
194
|
"parent",
|
|
195
|
+
"origin",
|
|
196
|
+
"forkOf",
|
|
197
|
+
"forkAsks",
|
|
153
198
|
] as const;
|
|
154
199
|
|
|
155
200
|
/** The listed fields whose canonical JSON differs between two identity records. */
|
|
@@ -222,6 +267,10 @@ function spawnHash(options: CassetteSpawn | OpenOptions): string {
|
|
|
222
267
|
? {}
|
|
223
268
|
: { declaredExtensions: options.declaredExtensions }),
|
|
224
269
|
...(options.parent === undefined ? {} : { parent: options.parent }),
|
|
270
|
+
// Conditional, so a Cassette recorded before forking keeps its hash.
|
|
271
|
+
...(options.origin === undefined ? {} : { origin: options.origin }),
|
|
272
|
+
...(options.forkOf === undefined ? {} : { forkOf: options.forkOf }),
|
|
273
|
+
...(options.forkAsks === undefined ? {} : { forkAsks: options.forkAsks }),
|
|
225
274
|
}),
|
|
226
275
|
);
|
|
227
276
|
return hasher.digest("hex");
|
|
@@ -3,6 +3,8 @@ import type {
|
|
|
3
3
|
AgentTransport,
|
|
4
4
|
AskMarker,
|
|
5
5
|
AskPlayback,
|
|
6
|
+
CompactionMarker,
|
|
7
|
+
CompactionPlayback,
|
|
6
8
|
Frame,
|
|
7
9
|
OpenOptions,
|
|
8
10
|
RecordedSpawnSelection,
|
|
@@ -30,7 +32,14 @@ export function replayTransport(cassette: Cassette): TransportFactory {
|
|
|
30
32
|
const mismatch = replayMismatch.spawn(agent, options);
|
|
31
33
|
if (mismatch) strictReplay(mismatch);
|
|
32
34
|
spawnCursor += 1;
|
|
33
|
-
|
|
35
|
+
// The recorded session file rides along, so a replayed Agent can be
|
|
36
|
+
// forked exactly where the recording forked it (ADR-0044).
|
|
37
|
+
if (agent.worktree !== undefined || agent.sessionFile !== undefined) {
|
|
38
|
+
observeStartup?.({
|
|
39
|
+
...(agent.worktree === undefined ? {} : { worktree: agent.worktree }),
|
|
40
|
+
...(agent.sessionFile === undefined ? {} : { sessionFile: agent.sessionFile }),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
34
43
|
return new ReplayTransport(new CassetteReplay(agent));
|
|
35
44
|
},
|
|
36
45
|
|
|
@@ -46,6 +55,7 @@ class ReplayTransport implements AgentTransport {
|
|
|
46
55
|
readonly model: string;
|
|
47
56
|
readonly #replay: CassetteReplay;
|
|
48
57
|
#askCursor = 0;
|
|
58
|
+
#compactionCursor = 0;
|
|
49
59
|
#closed: Promise<AgentStats> | null = null;
|
|
50
60
|
|
|
51
61
|
constructor(replay: CassetteReplay) {
|
|
@@ -72,6 +82,17 @@ class ReplayTransport implements AgentTransport {
|
|
|
72
82
|
// A completed replay does not alter the recorded Cassette.
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
beginCompaction(marker: CompactionMarker): CompactionPlayback {
|
|
86
|
+
const mismatch = replayMismatch.compaction(this.#replay.agent, this.#compactionCursor, marker);
|
|
87
|
+
if (mismatch) strictReplay(mismatch);
|
|
88
|
+
this.#compactionCursor += 1;
|
|
89
|
+
return this.#replay.beginCompaction(marker);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
finishCompaction(): void {
|
|
93
|
+
// A completed replay does not alter the recorded Cassette.
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
recordedExtractionPolicy(index: number): string | undefined {
|
|
76
97
|
return this.#replay.agent.asks[index]?.extractionPolicy;
|
|
77
98
|
}
|