@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { AskOutputChannel } from "./events.ts";
|
|
2
|
+
import type { Frame } from "./transport.ts";
|
|
3
|
+
import {
|
|
4
|
+
ASK_OUTPUT_FLUSH_INTERVAL_MS,
|
|
5
|
+
ASK_OUTPUT_MAX_BYTES,
|
|
6
|
+
ASK_OUTPUT_TRUNCATION_MARKER,
|
|
7
|
+
} from "./wire-constants.ts";
|
|
8
|
+
|
|
9
|
+
/** The timer boundary used to test output batching without changing its wire rate. */
|
|
10
|
+
export interface AskOutputTailScheduler<Handle = unknown> {
|
|
11
|
+
schedule(callback: () => void): Handle;
|
|
12
|
+
cancel(timer: Handle): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface AskOutputTailOptions {
|
|
16
|
+
readonly report: (channel: AskOutputChannel, text: string) => void;
|
|
17
|
+
readonly scheduler?: AskOutputTailScheduler;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OutputSegment {
|
|
21
|
+
readonly channel: AskOutputChannel;
|
|
22
|
+
readonly text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const scheduler: AskOutputTailScheduler<ReturnType<typeof setTimeout>> = {
|
|
27
|
+
schedule: (callback) => setTimeout(callback, ASK_OUTPUT_FLUSH_INTERVAL_MS),
|
|
28
|
+
cancel: (timer) => clearTimeout(timer),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Coalesces live assistant deltas into channel-tagged, UTF-8-bounded output tails.
|
|
33
|
+
*
|
|
34
|
+
* Malformed and unsupported frames are ignored. `close()` flushes synchronously and
|
|
35
|
+
* prevents a timer from emitting after an Ask terminal event.
|
|
36
|
+
*/
|
|
37
|
+
export class AskOutputTail {
|
|
38
|
+
readonly #report: (channel: AskOutputChannel, text: string) => void;
|
|
39
|
+
readonly #scheduler: AskOutputTailScheduler;
|
|
40
|
+
#segments: OutputSegment[] = [];
|
|
41
|
+
#timer: unknown;
|
|
42
|
+
#truncated = false;
|
|
43
|
+
#closed = false;
|
|
44
|
+
|
|
45
|
+
constructor(options: AskOutputTailOptions) {
|
|
46
|
+
this.#report = options.report;
|
|
47
|
+
this.#scheduler = options.scheduler ?? scheduler;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Inspects one frame and queues only non-empty assistant thinking or text deltas. */
|
|
51
|
+
observe(frame: Frame): void {
|
|
52
|
+
if (this.#closed) return;
|
|
53
|
+
const segment = outputFromFrame(frame);
|
|
54
|
+
if (segment === null) return;
|
|
55
|
+
this.#append(segment);
|
|
56
|
+
if (this.#timer === undefined) this.#timer = this.#scheduler.schedule(() => this.flush());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Emits one pending batch immediately and cancels its scheduled trailing flush. */
|
|
60
|
+
flush(): void {
|
|
61
|
+
this.#cancelTimer();
|
|
62
|
+
if (this.#segments.length === 0) return;
|
|
63
|
+
const segments = this.#truncated ? prependMarker(this.#segments) : this.#segments;
|
|
64
|
+
this.#segments = [];
|
|
65
|
+
this.#truncated = false;
|
|
66
|
+
for (const segment of segments) this.#report(segment.channel, segment.text);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Flushes remaining output and prevents future observations or timer emissions. */
|
|
70
|
+
close(): void {
|
|
71
|
+
this.flush();
|
|
72
|
+
this.#closed = true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#append(segment: OutputSegment): void {
|
|
76
|
+
const previous = this.#segments.at(-1);
|
|
77
|
+
this.#segments =
|
|
78
|
+
previous?.channel === segment.channel
|
|
79
|
+
? [
|
|
80
|
+
...this.#segments.slice(0, -1),
|
|
81
|
+
{ channel: segment.channel, text: previous.text + segment.text },
|
|
82
|
+
]
|
|
83
|
+
: [...this.#segments, segment];
|
|
84
|
+
if (this.#truncated || byteLength(this.#segments) > ASK_OUTPUT_MAX_BYTES) {
|
|
85
|
+
this.#truncated = true;
|
|
86
|
+
this.#segments = coalesce(
|
|
87
|
+
newestTail(this.#segments, ASK_OUTPUT_MAX_BYTES - bytes(ASK_OUTPUT_TRUNCATION_MARKER)),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#cancelTimer(): void {
|
|
93
|
+
if (this.#timer === undefined) return;
|
|
94
|
+
this.#scheduler.cancel(this.#timer);
|
|
95
|
+
this.#timer = undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function outputFromFrame(frame: Frame): OutputSegment | null {
|
|
100
|
+
if (frame.type !== "message_update" || !isRecord(frame.assistantMessageEvent)) return null;
|
|
101
|
+
const { type, delta } = frame.assistantMessageEvent;
|
|
102
|
+
if (typeof delta !== "string" || delta === "") return null;
|
|
103
|
+
if (type === "text_delta") return { channel: "text", text: delta };
|
|
104
|
+
if (type === "thinking_delta") return { channel: "thinking", text: delta };
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function prependMarker(segments: readonly OutputSegment[]): OutputSegment[] {
|
|
109
|
+
const first = segments[0];
|
|
110
|
+
if (first === undefined) return [];
|
|
111
|
+
return [{ ...first, text: ASK_OUTPUT_TRUNCATION_MARKER + first.text }, ...segments.slice(1)];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function coalesce(segments: readonly OutputSegment[]): OutputSegment[] {
|
|
115
|
+
const coalesced: OutputSegment[] = [];
|
|
116
|
+
for (const segment of segments) {
|
|
117
|
+
const previous = coalesced.at(-1);
|
|
118
|
+
if (previous?.channel === segment.channel) {
|
|
119
|
+
coalesced[coalesced.length - 1] = {
|
|
120
|
+
channel: segment.channel,
|
|
121
|
+
text: previous.text + segment.text,
|
|
122
|
+
};
|
|
123
|
+
} else {
|
|
124
|
+
coalesced.push(segment);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return coalesced;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function newestTail(segments: readonly OutputSegment[], limit: number): OutputSegment[] {
|
|
131
|
+
let remaining = limit;
|
|
132
|
+
const tail: OutputSegment[] = [];
|
|
133
|
+
for (const segment of [...segments].reverse()) {
|
|
134
|
+
if (remaining === 0) break;
|
|
135
|
+
const text = suffixWithinBytes(segment.text, remaining);
|
|
136
|
+
if (text !== "") {
|
|
137
|
+
tail.unshift({ channel: segment.channel, text });
|
|
138
|
+
remaining -= bytes(text);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return tail;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function suffixWithinBytes(value: string, limit: number): string {
|
|
145
|
+
let total = 0;
|
|
146
|
+
let start = value.length;
|
|
147
|
+
for (const point of Array.from(value).reverse()) {
|
|
148
|
+
const size = bytes(point);
|
|
149
|
+
if (total + size > limit) break;
|
|
150
|
+
total += size;
|
|
151
|
+
start -= point.length;
|
|
152
|
+
}
|
|
153
|
+
return value.slice(start);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function byteLength(segments: readonly OutputSegment[]): number {
|
|
157
|
+
return segments.reduce((total, segment) => total + bytes(segment.text), 0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function bytes(value: string): number {
|
|
161
|
+
return encoder.encode(value).byteLength;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
165
|
+
return typeof value === "object" && value !== null;
|
|
166
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
import type { AskInvalidOutputOutcome } from "./errors.ts";
|
|
4
|
+
import { YaagError } from "./errors.ts";
|
|
5
|
+
import type { AskInvalidOutputPlayback } from "./transport.ts";
|
|
6
|
+
import { formatValidationErrors } from "./validation-errors.ts";
|
|
7
|
+
|
|
8
|
+
/** Versioned extraction behavior. It becomes Cassette identity in issue 04. */
|
|
9
|
+
export const ASK_OUTPUT_EXTRACTION_POLICY = "json-first-block/v1";
|
|
10
|
+
|
|
11
|
+
/** The currently implemented extraction policy identifier. */
|
|
12
|
+
export type AskOutputExtractionPolicy = typeof ASK_OUTPUT_EXTRACTION_POLICY;
|
|
13
|
+
|
|
14
|
+
export type AskOutputResult =
|
|
15
|
+
| { readonly ok: true; readonly value: unknown }
|
|
16
|
+
| { readonly ok: false; readonly errors: readonly string[] };
|
|
17
|
+
|
|
18
|
+
/** Extracts the first parseable JSON object or array and validates it without coercion. */
|
|
19
|
+
export function validateAskOutput(text: string, schema: TSchema): AskOutputResult {
|
|
20
|
+
const value = firstJsonBlock(text);
|
|
21
|
+
if (value === undefined) return { ok: false, errors: ["$: no parseable JSON block found"] };
|
|
22
|
+
const errors = formatValidationErrors(value, Value.Errors(schema, value));
|
|
23
|
+
return errors.length === 0 ? { ok: true, value } : { ok: false, errors };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Produces the deterministic prompt for one correction effort. */
|
|
27
|
+
export function formatAskOutputCorrection(errors: readonly string[]): string {
|
|
28
|
+
return [
|
|
29
|
+
"Your previous result did not satisfy the required JSON output schema:",
|
|
30
|
+
...errors,
|
|
31
|
+
"Please resend a complete conforming JSON result.",
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolves a replayed Ask's structured output from its recorded text and outcome.
|
|
37
|
+
*
|
|
38
|
+
* A recorded invalid-output outcome rethrows deterministically (ADR-0027); otherwise
|
|
39
|
+
* the text is re-validated so playback and live settlement agree. Throws the same
|
|
40
|
+
* recoverable ASK_INVALID_OUTPUT error as a live Ask on validation failure.
|
|
41
|
+
*/
|
|
42
|
+
export function resolvePlaybackAskOutput(
|
|
43
|
+
agent: string,
|
|
44
|
+
schema: TSchema,
|
|
45
|
+
text: string,
|
|
46
|
+
recordedOutcome: AskInvalidOutputPlayback | undefined,
|
|
47
|
+
): unknown {
|
|
48
|
+
const result = validateAskOutput(text, schema);
|
|
49
|
+
const errors = result.ok ? [] : result.errors;
|
|
50
|
+
if (recordedOutcome !== undefined) {
|
|
51
|
+
throw invalidAskOutputError(agent, {
|
|
52
|
+
steeringEfforts: recordedOutcome.steeringEfforts,
|
|
53
|
+
errors,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (result.ok) return result.value;
|
|
57
|
+
throw invalidAskOutputError(agent, { steeringEfforts: 0, errors });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Creates the recoverable error for final structured-output validation failure. */
|
|
61
|
+
export function invalidAskOutputError(agent: string, outcome: AskInvalidOutputOutcome): YaagError {
|
|
62
|
+
return new YaagError(
|
|
63
|
+
"ASK_INVALID_OUTPUT",
|
|
64
|
+
`agent "${agent}": invalid structured output after ${outcome.steeringEfforts} steering efforts: ${outcome.errors.join("\n")}`,
|
|
65
|
+
agent,
|
|
66
|
+
outcome,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function firstJsonBlock(text: string): unknown | undefined {
|
|
71
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
72
|
+
if (text[index] !== "{" && text[index] !== "[") continue;
|
|
73
|
+
const candidate = balancedJson(text, index);
|
|
74
|
+
if (candidate === undefined) continue;
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(candidate);
|
|
77
|
+
} catch {
|
|
78
|
+
// The first syntactically balanced source fragment need not be valid JSON.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function balancedJson(text: string, start: number): string | undefined {
|
|
85
|
+
const opens: string[] = [];
|
|
86
|
+
let quote = false;
|
|
87
|
+
let escaped = false;
|
|
88
|
+
for (let index = start; index < text.length; index += 1) {
|
|
89
|
+
const character = text[index];
|
|
90
|
+
if (quote) {
|
|
91
|
+
if (escaped) escaped = false;
|
|
92
|
+
else if (character === "\\") escaped = true;
|
|
93
|
+
else if (character === '"') quote = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (character === '"') {
|
|
97
|
+
quote = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (character === "{" || character === "[") opens.push(character);
|
|
101
|
+
if (character !== "}" && character !== "]") continue;
|
|
102
|
+
const opening = opens.pop();
|
|
103
|
+
if ((character === "}" && opening !== "{") || (character === "]" && opening !== "[")) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
if (opens.length === 0) return text.slice(start, index + 1);
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { YaagError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
export interface AwaitAskSettlementOptions {
|
|
4
|
+
readonly settled: Promise<void>;
|
|
5
|
+
readonly closed: Promise<void>;
|
|
6
|
+
readonly timeoutMs: number | undefined;
|
|
7
|
+
readonly controlFailure: Promise<never>;
|
|
8
|
+
readonly error: (code: YaagError["code"], message: string) => YaagError;
|
|
9
|
+
readonly close: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Waits for one effort's settlement while preserving the Ask-wide timeout and death semantics. */
|
|
13
|
+
export async function awaitAskSettlement(options: AwaitAskSettlementOptions): Promise<void> {
|
|
14
|
+
const death = options.closed.then(() => {
|
|
15
|
+
throw options.error("AGENT_DIED", "agent exited with an Ask pending");
|
|
16
|
+
});
|
|
17
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
18
|
+
const expiry =
|
|
19
|
+
options.timeoutMs === undefined
|
|
20
|
+
? new Promise<never>(() => {})
|
|
21
|
+
: new Promise<never>((_resolve, reject) => {
|
|
22
|
+
timer = setTimeout(
|
|
23
|
+
() => reject(options.error("ASK_TIMEOUT", `no answer in ${options.timeoutMs}ms`)),
|
|
24
|
+
options.timeoutMs,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
await Promise.race([options.settled, death, expiry, options.controlFailure]);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof YaagError && (error.code === "ASK_TIMEOUT" || error.destructive)) {
|
|
31
|
+
options.close();
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
} finally {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/ask-turn.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Frame } from "./transport.ts";
|
|
2
|
+
|
|
3
|
+
interface AssistantEnd {
|
|
4
|
+
readonly stopReason: string | null;
|
|
5
|
+
readonly errorMessage: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Interprets the frames of one Ask (ticket 01's 8-point contract).
|
|
10
|
+
*
|
|
11
|
+
* Two rules here are counter-intuitive and load-bearing:
|
|
12
|
+
* - `agent_end` is ignored entirely. `willRetry: true` is followed by a second
|
|
13
|
+
* `agent_start` for the same Ask; only `agent_settled` terminates one.
|
|
14
|
+
* - Only the *last* assistant `message_end` decides success. A retried-then-
|
|
15
|
+
* successful Ask emits an early `stopReason: "error"`, so an earlier one would
|
|
16
|
+
* falsely reject a recovered turn.
|
|
17
|
+
*/
|
|
18
|
+
export class AskTurn {
|
|
19
|
+
#stopReason: string | null = null;
|
|
20
|
+
#errorMessage: string | null = null;
|
|
21
|
+
#settled = false;
|
|
22
|
+
|
|
23
|
+
observe(frame: Frame): void {
|
|
24
|
+
if (frame.type === "agent_settled") {
|
|
25
|
+
this.#settled = true;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (frame.type !== "message_end") return;
|
|
29
|
+
const end = assistantEnd(frame);
|
|
30
|
+
if (!end) return;
|
|
31
|
+
this.#stopReason = end.stopReason;
|
|
32
|
+
this.#errorMessage = end.errorMessage;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True once `agent_settled` has been seen — settled says nothing about success. */
|
|
36
|
+
get settled(): boolean {
|
|
37
|
+
return this.#settled;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Starts a new correction effort without retaining the preceding terminal state. */
|
|
41
|
+
rearm(): void {
|
|
42
|
+
this.#stopReason = null;
|
|
43
|
+
this.#errorMessage = null;
|
|
44
|
+
this.#settled = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The failure message when the turn ended badly, else null.
|
|
49
|
+
* `agent_settled` fires on failed turns too, so this check is what
|
|
50
|
+
* distinguishes them (ticket 01 §3).
|
|
51
|
+
*/
|
|
52
|
+
failure(): string | null {
|
|
53
|
+
if (this.#stopReason !== "error" && this.#stopReason !== "aborted") return null;
|
|
54
|
+
return this.#errorMessage ?? `turn ended with stopReason "${this.#stopReason}"`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Reads an assistant `message_end`; null for user and toolResult messages. */
|
|
59
|
+
function assistantEnd(frame: Frame): AssistantEnd | null {
|
|
60
|
+
const message: unknown = frame.message;
|
|
61
|
+
if (typeof message !== "object" || message === null) return null;
|
|
62
|
+
if (!("role" in message) || message.role !== "assistant") return null;
|
|
63
|
+
const stopReason =
|
|
64
|
+
"stopReason" in message && typeof message.stopReason === "string" ? message.stopReason : null;
|
|
65
|
+
const errorMessage =
|
|
66
|
+
"errorMessage" in message && typeof message.errorMessage === "string"
|
|
67
|
+
? message.errorMessage
|
|
68
|
+
: null;
|
|
69
|
+
return { stopReason, errorMessage };
|
|
70
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Value } from "typebox/value";
|
|
2
|
+
import {
|
|
3
|
+
validateCanonicalOutputSchema,
|
|
4
|
+
validateExtractionPolicy,
|
|
5
|
+
} from "./ask-contract-identity.ts";
|
|
6
|
+
import {
|
|
7
|
+
CASSETTE_VERSION,
|
|
8
|
+
type Cassette,
|
|
9
|
+
type CassetteArtifact,
|
|
10
|
+
type CassetteAsk,
|
|
11
|
+
} from "./cassette.ts";
|
|
12
|
+
import { CassetteSchema } from "./cassette-schema.ts";
|
|
13
|
+
import { formatValidationErrors } from "./validation-errors.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reads and validates one Cassette artifact before replay begins.
|
|
17
|
+
*
|
|
18
|
+
* Rejects unreadable, unparsable, unsupported, and malformed artifacts at the
|
|
19
|
+
* filesystem boundary; replay never receives unchecked external data. The
|
|
20
|
+
* schema checks structure; the five named invariants below check semantics.
|
|
21
|
+
* A version 1 artifact is upgraded in memory to version 2 (ADR-0021).
|
|
22
|
+
* Unknown fields are retained, never dropped (forward tolerance).
|
|
23
|
+
*/
|
|
24
|
+
export async function loadCassette(path: string): Promise<Cassette> {
|
|
25
|
+
let text: string;
|
|
26
|
+
try {
|
|
27
|
+
text = await Bun.file(path).text();
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(`cannot read cassette "${path}": ${String(error)}`, { cause: error });
|
|
30
|
+
}
|
|
31
|
+
let value: unknown;
|
|
32
|
+
try {
|
|
33
|
+
value = JSON.parse(text);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error(`cannot parse cassette "${path}": ${String(error)}`, { cause: error });
|
|
36
|
+
}
|
|
37
|
+
gateVersion(value, path);
|
|
38
|
+
const errors = Value.Errors(CassetteSchema, value);
|
|
39
|
+
if (errors.length > 0) malformed(path, formatValidationErrors(value, errors).join("; "));
|
|
40
|
+
const artifact = value as CassetteArtifact; // sound: CassetteSchemaIsSound (cassette-schema.ts)
|
|
41
|
+
assertRunPresence(artifact, path);
|
|
42
|
+
for (const [agentIndex, agent] of artifact.agents.entries()) {
|
|
43
|
+
agent.asks.forEach((ask, askIndex) => {
|
|
44
|
+
const label = `agents[${agentIndex}].asks[${askIndex}]`;
|
|
45
|
+
assertAskPosition(ask, askIndex, path, label);
|
|
46
|
+
assertContract(ask, path, label);
|
|
47
|
+
if (ask.context !== undefined) assertContract(ask.context.ask, path, `${label}.context.ask`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return upgrade(artifact);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Invariant 1: the version gate, with its distinct non-"malformed" message. */
|
|
54
|
+
function gateVersion(value: unknown, path: string): void {
|
|
55
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
56
|
+
malformed(path, "root must be an object");
|
|
57
|
+
}
|
|
58
|
+
const version = (value as Record<string, unknown>).v;
|
|
59
|
+
if (typeof version !== "number") malformed(path, "v must be a number");
|
|
60
|
+
if (version !== 1 && version !== CASSETTE_VERSION) {
|
|
61
|
+
throw new Error(`cassette version ${version} is unsupported; runtime expects version 1 or 2`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Invariant 2: Ask indices are monotonic per Agent from 0 (AskMarker); each
|
|
67
|
+
* recorded Ask must sit at its own position, or replay's index-based matching
|
|
68
|
+
* would lie.
|
|
69
|
+
*/
|
|
70
|
+
function assertAskPosition(ask: CassetteAsk, position: number, path: string, label: string): void {
|
|
71
|
+
if (ask.index !== position) {
|
|
72
|
+
malformed(path, `${label}.index must be ${position}, the Ask's position in sequence`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Invariants 3 + 4: contract co-presence, then canonical identity checks. */
|
|
77
|
+
function assertContract(
|
|
78
|
+
contract: {
|
|
79
|
+
readonly outputSchema?: unknown;
|
|
80
|
+
readonly maxSteers?: number;
|
|
81
|
+
readonly extractionPolicy?: string;
|
|
82
|
+
},
|
|
83
|
+
path: string,
|
|
84
|
+
label: string,
|
|
85
|
+
): void {
|
|
86
|
+
const hasSchema = contract.outputSchema !== undefined;
|
|
87
|
+
const hasPolicy = contract.extractionPolicy !== undefined;
|
|
88
|
+
const hasMaxSteers = contract.maxSteers !== undefined;
|
|
89
|
+
if (!hasSchema && !hasPolicy && !hasMaxSteers) return;
|
|
90
|
+
if (!hasSchema) {
|
|
91
|
+
malformed(
|
|
92
|
+
path,
|
|
93
|
+
`${label}.${hasPolicy ? "outputSchema" : "maxSteers"} must accompany outputSchema`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (!hasPolicy) malformed(path, `${label}.extractionPolicy must accompany outputSchema`);
|
|
97
|
+
try {
|
|
98
|
+
validateCanonicalOutputSchema(contract.outputSchema);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
malformed(path, `${label}.outputSchema ${message(error)}`);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
validateExtractionPolicy(contract.extractionPolicy);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
malformed(path, `${label}.extractionPolicy ${message(error)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Invariant 5: an artifact from version 2 on states its Run's outcome. A
|
|
111
|
+
* version 1 artifact was only ever written on settlement, so it loads as
|
|
112
|
+
* `completed`. Keyed on `v !== 1` so a future version bump cannot silently
|
|
113
|
+
* lapse the check.
|
|
114
|
+
*/
|
|
115
|
+
function assertRunPresence(artifact: CassetteArtifact, path: string): void {
|
|
116
|
+
if (artifact.v !== 1 && artifact.run === undefined) {
|
|
117
|
+
malformed(path, "run must be present in a version 2 cassette");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function upgrade(artifact: CassetteArtifact): Cassette {
|
|
122
|
+
return { ...artifact, v: CASSETTE_VERSION, run: artifact.run ?? { outcome: "completed" } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function message(error: unknown): string {
|
|
126
|
+
return error instanceof Error ? error.message : "is invalid";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function malformed(path: string, detail: string): never {
|
|
130
|
+
throw new Error(`malformed cassette "${path}": ${detail}`);
|
|
131
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { open, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import type { Cassette } from "./cassette.ts";
|
|
4
|
+
|
|
5
|
+
/** Prefix of every in-flight publication temp file, so a later start can clean stale ones (ADR-0021). */
|
|
6
|
+
export const CASSETTE_TEMP_PREFIX = ".yaag-tmp-";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Publishes a Cassette crash-durably: temp file, fsync, rename, destination
|
|
10
|
+
* directory fsync. Resolves only after all four steps complete (ADR-0021).
|
|
11
|
+
*
|
|
12
|
+
* Rejects with an error naming `path` when any step fails; the temp file is
|
|
13
|
+
* removed on a best-effort basis first.
|
|
14
|
+
*/
|
|
15
|
+
export async function publishCassette(path: string, cassette: Cassette): Promise<void> {
|
|
16
|
+
const directory = dirname(path);
|
|
17
|
+
const temp = join(directory, `${CASSETTE_TEMP_PREFIX}${process.pid}-${basename(path)}`);
|
|
18
|
+
try {
|
|
19
|
+
await writeDurable(temp, JSON.stringify(cassette));
|
|
20
|
+
await rename(temp, path);
|
|
21
|
+
await syncDirectory(directory);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
await discard(temp);
|
|
24
|
+
throw new Error(`failed to publish cassette ${path}: ${String(error)}`, { cause: error });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Private artifact: the checkpoint may quote Agent output, so it is owner-only (ADR-0021). */
|
|
29
|
+
async function writeDurable(path: string, contents: string): Promise<void> {
|
|
30
|
+
const file = await open(path, "w", 0o600);
|
|
31
|
+
try {
|
|
32
|
+
await file.writeFile(contents);
|
|
33
|
+
await file.sync();
|
|
34
|
+
} finally {
|
|
35
|
+
await file.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A rename is only durable once its directory entry is on disk (ADR-0021). */
|
|
40
|
+
async function syncDirectory(directory: string): Promise<void> {
|
|
41
|
+
const handle = await open(directory, "r");
|
|
42
|
+
try {
|
|
43
|
+
await handle.sync();
|
|
44
|
+
} finally {
|
|
45
|
+
await handle.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function discard(path: string): Promise<void> {
|
|
50
|
+
try {
|
|
51
|
+
await unlink(path);
|
|
52
|
+
} catch {
|
|
53
|
+
// The temp file may never have existed; a stale one is cleaned at a later start.
|
|
54
|
+
}
|
|
55
|
+
}
|