@yaag/runtime 0.1.2 → 0.2.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 +3 -3
- package/src/agent.ts +11 -3
- package/src/ask-contract-identity.ts +23 -5
- package/src/ask-exchange-events.ts +14 -1
- package/src/ask-exchange-options.ts +7 -2
- package/src/ask-exchange.ts +198 -26
- package/src/ask-hash.ts +7 -3
- package/src/ask-limit.ts +27 -11
- package/src/ask-output-steering.ts +1 -3
- package/src/ask-output.ts +20 -4
- package/src/ask-turn.ts +11 -0
- package/src/cassette-loader.ts +24 -0
- package/src/cassette-replay.ts +76 -7
- package/src/cassette-schema.ts +17 -2
- package/src/cassette.ts +10 -9
- package/src/checkpoint-flush.ts +101 -0
- package/src/connection.ts +20 -0
- package/src/define-agent.ts +11 -5
- package/src/errors.ts +11 -2
- package/src/events.ts +29 -3
- package/src/fake-transport.ts +55 -0
- package/src/frame-queue.ts +5 -0
- package/src/index.ts +11 -1
- package/src/model-resolution.ts +119 -0
- package/src/model-suffix.ts +24 -0
- package/src/pi-state.ts +63 -8
- package/src/recording-transport.ts +8 -8
- package/src/replay-divergence.ts +1 -0
- package/src/replay-transport.ts +4 -0
- package/src/report-result-extension.ts +128 -0
- package/src/report-result-output.ts +73 -0
- package/src/report-result-steering.ts +83 -0
- package/src/report-result.ts +122 -0
- package/src/resume-transport.ts +26 -8
- package/src/run-checkpoint.ts +93 -41
- package/src/run.ts +86 -32
- package/src/spawn.ts +29 -4
- package/src/stall-watchdog.ts +193 -0
- package/src/summary-agent.ts +11 -2
- package/src/summary.ts +23 -2
- package/src/thinking-level.ts +32 -0
- package/src/transport.ts +43 -8
- package/src/types.ts +50 -14
- package/src/validation-errors.ts +10 -4
package/src/ask-limit.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import type { AskLimitKind, AskLimitOutcome } from "./errors.ts";
|
|
2
|
+
import { REPORT_RESULT_TOOL_NAME } from "./report-result.ts";
|
|
2
3
|
import type { Frame } from "./transport.ts";
|
|
3
4
|
import type { AskOptions } from "./types.ts";
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* True for a call to yaag's private result tool.
|
|
8
|
+
*
|
|
9
|
+
* `maxToolCalls` bounds the Agent's own work. The settling `report_result` call
|
|
10
|
+
* is yaag's mechanism, and counting it would both shrink every declared budget
|
|
11
|
+
* and let the settling call trip a wrap-up steer that pi parks for the next Ask
|
|
12
|
+
* (ADR-0032).
|
|
13
|
+
*/
|
|
14
|
+
function isReportResult(frame: Frame): boolean {
|
|
15
|
+
return frame.toolName === REPORT_RESULT_TOOL_NAME;
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
/** The message sent when an Ask uses its allotted soft budget. */
|
|
6
19
|
export const DEFAULT_WRAP_UP_PROMPT = "Please wrap up now and provide your final answer.";
|
|
7
20
|
/** Fixed time allowed to conclude after a duration limit trips. */
|
|
@@ -12,6 +25,8 @@ export interface AskLimitOptions {
|
|
|
12
25
|
readonly command: (frame: Frame) => Promise<boolean>;
|
|
13
26
|
readonly durationGraceMs?: number;
|
|
14
27
|
readonly now?: () => number;
|
|
28
|
+
/** One sentence appended to the wrap-up, so a structured Ask still reports its result. */
|
|
29
|
+
readonly wrapUpSuffix?: string;
|
|
15
30
|
}
|
|
16
31
|
|
|
17
32
|
/**
|
|
@@ -24,6 +39,7 @@ export class AskLimit {
|
|
|
24
39
|
readonly #ask: AskOptions;
|
|
25
40
|
readonly #command: (frame: Frame) => Promise<boolean>;
|
|
26
41
|
readonly #durationGraceMs: number;
|
|
42
|
+
readonly #wrapUpSuffix: string | undefined;
|
|
27
43
|
readonly #now: () => number;
|
|
28
44
|
readonly #failure: Promise<string>;
|
|
29
45
|
#rejectFailure: (reason: string) => void = () => {};
|
|
@@ -43,6 +59,7 @@ export class AskLimit {
|
|
|
43
59
|
this.#ask = options.ask;
|
|
44
60
|
this.#command = options.command;
|
|
45
61
|
this.#durationGraceMs = options.durationGraceMs ?? ASK_LIMIT_DURATION_GRACE_MS;
|
|
62
|
+
this.#wrapUpSuffix = options.wrapUpSuffix;
|
|
46
63
|
this.#now = options.now ?? Date.now;
|
|
47
64
|
this.#failure = new Promise<string>((_resolve, reject) => {
|
|
48
65
|
this.#rejectFailure = reject;
|
|
@@ -77,16 +94,10 @@ export class AskLimit {
|
|
|
77
94
|
}
|
|
78
95
|
return;
|
|
79
96
|
}
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
) {
|
|
85
|
-
this.#toolCalls += 1;
|
|
86
|
-
if (this.#toolCalls >= this.#ask.maxToolCalls) this.#tripLimit("toolCalls", this.#toolCalls);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
if (frame.type === "tool_execution_start") this.#toolCalls += 1;
|
|
97
|
+
if (frame.type !== "tool_execution_start" || isReportResult(frame)) return;
|
|
98
|
+
this.#toolCalls += 1;
|
|
99
|
+
if (this.#trip !== null || this.#ask.maxToolCalls === undefined) return;
|
|
100
|
+
if (this.#toolCalls >= this.#ask.maxToolCalls) this.#tripLimit("toolCalls", this.#toolCalls);
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
/**
|
|
@@ -146,12 +157,17 @@ export class AskLimit {
|
|
|
146
157
|
if (this.#completed || this.#trip !== null) return;
|
|
147
158
|
this.#trip = { kind, count };
|
|
148
159
|
this.#turnsAtSteer = this.#turns;
|
|
149
|
-
void this.#send({ type: "steer", message: this.#
|
|
160
|
+
void this.#send({ type: "steer", message: this.#wrapUpMessage() });
|
|
150
161
|
if (kind === "durationMs") {
|
|
151
162
|
this.#graceTimer = setTimeout(() => this.#abort(), this.#durationGraceMs);
|
|
152
163
|
}
|
|
153
164
|
}
|
|
154
165
|
|
|
166
|
+
#wrapUpMessage(): string {
|
|
167
|
+
const wrapUp = this.#ask.wrapUpPrompt ?? DEFAULT_WRAP_UP_PROMPT;
|
|
168
|
+
return this.#wrapUpSuffix === undefined ? wrapUp : `${wrapUp} ${this.#wrapUpSuffix}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
155
171
|
#abort(): void {
|
|
156
172
|
if (this.#completed || this.#aborted || this.#trip === null) return;
|
|
157
173
|
void this.#sendAbort();
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import type { TSchema } from "typebox";
|
|
2
2
|
import {
|
|
3
|
+
DEFAULT_MAX_STEERS,
|
|
3
4
|
formatAskOutputCorrection,
|
|
4
5
|
invalidAskOutputError,
|
|
5
6
|
validateAskOutput,
|
|
6
7
|
} from "./ask-output.ts";
|
|
7
8
|
|
|
8
|
-
/** The per-Ask bound when a structured Ask omits `maxSteers`. */
|
|
9
|
-
export const DEFAULT_MAX_STEERS = 3;
|
|
10
|
-
|
|
11
9
|
export interface AskOutputSteeringOptions {
|
|
12
10
|
readonly agent: string;
|
|
13
11
|
readonly schema: TSchema;
|
package/src/ask-output.ts
CHANGED
|
@@ -5,11 +5,24 @@ import { YaagError } from "./errors.ts";
|
|
|
5
5
|
import type { AskInvalidOutputPlayback } from "./transport.ts";
|
|
6
6
|
import { formatValidationErrors } from "./validation-errors.ts";
|
|
7
7
|
|
|
8
|
-
/** Versioned extraction behavior
|
|
9
|
-
export const ASK_OUTPUT_EXTRACTION_POLICY = "
|
|
8
|
+
/** Versioned extraction behavior, and part of Ask identity in a Cassette. */
|
|
9
|
+
export const ASK_OUTPUT_EXTRACTION_POLICY = "report-result-tool/v1";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The pre-ADR-0032 policy: the first JSON block of the final assistant text.
|
|
13
|
+
*
|
|
14
|
+
* No live Ask selects it. It stays reachable so a Cassette recorded under it
|
|
15
|
+
* replays through the same ADR-0027 code path it was recorded from.
|
|
16
|
+
*/
|
|
17
|
+
export const LEGACY_ASK_OUTPUT_EXTRACTION_POLICY = "json-first-block/v1";
|
|
18
|
+
|
|
19
|
+
/** The extraction policy identifiers this runtime can execute. */
|
|
20
|
+
export type AskOutputExtractionPolicy =
|
|
21
|
+
| typeof ASK_OUTPUT_EXTRACTION_POLICY
|
|
22
|
+
| typeof LEGACY_ASK_OUTPUT_EXTRACTION_POLICY;
|
|
23
|
+
|
|
24
|
+
/** The per-Ask bound when a structured Ask omits `maxSteers`. */
|
|
25
|
+
export const DEFAULT_MAX_STEERS = 3;
|
|
13
26
|
|
|
14
27
|
export type AskOutputResult =
|
|
15
28
|
| { readonly ok: true; readonly value: unknown }
|
|
@@ -38,6 +51,9 @@ export function formatAskOutputCorrection(errors: readonly string[]): string {
|
|
|
38
51
|
* A recorded invalid-output outcome rethrows deterministically (ADR-0027); otherwise
|
|
39
52
|
* the text is re-validated so playback and live settlement agree. Throws the same
|
|
40
53
|
* recoverable ASK_INVALID_OUTPUT error as a live Ask on validation failure.
|
|
54
|
+
*
|
|
55
|
+
* Its signature is frozen with the policy it serves: this path exists only for
|
|
56
|
+
* Cassettes recorded under `json-first-block/v1` (ADR-0032).
|
|
41
57
|
*/
|
|
42
58
|
export function resolvePlaybackAskOutput(
|
|
43
59
|
agent: string,
|
package/src/ask-turn.ts
CHANGED
|
@@ -19,6 +19,7 @@ export class AskTurn {
|
|
|
19
19
|
#stopReason: string | null = null;
|
|
20
20
|
#errorMessage: string | null = null;
|
|
21
21
|
#settled = false;
|
|
22
|
+
#terminal = false;
|
|
22
23
|
|
|
23
24
|
observe(frame: Frame): void {
|
|
24
25
|
if (frame.type === "agent_settled") {
|
|
@@ -30,6 +31,7 @@ export class AskTurn {
|
|
|
30
31
|
if (!end) return;
|
|
31
32
|
this.#stopReason = end.stopReason;
|
|
32
33
|
this.#errorMessage = end.errorMessage;
|
|
34
|
+
this.#terminal = true;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/** True once `agent_settled` has been seen — settled says nothing about success. */
|
|
@@ -37,11 +39,20 @@ export class AskTurn {
|
|
|
37
39
|
return this.#settled;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* True once an assistant `message_end` was seen — the Agent produced a final
|
|
44
|
+
* message even if `agent_settled` never arrived. The Stall Watchdog reads it.
|
|
45
|
+
*/
|
|
46
|
+
get terminal(): boolean {
|
|
47
|
+
return this.#terminal;
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
/** Starts a new correction effort without retaining the preceding terminal state. */
|
|
41
51
|
rearm(): void {
|
|
42
52
|
this.#stopReason = null;
|
|
43
53
|
this.#errorMessage = null;
|
|
44
54
|
this.#settled = false;
|
|
55
|
+
this.#terminal = false;
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
/**
|
package/src/cassette-loader.ts
CHANGED
|
@@ -122,6 +122,30 @@ function upgrade(artifact: CassetteArtifact): Cassette {
|
|
|
122
122
|
return { ...artifact, v: CASSETTE_VERSION, run: artifact.run ?? { outcome: "completed" } };
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Strict replay demands a settled Run. An `interrupted` artifact stops at the
|
|
127
|
+
* last Ask boundary, so its final Ask has no recorded settlement (ADR-0031).
|
|
128
|
+
*/
|
|
129
|
+
export function assertReplayable(cassette: Cassette, path: string): void {
|
|
130
|
+
if (cassette.run.outcome === "interrupted") {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`cannot replay interrupted cassette "${path}": the Run died before it settled; use resume instead`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Warns once when a resume source came from a hard death. The Run continues:
|
|
139
|
+
* resume already handles an incomplete last Ask.
|
|
140
|
+
*/
|
|
141
|
+
export function interruptedResumeWarning(cassette: Cassette, path: string): string | null {
|
|
142
|
+
if (cassette.run.outcome !== "interrupted") return null;
|
|
143
|
+
return (
|
|
144
|
+
`resuming interrupted cassette "${path}": the Run died before it settled, ` +
|
|
145
|
+
"the recording stops at the last Ask boundary, and re-run tools can repeat their effects"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
125
149
|
function message(error: unknown): string {
|
|
126
150
|
return error instanceof Error ? error.message : "is invalid";
|
|
127
151
|
}
|
package/src/cassette-replay.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { CassetteAgent } from "./cassette.ts";
|
|
2
2
|
import { FrameQueue } from "./frame-queue.ts";
|
|
3
|
+
import type { ReportedResult } from "./report-result.ts";
|
|
4
|
+
import { isReportResultCommandFrame, ReportResultCall } from "./report-result.ts";
|
|
3
5
|
import type { AgentStats, AskMarker, AskPlayback, Frame } from "./transport.ts";
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -17,6 +19,7 @@ export class CassetteReplay {
|
|
|
17
19
|
#receivedCursor = 0;
|
|
18
20
|
#sent: readonly Frame[] = [];
|
|
19
21
|
#received: readonly Frame[] = [];
|
|
22
|
+
#promptFloor = 0;
|
|
20
23
|
#ids = new Map<string, string>();
|
|
21
24
|
|
|
22
25
|
constructor(agent: CassetteAgent) {
|
|
@@ -38,8 +41,13 @@ export class CassetteReplay {
|
|
|
38
41
|
if (frame.type === "get_last_assistant_text") this.#skipCorrectionHistory();
|
|
39
42
|
const expected = this.#sent[this.#sentCursor];
|
|
40
43
|
if (expected && expected.type === frame.type) {
|
|
44
|
+
const schemaCommand = isReportResultCommandFrame(expected);
|
|
41
45
|
this.#sentCursor += 1;
|
|
42
46
|
this.#rememberId(expected, frame);
|
|
47
|
+
if (schemaCommand) {
|
|
48
|
+
this.#releaseOneResponse();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
43
51
|
this.#releaseFor(frame.type);
|
|
44
52
|
this.#consumeControls();
|
|
45
53
|
return;
|
|
@@ -64,11 +72,20 @@ export class CassetteReplay {
|
|
|
64
72
|
this.#receivedCursor = 0;
|
|
65
73
|
this.#sent = ask.sentFrames;
|
|
66
74
|
this.#received = ask.receivedFrames;
|
|
75
|
+
// A schema-bearing Ask opens with the report_result schema command, so the
|
|
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;
|
|
67
79
|
const awaitsAbortSettlement = abortSettlementFollowsFinalText(ask);
|
|
80
|
+
const settles = ask.receivedFrames.some((frame) => frame.type === "agent_settled");
|
|
81
|
+
const reported = recordedReportedResult(ask);
|
|
68
82
|
return {
|
|
83
|
+
...(settles ? {} : { settles: false as const }),
|
|
84
|
+
...(reported === undefined ? {} : { reported }),
|
|
69
85
|
...(ask.limit === undefined ? {} : { limit: ask.limit }),
|
|
70
86
|
...(ask.stalled === undefined ? {} : { stalled: ask.stalled }),
|
|
71
87
|
...(ask.outcome === undefined ? {} : { outcome: ask.outcome }),
|
|
88
|
+
...(ask.recovered === true ? { recovered: true as const } : {}),
|
|
72
89
|
...(awaitsAbortSettlement ? { awaitsAbortSettlement: true as const } : {}),
|
|
73
90
|
};
|
|
74
91
|
}
|
|
@@ -83,14 +100,20 @@ export class CassetteReplay {
|
|
|
83
100
|
const expected = this.#sent[this.#sentCursor];
|
|
84
101
|
if (expected === undefined || !this.#isRecordedControl(expected)) return;
|
|
85
102
|
this.#sentCursor += 1;
|
|
103
|
+
this.#retireId(expected);
|
|
86
104
|
this.#releaseFor(expected.type);
|
|
87
105
|
}
|
|
88
106
|
}
|
|
89
107
|
|
|
90
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Any non-first prompt within an Ask is a recorded correction (ADR-0027
|
|
110
|
+
* amendment). A recorded `get_state` is a Stall Watchdog probe: replay is
|
|
111
|
+
* never silent, so it never probes, and the recorded exchange is consumed
|
|
112
|
+
* here instead of blocking the frames that follow it (ADR-0029).
|
|
113
|
+
*/
|
|
91
114
|
#isRecordedControl(frame: Frame): boolean {
|
|
92
|
-
if (frame.type === "steer" || frame.type === "abort") return true;
|
|
93
|
-
return frame.type === "prompt" && this.#sentCursor >
|
|
115
|
+
if (frame.type === "steer" || frame.type === "abort" || frame.type === "get_state") return true;
|
|
116
|
+
return frame.type === "prompt" && this.#sentCursor > this.#promptFloor;
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
#skipCorrectionHistory(): void {
|
|
@@ -112,6 +135,25 @@ export class CassetteReplay {
|
|
|
112
135
|
);
|
|
113
136
|
}
|
|
114
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Releases exactly the schema command's own response.
|
|
140
|
+
*
|
|
141
|
+
* The schema command and the Ask's own prompt are both `prompt` commands, so
|
|
142
|
+
* the ordinary release cannot tell their responses apart and would hand out
|
|
143
|
+
* the Ask's response before the Ask has sent it (ADR-0032).
|
|
144
|
+
*/
|
|
145
|
+
#releaseOneResponse(): void {
|
|
146
|
+
for (; this.#receivedCursor < this.#received.length; this.#receivedCursor += 1) {
|
|
147
|
+
const frame = this.#received[this.#receivedCursor];
|
|
148
|
+
if (frame === undefined) return;
|
|
149
|
+
this.#queue.push(this.#remapResponse(frame));
|
|
150
|
+
if (frame.type === "response") {
|
|
151
|
+
this.#receivedCursor += 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
115
157
|
#releaseFor(command: string, skippedText = false): void {
|
|
116
158
|
for (; this.#receivedCursor < this.#received.length; this.#receivedCursor += 1) {
|
|
117
159
|
const frame = this.#received[this.#receivedCursor];
|
|
@@ -133,6 +175,16 @@ export class CassetteReplay {
|
|
|
133
175
|
return this.#sent.slice(this.#sentCursor).some((frame) => frame.type === command);
|
|
134
176
|
}
|
|
135
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Renames a consumed probe's id, so its recorded response cannot answer an
|
|
180
|
+
* unrelated live command that reuses that id. Replay sends no probe and
|
|
181
|
+
* numbers its own commands from zero, so those ids do collide.
|
|
182
|
+
*/
|
|
183
|
+
#retireId(expected: Frame): void {
|
|
184
|
+
if (expected.type !== "get_state" || typeof expected.id !== "string") return;
|
|
185
|
+
this.#ids.set(expected.id, `cassette-${expected.id}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
136
188
|
#rememberId(expected: Frame, actual: Frame): void {
|
|
137
189
|
if (typeof expected.id === "string" && typeof actual.id === "string") {
|
|
138
190
|
this.#ids.set(expected.id, actual.id);
|
|
@@ -151,13 +203,30 @@ export class CassetteReplay {
|
|
|
151
203
|
}
|
|
152
204
|
}
|
|
153
205
|
|
|
206
|
+
/** The result the recorded Ask reported, folded from its own frames (ADR-0032). */
|
|
207
|
+
function recordedReportedResult(ask: CassetteAgent["asks"][number]): ReportedResult | undefined {
|
|
208
|
+
const call = ReportResultCall.armed();
|
|
209
|
+
for (const frame of ask.receivedFrames) call.observe(frame);
|
|
210
|
+
return call.reported;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* True when the recorded Ask ends with an abort that produced its own
|
|
215
|
+
* settlement, after the frame that asked the Agent for its result.
|
|
216
|
+
*
|
|
217
|
+
* That frame is the final text query of an ADR-0027 Ask, or the last prompt of
|
|
218
|
+
* a report_result Ask that recorded an invalid-output outcome. A limit abort
|
|
219
|
+
* also follows a prompt, and its settlement belongs to the Ask's own wait.
|
|
220
|
+
*/
|
|
154
221
|
function abortSettlementFollowsFinalText(agent: CassetteAgent["asks"][number]): boolean {
|
|
155
|
-
const
|
|
156
|
-
|
|
222
|
+
const structuredAbort = agent.outcome !== undefined;
|
|
223
|
+
const finalResultQuery = agent.sentFrames.findLastIndex(
|
|
224
|
+
(frame) =>
|
|
225
|
+
frame.type === "get_last_assistant_text" || (structuredAbort && frame.type === "prompt"),
|
|
157
226
|
);
|
|
158
227
|
if (
|
|
159
|
-
|
|
160
|
-
!agent.sentFrames.slice(
|
|
228
|
+
finalResultQuery === -1 ||
|
|
229
|
+
!agent.sentFrames.slice(finalResultQuery + 1).some((frame) => frame.type === "abort")
|
|
161
230
|
) {
|
|
162
231
|
return false;
|
|
163
232
|
}
|
package/src/cassette-schema.ts
CHANGED
|
@@ -56,6 +56,7 @@ const ContextAskSchema = Type.Object({
|
|
|
56
56
|
maxToolCalls: Type.Optional(Type.Number()),
|
|
57
57
|
maxDurationMs: Type.Optional(Type.Number()),
|
|
58
58
|
idleMs: Type.Optional(Type.Number()),
|
|
59
|
+
stallMs: Type.Optional(Type.Union([Type.Number(), Type.Literal(false)])),
|
|
59
60
|
wrapUpPrompt: Type.Optional(Type.String()),
|
|
60
61
|
...contractFields,
|
|
61
62
|
});
|
|
@@ -86,7 +87,18 @@ const CassetteAskSchema = Type.Object({
|
|
|
86
87
|
}),
|
|
87
88
|
),
|
|
88
89
|
stalled: Type.Optional(
|
|
89
|
-
Type.Object({
|
|
90
|
+
Type.Object({
|
|
91
|
+
idleMs: Type.Number({ exclusiveMinimum: 0 }),
|
|
92
|
+
destructive: Type.Boolean(),
|
|
93
|
+
state: Type.Optional(
|
|
94
|
+
Type.Object({
|
|
95
|
+
working: Type.Boolean(),
|
|
96
|
+
streaming: Type.Boolean(),
|
|
97
|
+
compacting: Type.Boolean(),
|
|
98
|
+
pendingMessages: Type.Number(),
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
}),
|
|
90
102
|
),
|
|
91
103
|
outcome: Type.Optional(
|
|
92
104
|
Type.Object({
|
|
@@ -94,6 +106,7 @@ const CassetteAskSchema = Type.Object({
|
|
|
94
106
|
steeringEfforts: Type.Integer({ minimum: 0 }),
|
|
95
107
|
}),
|
|
96
108
|
),
|
|
109
|
+
recovered: Type.Optional(Type.Literal(true)),
|
|
97
110
|
});
|
|
98
111
|
|
|
99
112
|
const TokenBreakdownSchema = Type.Object({
|
|
@@ -121,7 +134,9 @@ const CassetteAgentSchema = Type.Object({
|
|
|
121
134
|
|
|
122
135
|
const CassetteRunSchema = Type.Object({
|
|
123
136
|
outcome: Type.Union(
|
|
124
|
-
(["completed", "failed", "stopped", "paused"] as const).map((outcome) =>
|
|
137
|
+
(["completed", "failed", "stopped", "paused", "interrupted"] as const).map((outcome) =>
|
|
138
|
+
Type.Literal(outcome),
|
|
139
|
+
),
|
|
125
140
|
),
|
|
126
141
|
programFile: Type.Optional(Type.String()),
|
|
127
142
|
args: Type.Optional(Type.Unknown()),
|
package/src/cassette.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { AskLimitOutcome, AskStalledOutcome } from "./errors.ts";
|
|
|
3
3
|
import type { RunOutcome } from "./events.ts";
|
|
4
4
|
import type {
|
|
5
5
|
AgentStats,
|
|
6
|
+
AskCompletion,
|
|
6
7
|
AskInvalidOutputPlayback,
|
|
7
8
|
AskMarker,
|
|
8
9
|
AskMarkerContext,
|
|
@@ -104,6 +105,8 @@ export interface CassetteAsk {
|
|
|
104
105
|
readonly stalled?: AskStalledOutcome;
|
|
105
106
|
/** Present only when the Ask surfaced ASK_INVALID_OUTPUT. */
|
|
106
107
|
readonly outcome?: AskInvalidOutputPlayback;
|
|
108
|
+
/** Present only when the Stall Watchdog recovered the settlement (ADR-0029). */
|
|
109
|
+
readonly recovered?: true;
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
/** Sink consumed by recordingTransport without exposing its mutable state. */
|
|
@@ -126,11 +129,7 @@ export interface CassetteRecorder {
|
|
|
126
129
|
sent(frame: Frame): void;
|
|
127
130
|
received(frame: Frame): void;
|
|
128
131
|
beginAsk(marker: AskMarker): void;
|
|
129
|
-
finishAsk(
|
|
130
|
-
outcome?: AskLimitOutcome,
|
|
131
|
-
stalled?: AskStalledOutcome,
|
|
132
|
-
invalidOutput?: AskInvalidOutputPlayback,
|
|
133
|
-
): void;
|
|
132
|
+
finishAsk(completion: AskCompletion): void;
|
|
134
133
|
closed(stats: AgentStats): void;
|
|
135
134
|
}
|
|
136
135
|
|
|
@@ -147,6 +146,7 @@ interface MutableAsk {
|
|
|
147
146
|
limit?: AskLimitOutcome;
|
|
148
147
|
stalled?: AskStalledOutcome;
|
|
149
148
|
outcome?: AskInvalidOutputPlayback;
|
|
149
|
+
recovered?: true;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
interface MutableAgent {
|
|
@@ -198,11 +198,12 @@ export class CassetteCollector implements CassetteSink {
|
|
|
198
198
|
agent.asks.push(ask);
|
|
199
199
|
agent.active = ask;
|
|
200
200
|
},
|
|
201
|
-
finishAsk: (
|
|
201
|
+
finishAsk: (completion): void => {
|
|
202
202
|
if (!agent?.active) return;
|
|
203
|
-
if (
|
|
204
|
-
if (stalled !== undefined) agent.active.stalled = stalled;
|
|
205
|
-
if (invalidOutput !== undefined) agent.active.outcome = invalidOutput;
|
|
203
|
+
if (completion.limit !== undefined) agent.active.limit = completion.limit;
|
|
204
|
+
if (completion.stalled !== undefined) agent.active.stalled = completion.stalled;
|
|
205
|
+
if (completion.invalidOutput !== undefined) agent.active.outcome = completion.invalidOutput;
|
|
206
|
+
if (completion.recovered === true) agent.active.recovered = true;
|
|
206
207
|
},
|
|
207
208
|
closed: (stats): void => {
|
|
208
209
|
if (agent) agent.stats = stats;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { CassetteRecorder, CassetteSink } from "./cassette.ts";
|
|
2
|
+
import { publishCassette } from "./cassette-publish.ts";
|
|
3
|
+
import {
|
|
4
|
+
ensureCheckpointDirectory,
|
|
5
|
+
type RunIdentity,
|
|
6
|
+
writeRecordingDiagnostic,
|
|
7
|
+
} from "./run-checkpoint.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Publishes the Run's in-progress Checkpoint (ADR-0031).
|
|
11
|
+
*
|
|
12
|
+
* Each flush rewrites the full collected Cassette to the Run's destination with
|
|
13
|
+
* the same atomic write a settled Checkpoint uses, and states the outcome
|
|
14
|
+
* `interrupted`. Flushes never reject: a failed one writes a diagnostic, and the
|
|
15
|
+
* next Ask boundary tries again.
|
|
16
|
+
*/
|
|
17
|
+
export interface CheckpointFlusher {
|
|
18
|
+
/** Requests a publication; returns at once. Concurrent requests coalesce. */
|
|
19
|
+
flush(): void;
|
|
20
|
+
/** Resolves when no publication is in flight, and stops accepting new ones. */
|
|
21
|
+
quiesce(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CheckpointFlushOptions {
|
|
25
|
+
/** Read at each flush; its current content becomes the artifact. */
|
|
26
|
+
readonly collector: CassetteSink;
|
|
27
|
+
/** Explicit `--record` destination, or undefined when the Run only checkpoints. */
|
|
28
|
+
readonly record: string | undefined;
|
|
29
|
+
/** Where the in-progress artifact lives; fixed at Run start. */
|
|
30
|
+
readonly destination: string;
|
|
31
|
+
readonly identity: RunIdentity;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createCheckpointFlusher(options: CheckpointFlushOptions): CheckpointFlusher {
|
|
35
|
+
let active: Promise<void> | null = null;
|
|
36
|
+
let requested = false;
|
|
37
|
+
let stopped = false;
|
|
38
|
+
|
|
39
|
+
const publish = async (): Promise<void> => {
|
|
40
|
+
try {
|
|
41
|
+
await ensureCheckpointDirectory(options.record, options.destination);
|
|
42
|
+
await publishCassette(
|
|
43
|
+
options.destination,
|
|
44
|
+
options.collector.cassette({ outcome: "interrupted", ...options.identity }),
|
|
45
|
+
);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// Losing this boundary costs one Ask of restorability; failing the Run
|
|
48
|
+
// would cost the whole Run (ADR-0031).
|
|
49
|
+
writeRecordingDiagnostic(error);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Only one publication runs at a time, so two Asks settling together cannot
|
|
54
|
+
// interleave two writes onto one destination. A request that arrives during a
|
|
55
|
+
// write is folded into exactly one follow-up write.
|
|
56
|
+
const pump = async (): Promise<void> => {
|
|
57
|
+
while (requested && !stopped) {
|
|
58
|
+
requested = false;
|
|
59
|
+
await publish();
|
|
60
|
+
}
|
|
61
|
+
active = null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
flush: (): void => {
|
|
66
|
+
if (stopped) return;
|
|
67
|
+
requested = true;
|
|
68
|
+
active ??= pump();
|
|
69
|
+
},
|
|
70
|
+
quiesce: async (): Promise<void> => {
|
|
71
|
+
stopped = true;
|
|
72
|
+
while (active !== null) await active;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Wraps a sink so every Ask boundary publishes the Checkpoint: an Agent opened,
|
|
79
|
+
* an Ask begun, an Ask finished, an Agent closed (ADR-0031).
|
|
80
|
+
*/
|
|
81
|
+
export function flushingSink(sink: CassetteSink, flusher: CheckpointFlusher): CassetteSink {
|
|
82
|
+
return {
|
|
83
|
+
cassette: (run) => sink.cassette(run),
|
|
84
|
+
reserve: (options): CassetteRecorder => {
|
|
85
|
+
const recorder = sink.reserve(options);
|
|
86
|
+
const boundary =
|
|
87
|
+
<Args extends unknown[]>(record: (...args: Args) => void) =>
|
|
88
|
+
(...args: Args): void => {
|
|
89
|
+
record(...args);
|
|
90
|
+
flusher.flush();
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
...recorder,
|
|
94
|
+
opened: boundary(recorder.opened),
|
|
95
|
+
beginAsk: boundary(recorder.beginAsk),
|
|
96
|
+
finishAsk: boundary(recorder.finishAsk),
|
|
97
|
+
closed: boundary(recorder.closed),
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/connection.ts
CHANGED
|
@@ -24,6 +24,7 @@ export class Connection {
|
|
|
24
24
|
readonly #pending = new Map<string, (response: CommandResponse) => void>();
|
|
25
25
|
readonly #closed: Promise<void>;
|
|
26
26
|
#observer: ((frame: Frame) => void) | null = null;
|
|
27
|
+
#draining = false;
|
|
27
28
|
#dead = false;
|
|
28
29
|
#nextId = 0;
|
|
29
30
|
|
|
@@ -48,6 +49,21 @@ export class Connection {
|
|
|
48
49
|
this.#observer = listener;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Drops every frame up to and including the abandoned turn's `agent_settled`.
|
|
54
|
+
*
|
|
55
|
+
* A stall leaves the Agent still working on the turn yaag gave up on. pi
|
|
56
|
+
* queues the next prompt behind that turn, so its late frames — above all
|
|
57
|
+
* its `agent_settled` — arrive after the next Ask started, and would settle
|
|
58
|
+
* that Ask with the abandoned turn's text. Draining to the settlement frame
|
|
59
|
+
* is the only boundary pi gives. A turn that never settles keeps the drain
|
|
60
|
+
* open, and the next Ask's own Stall Watchdog ends that wait with
|
|
61
|
+
* `ASK_STALLED` instead of a wrong answer.
|
|
62
|
+
*/
|
|
63
|
+
drainStaleTurn(): void {
|
|
64
|
+
this.#draining = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
/**
|
|
52
68
|
* Sends one command and resolves with its id-correlated response.
|
|
53
69
|
* Rejects with AGENT_DIED if the Agent dies first.
|
|
@@ -94,6 +110,10 @@ export class Connection {
|
|
|
94
110
|
this.#answerDialog(frame);
|
|
95
111
|
return;
|
|
96
112
|
}
|
|
113
|
+
if (this.#draining) {
|
|
114
|
+
if (frame.type === "agent_settled") this.#draining = false;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
97
117
|
this.#observer?.(frame);
|
|
98
118
|
}
|
|
99
119
|
|
package/src/define-agent.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ModelSpec, ThinkingSpec } from "./model-resolution.ts";
|
|
2
|
+
import type { AskOptions } from "./types.ts";
|
|
2
3
|
|
|
3
4
|
export type { ThinkingLevel } from "./types.ts";
|
|
4
5
|
|
|
@@ -10,10 +11,14 @@ export interface AgentConfig {
|
|
|
10
11
|
readonly prompt?: string;
|
|
11
12
|
/** Replace pi's system prompt with `prompt` instead of appending to it. */
|
|
12
13
|
readonly overrideSystemPrompt?: boolean;
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Model id handed to `pi --model`. Unset = pi's default. An array is an ordered
|
|
16
|
+
* fallback list, a function picks the next candidate from the failures so far,
|
|
17
|
+
* and any pattern may carry an inline thinking suffix (`"opus-5:medium"`).
|
|
18
|
+
*/
|
|
19
|
+
readonly model?: ModelSpec;
|
|
20
|
+
/** Thinking budget for the Agent's turns, as a level or a resolver. */
|
|
21
|
+
readonly thinking?: ThinkingSpec;
|
|
17
22
|
/** Allow-list of tool names. Unset = pi's default tool set. */
|
|
18
23
|
readonly tools?: readonly string[];
|
|
19
24
|
/** Deny-list of tool names, applied after `tools`. */
|
|
@@ -58,6 +63,7 @@ export function defineAgent(config: AgentConfig): AgentDefinition {
|
|
|
58
63
|
const value = config[field];
|
|
59
64
|
if (value !== undefined) copy[field] = Object.freeze(value.slice());
|
|
60
65
|
}
|
|
66
|
+
if (Array.isArray(config.model)) copy.model = Object.freeze([...config.model]);
|
|
61
67
|
if (config.askDefaults !== undefined) {
|
|
62
68
|
copy.askDefaults = Object.freeze({ ...config.askDefaults });
|
|
63
69
|
}
|