@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.
Files changed (44) hide show
  1. package/package.json +3 -3
  2. package/src/agent.ts +11 -3
  3. package/src/ask-contract-identity.ts +23 -5
  4. package/src/ask-exchange-events.ts +14 -1
  5. package/src/ask-exchange-options.ts +7 -2
  6. package/src/ask-exchange.ts +198 -26
  7. package/src/ask-hash.ts +7 -3
  8. package/src/ask-limit.ts +27 -11
  9. package/src/ask-output-steering.ts +1 -3
  10. package/src/ask-output.ts +20 -4
  11. package/src/ask-turn.ts +11 -0
  12. package/src/cassette-loader.ts +24 -0
  13. package/src/cassette-replay.ts +76 -7
  14. package/src/cassette-schema.ts +17 -2
  15. package/src/cassette.ts +10 -9
  16. package/src/checkpoint-flush.ts +101 -0
  17. package/src/connection.ts +20 -0
  18. package/src/define-agent.ts +11 -5
  19. package/src/errors.ts +11 -2
  20. package/src/events.ts +29 -3
  21. package/src/fake-transport.ts +55 -0
  22. package/src/frame-queue.ts +5 -0
  23. package/src/index.ts +11 -1
  24. package/src/model-resolution.ts +119 -0
  25. package/src/model-suffix.ts +24 -0
  26. package/src/pi-state.ts +63 -8
  27. package/src/recording-transport.ts +8 -8
  28. package/src/replay-divergence.ts +1 -0
  29. package/src/replay-transport.ts +4 -0
  30. package/src/report-result-extension.ts +128 -0
  31. package/src/report-result-output.ts +73 -0
  32. package/src/report-result-steering.ts +83 -0
  33. package/src/report-result.ts +122 -0
  34. package/src/resume-transport.ts +26 -8
  35. package/src/run-checkpoint.ts +93 -41
  36. package/src/run.ts +86 -32
  37. package/src/spawn.ts +29 -4
  38. package/src/stall-watchdog.ts +193 -0
  39. package/src/summary-agent.ts +11 -2
  40. package/src/summary.ts +23 -2
  41. package/src/thinking-level.ts +32 -0
  42. package/src/transport.ts +43 -8
  43. package/src/types.ts +50 -14
  44. 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
- frame.type === "tool_execution_start" &&
82
- this.#trip === null &&
83
- this.#ask.maxToolCalls !== undefined
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.#ask.wrapUpPrompt ?? DEFAULT_WRAP_UP_PROMPT });
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. It becomes Cassette identity in issue 04. */
9
- export const ASK_OUTPUT_EXTRACTION_POLICY = "json-first-block/v1";
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
- /** The currently implemented extraction policy identifier. */
12
- export type AskOutputExtractionPolicy = typeof ASK_OUTPUT_EXTRACTION_POLICY;
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
  /**
@@ -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
  }
@@ -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
- /** Any non-first prompt within an Ask is a recorded correction (ADR-0027 amendment). */
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 > 0;
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 finalText = agent.sentFrames.findLastIndex(
156
- (frame) => frame.type === "get_last_assistant_text",
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
- finalText === -1 ||
160
- !agent.sentFrames.slice(finalText + 1).some((frame) => frame.type === "abort")
228
+ finalResultQuery === -1 ||
229
+ !agent.sentFrames.slice(finalResultQuery + 1).some((frame) => frame.type === "abort")
161
230
  ) {
162
231
  return false;
163
232
  }
@@ -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({ idleMs: Type.Number({ exclusiveMinimum: 0 }), destructive: Type.Boolean() }),
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) => Type.Literal(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: (outcome, stalled, invalidOutput): void => {
201
+ finishAsk: (completion): void => {
202
202
  if (!agent?.active) return;
203
- if (outcome !== undefined) agent.active.limit = outcome;
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
 
@@ -1,4 +1,5 @@
1
- import type { AskOptions, ThinkingLevel } from "./types.ts";
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
- /** Model id handed to `pi --model`. Unset = pi's default. */
14
- readonly model?: string;
15
- /** Thinking budget for the Agent's turns. */
16
- readonly thinking?: ThinkingLevel;
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
  }