@yaag/runtime 0.6.2 → 0.8.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.ts +38 -2
- package/src/agent/define-agent.ts +20 -3
- package/src/agent/spawn-extensions.ts +97 -0
- package/src/agent/spawn-request.ts +70 -0
- package/src/agent/spawn.ts +102 -62
- package/src/ask/ask-exchange-events.ts +10 -1
- package/src/ask/ask-exchange-options.ts +13 -0
- package/src/ask/ask-exchange.ts +77 -9
- package/src/ask/index.ts +1 -0
- package/src/cassette/cassette-publish.ts +5 -1
- package/src/cassette/cassette-replay.ts +23 -4
- package/src/cassette/cassette-schema.ts +1 -0
- package/src/cassette/cassette.ts +8 -0
- package/src/cassette/recording-transport.ts +7 -0
- package/src/cassette/replay-divergence.ts +86 -20
- package/src/cassette/replay-transport.ts +8 -1
- package/src/cassette/resume-transport.ts +14 -1
- package/src/config/config-file.ts +67 -0
- package/src/config/config-issues.ts +31 -0
- package/src/config/config-paths.ts +38 -0
- package/src/config/config-schema.ts +25 -0
- package/src/config/effective-config.ts +116 -0
- package/src/config/index.ts +22 -0
- package/src/errors.ts +56 -2
- package/src/events.ts +19 -0
- package/src/extension/extension-paths.ts +15 -6
- package/src/index.ts +14 -0
- package/src/model/index.ts +18 -0
- package/src/model/model-error-history.ts +36 -0
- package/src/model/model-failure.ts +78 -0
- package/src/model/model-fallback.ts +15 -0
- package/src/model/model-match.ts +99 -0
- package/src/model/model-resolution.ts +44 -6
- package/src/model/model-swap.ts +81 -0
- package/src/model/recorded-resolution.ts +61 -0
- package/src/model/resolution-loop.ts +115 -0
- package/src/run/run.ts +8 -0
- package/src/summary/index.ts +1 -0
- package/src/summary/summary-agent.ts +9 -1
- package/src/summary/summary-fallbacks.ts +50 -0
- package/src/summary/summary.ts +12 -0
- package/src/transport/fake-transport.ts +57 -1
- package/src/transport/index.ts +4 -0
- package/src/transport/live-transport.ts +37 -4
- package/src/transport/stderr-tail.ts +32 -0
- package/src/transport/transport.ts +16 -0
- package/src/types.ts +33 -5
- package/src/wire-constants.ts +3 -0
package/src/ask/ask-exchange.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
askStalledError,
|
|
18
18
|
isYaagError,
|
|
19
19
|
} from "../errors.ts";
|
|
20
|
+
import { retryOnModelFailure, swapModel } from "../model/index.ts";
|
|
20
21
|
import { NodeTracker } from "../node/index.ts";
|
|
21
22
|
import type { AskPlayback } from "../transport/index.ts";
|
|
22
23
|
import { FrameGapTracker } from "../transport/index.ts";
|
|
@@ -43,7 +44,7 @@ export async function exchangeAsk(options: AskExchangeOptions): Promise<unknown>
|
|
|
43
44
|
}
|
|
44
45
|
class AskExchange {
|
|
45
46
|
readonly #options: AskExchangeOptions;
|
|
46
|
-
|
|
47
|
+
#turn = new AskTurn();
|
|
47
48
|
readonly #events: AskEvents;
|
|
48
49
|
#limit: AskLimit | null = null;
|
|
49
50
|
#idle: IdleWatch | null = null;
|
|
@@ -73,16 +74,48 @@ class AskExchange {
|
|
|
73
74
|
|
|
74
75
|
async run(): Promise<unknown> {
|
|
75
76
|
const startedAt = Date.now();
|
|
77
|
+
this.#openEnvelope();
|
|
76
78
|
try {
|
|
77
|
-
const result = await this.#
|
|
79
|
+
const result = await this.#attempts();
|
|
78
80
|
this.#events.end(this.#endOutcome(startedAt, true));
|
|
79
81
|
return result;
|
|
80
82
|
} catch (error) {
|
|
81
83
|
if (this.#began) this.#events.end(this.#endOutcome(startedAt, false));
|
|
82
84
|
throw error;
|
|
85
|
+
} finally {
|
|
86
|
+
this.#finish();
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Runs the Ask, retrying it on a swapped-in model when a model failure ends an
|
|
92
|
+
* attempt. Every attempt stays inside one Ask envelope, so one Ask still emits
|
|
93
|
+
* exactly one `ask_start`, one `ask_end` and one recorded Cassette Ask.
|
|
94
|
+
*/
|
|
95
|
+
async #attempts(): Promise<unknown> {
|
|
96
|
+
const fallback = this.#options.modelFallback;
|
|
97
|
+
if (fallback === undefined) return await this.#exchange();
|
|
98
|
+
return await retryOnModelFailure({
|
|
99
|
+
resolution: fallback.resolution,
|
|
100
|
+
agent: this.#options.agent,
|
|
101
|
+
history: fallback.history,
|
|
102
|
+
onFallback: this.#events.fallback,
|
|
103
|
+
currentModel: () => fallback.currentModel(),
|
|
104
|
+
attempt: async () => await this.#exchange(),
|
|
105
|
+
swap: async (selection) => {
|
|
106
|
+
// Replay re-runs this loop and re-sends the recorded swap frames, which
|
|
107
|
+
// CassetteReplay matches as ordinary frames (ADR-0039). The Ask keeps its
|
|
108
|
+
// spawn-time identity, so the retry records under the same hash.
|
|
109
|
+
const swapped = await swapModel({
|
|
110
|
+
agent: this.#options.agent,
|
|
111
|
+
command: async (frame) => await this.#options.connection.command(frame),
|
|
112
|
+
selection,
|
|
113
|
+
});
|
|
114
|
+
fallback.onSwapped(selection.model ?? swapped.model, swapped.model);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
86
119
|
#endOutcome(startedAt: number, ok: boolean): AskEndOutcome {
|
|
87
120
|
return {
|
|
88
121
|
durationMs: Date.now() - startedAt,
|
|
@@ -94,8 +127,7 @@ class AskExchange {
|
|
|
94
127
|
|
|
95
128
|
async #exchange(): Promise<unknown> {
|
|
96
129
|
try {
|
|
97
|
-
this.#
|
|
98
|
-
this.#timeoutDeadline = this.#deadline();
|
|
130
|
+
this.#armAttempt();
|
|
99
131
|
// The schema command is answered without a turn, so settlement tracking
|
|
100
132
|
// starts after it: nothing it could emit may settle this Ask (ADR-0032).
|
|
101
133
|
await this.#deliverSchema();
|
|
@@ -119,11 +151,12 @@ class AskExchange {
|
|
|
119
151
|
this.#recordInvalidOutput(error);
|
|
120
152
|
throw error;
|
|
121
153
|
} finally {
|
|
122
|
-
this.#
|
|
154
|
+
this.#cleanupAttempt();
|
|
123
155
|
}
|
|
124
156
|
}
|
|
125
157
|
|
|
126
|
-
|
|
158
|
+
/** Opens the Ask envelope: identity, recording and the Ask-scoped events. Once per Ask. */
|
|
159
|
+
#openEnvelope(): void {
|
|
127
160
|
this.#legacyExtraction = this.#recordedLegacyPolicy();
|
|
128
161
|
const outputContract = structuredOutputContract(
|
|
129
162
|
this.#options.ask,
|
|
@@ -147,7 +180,27 @@ class AskExchange {
|
|
|
147
180
|
});
|
|
148
181
|
this.#began = true;
|
|
149
182
|
this.#playback = playback;
|
|
183
|
+
// `timeoutMs` is the Ask's hard ceiling and kills the Agent (ADR-0003), so
|
|
184
|
+
// it is Ask-scoped: retries share one deadline, unlike the soft limits.
|
|
185
|
+
this.#timeoutDeadline = this.#deadline();
|
|
150
186
|
this.#events.start(this.#options.prompt, playback !== undefined);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Arms one attempt: fresh turn tracking, limits, watches and report_result
|
|
191
|
+
* state, so a retry starts from nothing — the failed attempt's work died with
|
|
192
|
+
* its model error, and its stale reported result cannot settle the retry.
|
|
193
|
+
*/
|
|
194
|
+
#armAttempt(): void {
|
|
195
|
+
const playback = this.#playback;
|
|
196
|
+
this.#turn = new AskTurn();
|
|
197
|
+
this.#outcome = undefined;
|
|
198
|
+
this.#stalled = undefined;
|
|
199
|
+
// A failed attempt's stall recovery and invalid output describe work that
|
|
200
|
+
// died with it, so neither may reach the Ask's single `ask_end` or its one
|
|
201
|
+
// recorded completion when a later attempt settles cleanly.
|
|
202
|
+
this.#cause = "normal";
|
|
203
|
+
this.#invalidOutput = undefined;
|
|
151
204
|
this.#gap = playback === undefined ? new FrameGapTracker() : null;
|
|
152
205
|
this.#activity = playback === undefined ? new AskActivityTracker(this.#events.activity) : null;
|
|
153
206
|
this.#output =
|
|
@@ -443,15 +496,23 @@ class AskExchange {
|
|
|
443
496
|
this.#invalidOutput = { kind: "invalid_output", steeringEfforts: error.steeringEfforts ?? 0 };
|
|
444
497
|
}
|
|
445
498
|
|
|
446
|
-
|
|
447
|
-
|
|
499
|
+
/** Ends one attempt; the envelope stays open, so a retry can arm the next one. */
|
|
500
|
+
#cleanupAttempt(): void {
|
|
501
|
+
this.#maxFrameGapMs = maxGap(this.#maxFrameGapMs, this.#gap?.maxGapMs);
|
|
448
502
|
this.#stalled ??= this.#idle?.result ?? this.#stall?.result ?? undefined;
|
|
449
503
|
this.#limit?.cleanup();
|
|
450
504
|
this.#idle?.cleanup();
|
|
451
505
|
this.#stall?.cleanup();
|
|
452
506
|
this.#output?.close();
|
|
507
|
+
if (this.#drainStaleFrames) {
|
|
508
|
+
this.#options.connection.drainStaleTurn();
|
|
509
|
+
this.#drainStaleFrames = false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Closes the Ask envelope: one `finishAsk` for the whole Ask, retries included. */
|
|
514
|
+
#finish(): void {
|
|
453
515
|
this.#options.connection.observe(null);
|
|
454
|
-
if (this.#drainStaleFrames) this.#options.connection.drainStaleTurn();
|
|
455
516
|
if (!this.#began) return;
|
|
456
517
|
try {
|
|
457
518
|
this.#options.transport.finishAsk({
|
|
@@ -465,3 +526,10 @@ class AskExchange {
|
|
|
465
526
|
}
|
|
466
527
|
}
|
|
467
528
|
}
|
|
529
|
+
|
|
530
|
+
/** The widest frame gap seen across an Ask's attempts. */
|
|
531
|
+
function maxGap(left: number | undefined, right: number | undefined): number | undefined {
|
|
532
|
+
if (left === undefined) return right;
|
|
533
|
+
if (right === undefined) return left;
|
|
534
|
+
return Math.max(left, right);
|
|
535
|
+
}
|
package/src/ask/index.ts
CHANGED
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
* Files inside this directory import each other directly.
|
|
4
4
|
*/
|
|
5
5
|
export { exchangeAsk } from "./ask-exchange.ts";
|
|
6
|
+
export type { AskModelFallback } from "./ask-exchange-options.ts";
|
|
6
7
|
export { askHash, askMarkerContext, type EffectiveAskOptions } from "./ask-hash.ts";
|
|
7
8
|
export type { SettlementCause } from "./stall-watchdog.ts";
|
|
@@ -21,7 +21,11 @@ export async function publishCassette(path: string, cassette: Cassette): Promise
|
|
|
21
21
|
await syncDirectory(directory);
|
|
22
22
|
} catch (error) {
|
|
23
23
|
await discard(temp);
|
|
24
|
-
throw new Error(
|
|
24
|
+
throw new Error(
|
|
25
|
+
`failed to publish cassette ${path}: ${String(error)}; ` +
|
|
26
|
+
"see @yaag/extension docs/troubleshooting.md#failed-to-publish-cassette",
|
|
27
|
+
{ cause: error },
|
|
28
|
+
);
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -48,6 +48,11 @@ export class CassetteReplay {
|
|
|
48
48
|
this.#releaseOneResponse();
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
|
+
// A recorded swap ends one Ask attempt (ADR-0038): the prompt that
|
|
52
|
+
// follows is the retry, not an ADR-0027 correction, so the floor moves.
|
|
53
|
+
if (expected.type === "set_model" || expected.type === "set_thinking_level") {
|
|
54
|
+
this.#openAttempt(this.#sentCursor);
|
|
55
|
+
}
|
|
51
56
|
this.#releaseFor(frame.type);
|
|
52
57
|
this.#consumeControls();
|
|
53
58
|
return;
|
|
@@ -72,10 +77,7 @@ export class CassetteReplay {
|
|
|
72
77
|
this.#receivedCursor = 0;
|
|
73
78
|
this.#sent = ask.sentFrames;
|
|
74
79
|
this.#received = ask.receivedFrames;
|
|
75
|
-
|
|
76
|
-
// Ask's own prompt is the second sent frame, not the first (ADR-0032).
|
|
77
|
-
const first = ask.sentFrames[0];
|
|
78
|
-
this.#promptFloor = first !== undefined && isReportResultCommandFrame(first) ? 1 : 0;
|
|
80
|
+
this.#openAttempt(0);
|
|
79
81
|
const awaitsAbortSettlement = abortSettlementFollowsFinalText(ask);
|
|
80
82
|
const settles = ask.receivedFrames.some((frame) => frame.type === "agent_settled");
|
|
81
83
|
const reported = recordedReportedResult(ask);
|
|
@@ -95,6 +97,19 @@ export class CassetteReplay {
|
|
|
95
97
|
this.#queue.end();
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Opens one Ask attempt at `cursor`: the first prompt at or after it is the
|
|
102
|
+
* attempt's own prompt, and every later prompt is a recorded correction.
|
|
103
|
+
*
|
|
104
|
+
* A schema-bearing attempt opens with the report_result schema command, so
|
|
105
|
+
* its own prompt is the second sent frame, not the first (ADR-0032).
|
|
106
|
+
*/
|
|
107
|
+
#openAttempt(cursor: number): void {
|
|
108
|
+
const first = this.#sent[cursor];
|
|
109
|
+
this.#promptFloor =
|
|
110
|
+
first !== undefined && isReportResultCommandFrame(first) ? cursor + 1 : cursor;
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
#consumeControls(): void {
|
|
99
114
|
for (;;) {
|
|
100
115
|
const expected = this.#sent[this.#sentCursor];
|
|
@@ -110,6 +125,10 @@ export class CassetteReplay {
|
|
|
110
125
|
* amendment). A recorded `get_state` is a Stall Watchdog probe: replay is
|
|
111
126
|
* never silent, so it never probes, and the recorded exchange is consumed
|
|
112
127
|
* here instead of blocking the frames that follow it (ADR-0029).
|
|
128
|
+
*
|
|
129
|
+
* A recorded `get_available_models`/`set_model` pair is deliberately not a
|
|
130
|
+
* control: replay re-runs the mid-Ask Model Resolution and sends those
|
|
131
|
+
* commands itself, so `send()` matches them like any other frame (ADR-0039).
|
|
113
132
|
*/
|
|
114
133
|
#isRecordedControl(frame: Frame): boolean {
|
|
115
134
|
if (frame.type === "steer" || frame.type === "abort" || frame.type === "get_state") return true;
|
package/src/cassette/cassette.ts
CHANGED
|
@@ -83,6 +83,11 @@ export interface CassetteSpawn {
|
|
|
83
83
|
readonly disallowedSkills?: readonly string[];
|
|
84
84
|
/** Deterministic request, present only when the Agent requested a worktree. */
|
|
85
85
|
readonly worktree?: true;
|
|
86
|
+
/**
|
|
87
|
+
* Declared extensions in declaration order, unresolved so a Cassette matches
|
|
88
|
+
* across machines (ADR-0040). Absent when the Agent ran no extension.
|
|
89
|
+
*/
|
|
90
|
+
readonly declaredExtensions?: readonly string[];
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/** The frames attributed to one Ask marker. */
|
|
@@ -264,6 +269,9 @@ function spawnIdentity(options: OpenOptions): CassetteSpawn {
|
|
|
264
269
|
...(options.disallowedSkills === undefined
|
|
265
270
|
? {}
|
|
266
271
|
: { disallowedSkills: [...options.disallowedSkills] }),
|
|
272
|
+
...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
|
|
273
|
+
? {}
|
|
274
|
+
: { declaredExtensions: [...options.declaredExtensions] }),
|
|
267
275
|
...(options.worktree === true ? { worktree: true } : {}),
|
|
268
276
|
};
|
|
269
277
|
}
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
AskPlayback,
|
|
7
7
|
Frame,
|
|
8
8
|
OpenOptions,
|
|
9
|
+
RecordedSpawnSelection,
|
|
9
10
|
TransportFactory,
|
|
10
11
|
TransportStartup,
|
|
11
12
|
TransportStartupObserver,
|
|
@@ -43,6 +44,12 @@ export function recordingTransport(inner: TransportFactory, sink: CassetteSink):
|
|
|
43
44
|
throw error;
|
|
44
45
|
}
|
|
45
46
|
},
|
|
47
|
+
|
|
48
|
+
// The recorder is the outermost wrapper of every composition, so a
|
|
49
|
+
// Cassette-backed inner factory answers the recorded-selection peek.
|
|
50
|
+
recordedSpawn(options: OpenOptions): RecordedSpawnSelection | undefined {
|
|
51
|
+
return inner.recordedSpawn?.(options);
|
|
52
|
+
},
|
|
46
53
|
};
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { YaagError } from "../errors.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AskMarker,
|
|
4
|
+
AskMarkerContext,
|
|
5
|
+
OpenOptions,
|
|
6
|
+
RecordedSpawnSelection,
|
|
7
|
+
} from "../transport/index.ts";
|
|
3
8
|
import type { CassetteAgent, CassetteAsk, CassetteSpawn } from "./cassette.ts";
|
|
4
9
|
|
|
5
10
|
/** A pure description of the first strict replay identity mismatch. */
|
|
@@ -28,7 +33,13 @@ export const replayMismatch = {
|
|
|
28
33
|
const actualHash = spawnHash(actual);
|
|
29
34
|
return expectedHash === actualHash
|
|
30
35
|
? null
|
|
31
|
-
: {
|
|
36
|
+
: {
|
|
37
|
+
kind: "spawn-options",
|
|
38
|
+
agent: actual.name,
|
|
39
|
+
expectedHash,
|
|
40
|
+
actualHash,
|
|
41
|
+
changedFields: spawnChangedFields(expected.spawn, actual),
|
|
42
|
+
};
|
|
32
43
|
},
|
|
33
44
|
|
|
34
45
|
ask(agent: CassetteAgent, cursor: number, actual: AskMarker): ReplayMismatch | null {
|
|
@@ -62,24 +73,91 @@ export const replayMismatch = {
|
|
|
62
73
|
},
|
|
63
74
|
};
|
|
64
75
|
|
|
76
|
+
/** Where a user reads what a Divergence means and what to do about it. */
|
|
77
|
+
const DOCS_POINTER = "; see @yaag/extension docs/troubleshooting.md#replay-or-resume-mismatch";
|
|
78
|
+
|
|
65
79
|
/** Converts a detected mismatch into strict replay's public failure. */
|
|
66
80
|
export function strictReplay(mismatch: ReplayMismatch): never {
|
|
67
81
|
if (mismatch.kind === "changed-ask" && mismatch.definitionName !== undefined) {
|
|
68
82
|
const fields = mismatch.changedFields?.join(", ") ?? "hash inputs";
|
|
69
83
|
throw new YaagError(
|
|
70
84
|
"REPLAY_DIVERGED",
|
|
71
|
-
`replay diverged for agent "${mismatch.agent}" at ask #${mismatch.index}: definition "${mismatch.definitionName}" changed since recording (${fields})`,
|
|
85
|
+
`replay diverged for agent "${mismatch.agent}" at ask #${mismatch.index}: definition "${mismatch.definitionName}" changed since recording (${fields})${DOCS_POINTER}`,
|
|
72
86
|
mismatch.agent,
|
|
73
87
|
);
|
|
74
88
|
}
|
|
75
89
|
const at = mismatch.index === undefined ? "spawn" : `Ask ${mismatch.index}`;
|
|
90
|
+
const changed =
|
|
91
|
+
mismatch.kind === "spawn-options" && mismatch.changedFields !== undefined
|
|
92
|
+
? ` changed ${mismatch.changedFields.join(", ")};`
|
|
93
|
+
: "";
|
|
76
94
|
throw new YaagError(
|
|
77
95
|
"REPLAY_DIVERGED",
|
|
78
|
-
`replay diverged for agent "${mismatch.agent}" at ${at}
|
|
96
|
+
`replay diverged for agent "${mismatch.agent}" at ${at}:${changed} expected ${mismatch.expectedHash}, actual ${mismatch.actualHash}${DOCS_POINTER}`,
|
|
79
97
|
mismatch.agent,
|
|
80
98
|
);
|
|
81
99
|
}
|
|
82
100
|
|
|
101
|
+
/** The identity fields a changed spawn altered, for the Divergence report (ADR-0039). */
|
|
102
|
+
function spawnChangedFields(expected: CassetteSpawn, actual: OpenOptions): readonly string[] {
|
|
103
|
+
return changedAmong(SPAWN_OPEN_FIELDS, expected, actual);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* True when a live spawn matches the recorded one on every identity field
|
|
108
|
+
* except the resolved model and thinking, so a resume may adopt the recorded
|
|
109
|
+
* selection instead of re-running Model Resolution (ADR-0039).
|
|
110
|
+
*/
|
|
111
|
+
export function spawnMatchesExceptModel(expected: CassetteSpawn, actual: OpenOptions): boolean {
|
|
112
|
+
return (
|
|
113
|
+
spawnHash({ ...expected, model: undefined, thinking: undefined }) ===
|
|
114
|
+
spawnHash({ ...actual, model: undefined, thinking: undefined })
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The recorded resolved selection of one Agent, or `undefined` when none is recorded. */
|
|
119
|
+
export function recordedSpawnSelection(
|
|
120
|
+
agent: CassetteAgent | null,
|
|
121
|
+
): RecordedSpawnSelection | undefined {
|
|
122
|
+
if (agent === null) return undefined;
|
|
123
|
+
return {
|
|
124
|
+
...(agent.spawn.model === undefined ? {} : { model: agent.spawn.model }),
|
|
125
|
+
...(agent.spawn.thinking === undefined ? {} : { thinking: agent.spawn.thinking }),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The spawn fields both Divergence reports compare, so the two cannot drift
|
|
131
|
+
* apart. Extensions are the deliberate exception: they identify the process
|
|
132
|
+
* start, not an Ask, so they join only the open-request list below.
|
|
133
|
+
*/
|
|
134
|
+
const SPAWN_IDENTITY_FIELDS = [
|
|
135
|
+
"cwd",
|
|
136
|
+
"model",
|
|
137
|
+
"systemPrompt",
|
|
138
|
+
"thinking",
|
|
139
|
+
"appendSystemPrompt",
|
|
140
|
+
"tools",
|
|
141
|
+
"disallowedTools",
|
|
142
|
+
"skills",
|
|
143
|
+
"disallowedSkills",
|
|
144
|
+
"worktree",
|
|
145
|
+
] as const;
|
|
146
|
+
|
|
147
|
+
/** The spawn identity fields plus the Agent name, which only an open request carries. */
|
|
148
|
+
const SPAWN_OPEN_FIELDS = ["name", ...SPAWN_IDENTITY_FIELDS, "declaredExtensions"] as const;
|
|
149
|
+
|
|
150
|
+
/** The listed fields whose canonical JSON differs between two identity records. */
|
|
151
|
+
function changedAmong<Key extends string>(
|
|
152
|
+
fields: readonly Key[],
|
|
153
|
+
expected: { readonly [Field in Key]?: unknown },
|
|
154
|
+
actual: { readonly [Field in Key]?: unknown },
|
|
155
|
+
): readonly Key[] {
|
|
156
|
+
return fields.filter(
|
|
157
|
+
(field) => JSON.stringify(expected[field]) !== JSON.stringify(actual[field]),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
83
161
|
function askMatches(expected: CassetteAsk, actual: AskMarker): boolean {
|
|
84
162
|
return (
|
|
85
163
|
expected.index === actual.index &&
|
|
@@ -93,22 +171,7 @@ function askMatches(expected: CassetteAsk, actual: AskMarker): boolean {
|
|
|
93
171
|
function changedFields(expected: AskMarkerContext, actual: AskMarkerContext): readonly string[] {
|
|
94
172
|
const fields: string[] = [];
|
|
95
173
|
if (expected.prompt !== actual.prompt) fields.push("prompt");
|
|
96
|
-
|
|
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
|
-
}
|
|
174
|
+
fields.push(...changedAmong(SPAWN_IDENTITY_FIELDS, expected.spawn, actual.spawn));
|
|
112
175
|
for (const field of [
|
|
113
176
|
"maxTurns",
|
|
114
177
|
"maxToolCalls",
|
|
@@ -150,6 +213,9 @@ function spawnHash(options: CassetteSpawn | OpenOptions): string {
|
|
|
150
213
|
...(options.appendSystemPrompt === undefined
|
|
151
214
|
? {}
|
|
152
215
|
: { appendSystemPrompt: options.appendSystemPrompt }),
|
|
216
|
+
...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
|
|
217
|
+
? {}
|
|
218
|
+
: { declaredExtensions: options.declaredExtensions }),
|
|
153
219
|
}),
|
|
154
220
|
);
|
|
155
221
|
return hasher.digest("hex");
|
|
@@ -5,12 +5,13 @@ import type {
|
|
|
5
5
|
AskPlayback,
|
|
6
6
|
Frame,
|
|
7
7
|
OpenOptions,
|
|
8
|
+
RecordedSpawnSelection,
|
|
8
9
|
TransportFactory,
|
|
9
10
|
TransportStartupObserver,
|
|
10
11
|
} from "../transport/index.ts";
|
|
11
12
|
import type { Cassette } from "./cassette.ts";
|
|
12
13
|
import { CassetteReplay } from "./cassette-replay.ts";
|
|
13
|
-
import { replayMismatch, strictReplay } from "./replay-divergence.ts";
|
|
14
|
+
import { recordedSpawnSelection, replayMismatch, strictReplay } from "./replay-divergence.ts";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Creates a strict Cassette-backed transport factory without starting pi.
|
|
@@ -32,6 +33,12 @@ export function replayTransport(cassette: Cassette): TransportFactory {
|
|
|
32
33
|
if (agent.worktree !== undefined) observeStartup?.({ worktree: agent.worktree });
|
|
33
34
|
return new ReplayTransport(new CassetteReplay(agent));
|
|
34
35
|
},
|
|
36
|
+
|
|
37
|
+
// Strict replay always offers the recorded selection: an outcome the
|
|
38
|
+
// declared spec can no longer produce must diverge at open (ADR-0039).
|
|
39
|
+
recordedSpawn(): RecordedSpawnSelection | undefined {
|
|
40
|
+
return recordedSpawnSelection(cassette.agents[spawnCursor] ?? null);
|
|
41
|
+
},
|
|
35
42
|
};
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -6,13 +6,18 @@ import type {
|
|
|
6
6
|
AskPlayback,
|
|
7
7
|
Frame,
|
|
8
8
|
OpenOptions,
|
|
9
|
+
RecordedSpawnSelection,
|
|
9
10
|
TransportFactory,
|
|
10
11
|
TransportStartupObserver,
|
|
11
12
|
} from "../transport/index.ts";
|
|
12
13
|
import { FrameQueue } from "../transport/index.ts";
|
|
13
14
|
import type { Cassette, CassetteAgent } from "./cassette.ts";
|
|
14
15
|
import { CassetteReplay } from "./cassette-replay.ts";
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
recordedSpawnSelection,
|
|
18
|
+
replayMismatch,
|
|
19
|
+
spawnMatchesExceptModel,
|
|
20
|
+
} from "./replay-divergence.ts";
|
|
16
21
|
import { checkResumePreconditions } from "./resume-preconditions.ts";
|
|
17
22
|
|
|
18
23
|
/**
|
|
@@ -39,6 +44,14 @@ export function resumeTransport(cassette: Cassette, live: TransportFactory): Tra
|
|
|
39
44
|
});
|
|
40
45
|
return new ResumeTransport(agent, live, options);
|
|
41
46
|
},
|
|
47
|
+
|
|
48
|
+
// A changed spawn must not silently inherit the recorded model, so the
|
|
49
|
+
// recorded selection is offered only when every other field matches.
|
|
50
|
+
recordedSpawn(options: OpenOptions): RecordedSpawnSelection | undefined {
|
|
51
|
+
const agent = cassette.agents[spawnCursor] ?? null;
|
|
52
|
+
if (agent === null || !spawnMatchesExceptModel(agent.spawn, options)) return undefined;
|
|
53
|
+
return recordedSpawnSelection(agent);
|
|
54
|
+
},
|
|
42
55
|
};
|
|
43
56
|
}
|
|
44
57
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
import { YaagError } from "../errors.ts";
|
|
5
|
+
import { describeConfigIssues } from "./config-issues.ts";
|
|
6
|
+
import { type YaagConfigDocument, YaagConfigSchema } from "./config-schema.ts";
|
|
7
|
+
|
|
8
|
+
/** One config file that contributed to the Effective Config. */
|
|
9
|
+
export interface ConfigLayer {
|
|
10
|
+
/** The file this layer was read from. */
|
|
11
|
+
readonly file: string;
|
|
12
|
+
/** The file's own directory: relative paths inside it resolve here (spec rule 4). */
|
|
13
|
+
readonly directory: string;
|
|
14
|
+
readonly document: YaagConfigDocument;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether an absent file is an empty layer or a Run-start failure. */
|
|
18
|
+
export interface ReadConfigOptions {
|
|
19
|
+
/** True for the explicit Run Config: a missing file is then an error. */
|
|
20
|
+
readonly required: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads, parses, and validates one config layer.
|
|
25
|
+
*
|
|
26
|
+
* A missing file at a discovered location yields `undefined` (an empty layer);
|
|
27
|
+
* a missing explicit Run Config fails. Any other errno — `EACCES`, say — always
|
|
28
|
+
* throws: a config yaag cannot read is not an empty config.
|
|
29
|
+
*/
|
|
30
|
+
export async function readConfigLayer(
|
|
31
|
+
path: string,
|
|
32
|
+
options: ReadConfigOptions,
|
|
33
|
+
): Promise<ConfigLayer | undefined> {
|
|
34
|
+
let text: string;
|
|
35
|
+
try {
|
|
36
|
+
text = await readFile(path, "utf8");
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (isMissing(error)) {
|
|
39
|
+
if (!options.required) return undefined;
|
|
40
|
+
throw invalid(`config file "${path}" does not exist`);
|
|
41
|
+
}
|
|
42
|
+
throw invalid(`cannot read config "${path}": ${String(error)}`);
|
|
43
|
+
}
|
|
44
|
+
let value: unknown;
|
|
45
|
+
try {
|
|
46
|
+
value = JSON.parse(text);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw invalid(`cannot parse config "${path}": ${String(error)}`);
|
|
49
|
+
}
|
|
50
|
+
const errors = Value.Errors(YaagConfigSchema, value);
|
|
51
|
+
if (errors.length > 0) {
|
|
52
|
+
throw invalid(`invalid config "${path}": ${describeConfigIssues(value, errors).join("; ")}`);
|
|
53
|
+
}
|
|
54
|
+
// Sound: the strict schema above accepted `value`, so it has exactly this shape.
|
|
55
|
+
const document = value as YaagConfigDocument;
|
|
56
|
+
return { file: path, directory: dirname(path), document };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function invalid(message: string): YaagError {
|
|
60
|
+
return new YaagError("CONFIG_INVALID", message);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isMissing(error: unknown): boolean {
|
|
64
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
65
|
+
const { code } = error;
|
|
66
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
67
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { TLocalizedValidationError } from "typebox/error";
|
|
2
|
+
import { formatValidationErrors } from "../validation-errors.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Turns TypeBox errors into config diagnostics that name the offending key
|
|
6
|
+
* (spec rule 2).
|
|
7
|
+
*
|
|
8
|
+
* A strict object reports an unknown key twice: once at the key itself with the
|
|
9
|
+
* unhelpful `"schema is false"`, and once at the owning object carrying the key
|
|
10
|
+
* name. Neither alone reads well, so the pair collapses into one line. Every
|
|
11
|
+
* other keyword delegates to the shared formatter, which stays untouched
|
|
12
|
+
* because args validation and ADR-0032 steering text depend on its wording.
|
|
13
|
+
*/
|
|
14
|
+
export function describeConfigIssues(
|
|
15
|
+
value: unknown,
|
|
16
|
+
errors: Iterable<TLocalizedValidationError>,
|
|
17
|
+
): readonly string[] {
|
|
18
|
+
return Array.from(errors).flatMap((error) => {
|
|
19
|
+
if (error.keyword === "additionalProperties") {
|
|
20
|
+
const [line] = formatValidationErrors(value, [error]);
|
|
21
|
+
// The formatter renders `<path>: <message>`; the message carries no colon,
|
|
22
|
+
// so the last one is the separator even for quoted path segments.
|
|
23
|
+
const prefix = line === undefined ? "$" : line.slice(0, line.lastIndexOf(":"));
|
|
24
|
+
return error.params.additionalProperties.map(
|
|
25
|
+
(key) => `${prefix}: unknown key ${JSON.stringify(key)}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (error.keyword === "boolean") return [];
|
|
29
|
+
return formatValidationErrors(value, [error]);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The part of the environment this module reads. It is structural on purpose:
|
|
6
|
+
* the vendored declaration tree compiles with no ambient Node globals.
|
|
7
|
+
*/
|
|
8
|
+
export interface ConfigEnvironment {
|
|
9
|
+
readonly [name: string]: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Every config layer lives in a file with this name. */
|
|
13
|
+
export const CONFIG_FILE_NAME = "config.json";
|
|
14
|
+
|
|
15
|
+
/** The Project Config directory inside a Program Directory. */
|
|
16
|
+
export const PROJECT_CONFIG_DIR = ".yaag";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the Global Config path: `YAAG_CONFIG_DIR`, then an absolute
|
|
20
|
+
* `XDG_CONFIG_HOME`, then `$HOME/.config`. A relative `XDG_CONFIG_HOME` is
|
|
21
|
+
* ignored (XDG spec), exactly as in `resolveCheckpointDirectory`.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveGlobalConfigPath(env: ConfigEnvironment = process.env): string {
|
|
24
|
+
const override = env["YAAG_CONFIG_DIR"];
|
|
25
|
+
if (override !== undefined && override !== "") return join(override, CONFIG_FILE_NAME);
|
|
26
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
27
|
+
if (xdg !== undefined && isAbsolute(xdg)) return join(xdg, "yaag", CONFIG_FILE_NAME);
|
|
28
|
+
const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
|
|
29
|
+
return join(home, ".config", "yaag", CONFIG_FILE_NAME);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the Project Config path under a Program Directory. The Program
|
|
34
|
+
* Directory is an input: this module never walks upward looking for `.yaag/`.
|
|
35
|
+
*/
|
|
36
|
+
export function projectConfigPath(programDirectory: string): string {
|
|
37
|
+
return join(programDirectory, PROJECT_CONFIG_DIR, CONFIG_FILE_NAME);
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The config document of one layer (spec rule 1): plain JSON, namespaced as
|
|
5
|
+
* `{ "agents": { "extensions": ["..."] } }`.
|
|
6
|
+
*
|
|
7
|
+
* Every node sets `additionalProperties: false` on purpose (spec rule 2) — the
|
|
8
|
+
* opposite choice from `cassette-schema.ts`, which tolerates unknown fields so
|
|
9
|
+
* a newer artifact still loads. A config file is hand-written, so an unknown
|
|
10
|
+
* key is a typo the author wants named, not forward compatibility.
|
|
11
|
+
*/
|
|
12
|
+
export const YaagConfigSchema = Type.Object(
|
|
13
|
+
{
|
|
14
|
+
agents: Type.Optional(
|
|
15
|
+
Type.Object(
|
|
16
|
+
{ extensions: Type.Optional(Type.Array(Type.String())) },
|
|
17
|
+
{ additionalProperties: false },
|
|
18
|
+
),
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
{ additionalProperties: false },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** The validated shape of one config file. */
|
|
25
|
+
export type YaagConfigDocument = Static<typeof YaagConfigSchema>;
|