@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/runtime",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -16,8 +16,8 @@
16
16
  },
17
17
  "scripts": {
18
18
  "typecheck": "tsc --noEmit",
19
- "test": "bun test src",
20
- "test:e2e": "bun test e2e"
19
+ "test": "bun test --timeout 30000 src",
20
+ "test:e2e": "bun test --timeout 30000 e2e"
21
21
  },
22
22
  "dependencies": {
23
23
  "typebox": "1.3.7"
package/src/agent.ts CHANGED
@@ -5,8 +5,9 @@ import type { EffectiveAskOptions } from "./ask-hash.ts";
5
5
  import { Connection } from "./connection.ts";
6
6
  import { agentError } from "./errors.ts";
7
7
  import type { EventSink } from "./events.ts";
8
+ import { ReportResultTool } from "./report-result.ts";
8
9
  import type { AgentStats, AgentTransport } from "./transport.ts";
9
- import type { AskOptions, Handle, SpawnOptions, StructuredAskOptions } from "./types.ts";
10
+ import type { AskOptions, Handle, ResolvedSpawnOptions, StructuredAskOptions } from "./types.ts";
10
11
 
11
12
  export interface AgentOptions {
12
13
  readonly name: string;
@@ -15,7 +16,7 @@ export interface AgentOptions {
15
16
  readonly transport: AgentTransport;
16
17
  readonly emit: EventSink;
17
18
  /** The options this Agent was spawned with, for the ADR-0014 Ask hash. */
18
- readonly spawnOptions: SpawnOptions;
19
+ readonly spawnOptions: ResolvedSpawnOptions;
19
20
  /** Definition-owned defaults merged below explicit per-Ask options. */
20
21
  readonly askDefaults?: AskOptions;
21
22
  /** Definition identity recorded on its Asks, outside replay identity. */
@@ -24,6 +25,8 @@ export interface AgentOptions {
24
25
  readonly askLimitGraceMs?: number;
25
26
  /** Test-only override for the bounded wait for `agent_settled` after an idle abort. */
26
27
  readonly idleAbortSettleMs?: number;
28
+ /** Test-only override for the bounded wait for the Stall Watchdog probe. */
29
+ readonly stallProbeSettleMs?: number;
27
30
  }
28
31
 
29
32
  /** One Agent as an Orchestration Program sees it. Lives above the transport seam. */
@@ -36,12 +39,14 @@ export class Agent implements Handle {
36
39
  readonly #transport: AgentTransport;
37
40
  readonly #connection: Connection;
38
41
  readonly #emit: EventSink;
39
- readonly #spawnOptions: SpawnOptions;
42
+ readonly #spawnOptions: ResolvedSpawnOptions;
40
43
  readonly #askDefaults: AskOptions;
41
44
  readonly #definitionName: string | undefined;
42
45
  readonly #askLimitGraceMs: number | undefined;
43
46
  readonly #idleAbortSettleMs: number | undefined;
47
+ readonly #stallProbeSettleMs: number | undefined;
44
48
  readonly #usage: AgentUsage;
49
+ readonly #reportResultTool = new ReportResultTool();
45
50
  #busy = false;
46
51
  #incomplete = false;
47
52
  #askIndex = 0;
@@ -59,6 +64,7 @@ export class Agent implements Handle {
59
64
  this.#definitionName = options.definitionName;
60
65
  this.#askLimitGraceMs = options.askLimitGraceMs;
61
66
  this.#idleAbortSettleMs = options.idleAbortSettleMs;
67
+ this.#stallProbeSettleMs = options.stallProbeSettleMs;
62
68
  this.#usage = new AgentUsage((snapshot) =>
63
69
  this.#emit({ type: "agent_usage", agent: this.name, ...snapshot }),
64
70
  );
@@ -92,11 +98,13 @@ export class Agent implements Handle {
92
98
  ask: { ...this.#askDefaults, ...options },
93
99
  spawnOptions: this.#spawnOptions,
94
100
  definitionName: this.#definitionName,
101
+ reportResultTool: this.#reportResultTool,
95
102
  emit: this.#emit,
96
103
  usage: this.#usage,
97
104
  close: () => void this.close().catch(() => {}),
98
105
  askLimitGraceMs: this.#askLimitGraceMs,
99
106
  idleAbortSettleMs: this.#idleAbortSettleMs,
107
+ stallProbeSettleMs: this.#stallProbeSettleMs,
100
108
  });
101
109
  } finally {
102
110
  this.#busy = false;
@@ -1,5 +1,5 @@
1
1
  import type { TSchema } from "typebox";
2
- import { ASK_OUTPUT_EXTRACTION_POLICY } from "./ask-output.ts";
2
+ import { ASK_OUTPUT_EXTRACTION_POLICY, type AskOutputExtractionPolicy } from "./ask-output.ts";
3
3
 
4
4
  /** A JSON value suitable for deterministic Cassette identity. */
5
5
  export type CanonicalJson =
@@ -32,11 +32,29 @@ export function canonicalizeSchema(value: unknown): CanonicalJsonObject {
32
32
  return canonicalize(value, new Set<object>()) as CanonicalJsonObject;
33
33
  }
34
34
 
35
- /** Builds the recorded and hashed behavioral contract for one structured Ask. */
36
- export function createAskOutputContract(schema: TSchema, maxSteers?: number): AskOutputContract {
35
+ /**
36
+ * Builds the recorded and hashed behavioral contract for one structured Ask.
37
+ *
38
+ * `policy` is the live policy unless a Cassette recorded this Ask under an
39
+ * older one, in which case that Ask keeps its recorded identity and behavior.
40
+ * Throws a TypeError when a live Ask declares a schema that is not an object
41
+ * schema: the Agent reports the result as one tool call's arguments (ADR-0032),
42
+ * and an Ask recorded before that decision keeps whatever schema it recorded.
43
+ */
44
+ export function createAskOutputContract(
45
+ schema: TSchema,
46
+ maxSteers?: number,
47
+ policy: AskOutputExtractionPolicy = ASK_OUTPUT_EXTRACTION_POLICY,
48
+ ): AskOutputContract {
49
+ const outputSchema = canonicalizeSchema(schema);
50
+ if (policy === ASK_OUTPUT_EXTRACTION_POLICY && outputSchema.type !== "object") {
51
+ throw new TypeError(
52
+ "outputSchema must be an object schema: the Agent reports it as one tool call's arguments",
53
+ );
54
+ }
37
55
  return {
38
- outputSchema: canonicalizeSchema(schema),
39
- extractionPolicy: ASK_OUTPUT_EXTRACTION_POLICY,
56
+ outputSchema,
57
+ extractionPolicy: policy,
40
58
  ...(maxSteers === undefined ? {} : { maxSteers }),
41
59
  };
42
60
  }
@@ -2,6 +2,16 @@ import type { AgentActivity, AskOutputChannel, EventSink } from "./events.ts";
2
2
  import { agentAskPath, childPath } from "./node-path.ts";
3
3
  import type { NodeSnapshot } from "./node-tracker.ts";
4
4
  import { promptGist } from "./prompt-gist.ts";
5
+ import type { SettlementCause } from "./stall-watchdog.ts";
6
+
7
+ /** What one finished Ask reports in its `ask_end` Lifecycle Event. */
8
+ export interface AskEndOutcome {
9
+ readonly durationMs: number;
10
+ readonly ok: boolean;
11
+ /** Largest inter-frame silence; absent during Cassette playback. */
12
+ readonly maxFrameGapMs: number | undefined;
13
+ readonly cause?: SettlementCause;
14
+ }
5
15
 
6
16
  /** Emits the four Ask-scoped Lifecycle Events for one exchange. */
7
17
  export class AskEvents {
@@ -47,7 +57,9 @@ export class AskEvents {
47
57
  });
48
58
  };
49
59
 
50
- end(durationMs: number, ok: boolean, maxFrameGapMs: number | undefined): void {
60
+ /** A `normal` cause is the absent default, so ordinary settlements stay lean. */
61
+ end(outcome: AskEndOutcome): void {
62
+ const { durationMs, ok, maxFrameGapMs, cause = "normal" } = outcome;
51
63
  this.#emit({
52
64
  type: "ask_end",
53
65
  agent: this.#agent,
@@ -55,6 +67,7 @@ export class AskEvents {
55
67
  durationMs,
56
68
  ok,
57
69
  ...(maxFrameGapMs === undefined ? {} : { maxFrameGapMs }),
70
+ ...(cause === "normal" ? {} : { cause }),
58
71
  });
59
72
  }
60
73
  }
@@ -1,8 +1,9 @@
1
1
  import type { EffectiveAskOptions } from "./ask-hash.ts";
2
2
  import type { Connection } from "./connection.ts";
3
3
  import type { EventSink } from "./events.ts";
4
+ import type { ReportResultTool } from "./report-result.ts";
4
5
  import type { AgentTransport, Frame } from "./transport.ts";
5
- import type { SpawnOptions } from "./types.ts";
6
+ import type { ResolvedSpawnOptions } from "./types.ts";
6
7
 
7
8
  /** A frame consumer whose lifetime exceeds one Ask; the Agent's AgentUsage satisfies it. */
8
9
  export interface FrameObserver {
@@ -17,8 +18,10 @@ export interface AskExchangeOptions {
17
18
  readonly prompt: string;
18
19
  readonly index: number;
19
20
  readonly ask: EffectiveAskOptions;
20
- readonly spawnOptions: SpawnOptions;
21
+ readonly spawnOptions: ResolvedSpawnOptions;
21
22
  readonly definitionName: string | undefined;
23
+ /** The Agent's report_result activation state, which spans its Asks (ADR-0032). */
24
+ readonly reportResultTool: ReportResultTool;
22
25
  /** Lifecycle Event sink; the exchange emits every Ask-scoped event itself. */
23
26
  readonly emit: EventSink;
24
27
  /** The Agent's persistent usage accumulator; fed frames only during live Asks. */
@@ -29,4 +32,6 @@ export interface AskExchangeOptions {
29
32
  readonly askLimitGraceMs: number | undefined;
30
33
  /** Test-only override for the bounded settle wait after an idle abort. */
31
34
  readonly idleAbortSettleMs: number | undefined;
35
+ /** Test-only override for the bounded wait for the Stall Watchdog probe. */
36
+ readonly stallProbeSettleMs: number | undefined;
32
37
  }
@@ -1,9 +1,11 @@
1
+ import type { TSchema } from "typebox";
1
2
  import { AskActivityTracker } from "./ask-activity.ts";
2
- import { AskEvents } from "./ask-exchange-events.ts";
3
+ import type { AskOutputContract } from "./ask-contract-identity.ts";
4
+ import { type AskEndOutcome, AskEvents } from "./ask-exchange-events.ts";
3
5
  import type { AskExchangeOptions } from "./ask-exchange-options.ts";
4
6
  import { askHash, askMarkerContext, structuredOutputContract } from "./ask-hash.ts";
5
7
  import { AskLimit } from "./ask-limit.ts";
6
- import { resolvePlaybackAskOutput } from "./ask-output.ts";
8
+ import { LEGACY_ASK_OUTPUT_EXTRACTION_POLICY, resolvePlaybackAskOutput } from "./ask-output.ts";
7
9
  import { AskOutputSteering } from "./ask-output-steering.ts";
8
10
  import { AskOutputTail } from "./ask-output-tail.ts";
9
11
  import { awaitAskSettlement } from "./ask-settlement.ts";
@@ -19,6 +21,15 @@ import {
19
21
  import { FrameGapTracker } from "./frame-gap.ts";
20
22
  import { IdleKillSignal, IdleWatch } from "./idle-watch.ts";
21
23
  import { NodeTracker } from "./node-tracker.ts";
24
+ import { ReportResultCall } from "./report-result.ts";
25
+ import { REPORT_RESULT_WRAP_UP, resolvePlaybackReportedResult } from "./report-result-output.ts";
26
+ import { ReportResultSteering } from "./report-result-steering.ts";
27
+ import {
28
+ type SettlementCause,
29
+ StallSignal,
30
+ StallWatchdog,
31
+ stallBudgetMs,
32
+ } from "./stall-watchdog.ts";
22
33
  import type { AskPlayback } from "./transport.ts";
23
34
 
24
35
  export type { AskExchangeOptions } from "./ask-exchange-options.ts";
@@ -32,10 +43,14 @@ class AskExchange {
32
43
  readonly #events: AskEvents;
33
44
  #limit: AskLimit | null = null;
34
45
  #idle: IdleWatch | null = null;
46
+ #stall: StallWatchdog | null = null;
35
47
  #gap: FrameGapTracker | null = null;
36
48
  #activity: AskActivityTracker | null = null;
37
49
  #output: AskOutputTail | null = null;
38
50
  #nodes: NodeTracker | null = null;
51
+ #call: ReportResultCall | null = null;
52
+ #legacyExtraction = false;
53
+ #contract: AskOutputContract | undefined;
39
54
  #playback: AskPlayback | undefined;
40
55
  #began = false;
41
56
  #outcome: AskLimitOutcome | undefined;
@@ -45,6 +60,8 @@ class AskExchange {
45
60
  #controlFailure: Promise<never> = new Promise<never>(() => {});
46
61
  #timeoutDeadline: number | undefined;
47
62
  #maxFrameGapMs: number | undefined;
63
+ #cause: SettlementCause = "normal";
64
+ #drainStaleFrames = false;
48
65
  constructor(options: AskExchangeOptions) {
49
66
  this.#options = options;
50
67
  this.#events = new AskEvents(options.emit, options.agent, options.index);
@@ -54,19 +71,31 @@ class AskExchange {
54
71
  const startedAt = Date.now();
55
72
  try {
56
73
  const result = await this.#exchange();
57
- this.#events.end(Date.now() - startedAt, true, this.#maxFrameGapMs);
74
+ this.#events.end(this.#endOutcome(startedAt, true));
58
75
  return result;
59
76
  } catch (error) {
60
- if (this.#began) this.#events.end(Date.now() - startedAt, false, this.#maxFrameGapMs);
77
+ if (this.#began) this.#events.end(this.#endOutcome(startedAt, false));
61
78
  throw error;
62
79
  }
63
80
  }
64
81
 
82
+ #endOutcome(startedAt: number, ok: boolean): AskEndOutcome {
83
+ return {
84
+ durationMs: Date.now() - startedAt,
85
+ ok,
86
+ maxFrameGapMs: this.#maxFrameGapMs,
87
+ cause: this.#cause,
88
+ };
89
+ }
90
+
65
91
  async #exchange(): Promise<unknown> {
66
92
  try {
67
93
  this.#begin();
68
- const settled = this.#armSettlement();
69
94
  this.#timeoutDeadline = this.#deadline();
95
+ // The schema command is answered without a turn, so settlement tracking
96
+ // starts after it: nothing it could emit may settle this Ask (ADR-0032).
97
+ await this.#deliverSchema();
98
+ const settled = this.#armSettlement();
70
99
  await this.#sendPrompt();
71
100
  await this.#verify(settled);
72
101
  const abortSettled =
@@ -74,7 +103,7 @@ class AskExchange {
74
103
  let result: unknown;
75
104
  let outputError: unknown;
76
105
  try {
77
- result = await this.#resolveOutput(await this.#lastAssistantText());
106
+ result = await this.#resolveOutput();
78
107
  } catch (error) {
79
108
  if (abortSettled === undefined) throw error;
80
109
  outputError = error;
@@ -91,7 +120,12 @@ class AskExchange {
91
120
  }
92
121
 
93
122
  #begin(): void {
94
- const outputContract = structuredOutputContract(this.#options.ask);
123
+ this.#legacyExtraction = this.#recordedLegacyPolicy();
124
+ const outputContract = structuredOutputContract(
125
+ this.#options.ask,
126
+ this.#legacyExtraction ? LEGACY_ASK_OUTPUT_EXTRACTION_POLICY : undefined,
127
+ );
128
+ this.#contract = outputContract;
95
129
  const identity = {
96
130
  spawnOptions: this.#options.spawnOptions,
97
131
  index: this.#options.index,
@@ -118,11 +152,16 @@ class AskExchange {
118
152
  // Nested Node is decoded from recorded Agent frames only, so playback must
119
153
  // re-derive the identical `node_update` stream (ADR-0013, spec §3).
120
154
  this.#nodes = new NodeTracker({ report: this.#events.node });
155
+ // Playback reads the recorded call from the Cassette instead, so its value
156
+ // cannot depend on how far the replayed frame stream has drained.
157
+ this.#call = playback === undefined ? new ReportResultCall() : null;
121
158
  this.#limit = playback === undefined ? this.#createLimit() : null;
122
159
  this.#idle = playback === undefined ? this.#createIdleWatch() : null;
160
+ this.#stall = playback === undefined ? this.#createStallWatchdog() : null;
123
161
  this.#observe();
124
162
  this.#limit?.start();
125
163
  this.#idle?.start();
164
+ this.#stall?.start();
126
165
  this.#gap?.start();
127
166
  this.#controlFailure = this.#createControlFailure();
128
167
  }
@@ -132,9 +171,35 @@ class AskExchange {
132
171
  ask: this.#options.ask,
133
172
  durationGraceMs: this.#options.askLimitGraceMs,
134
173
  command: async (frame) => (await this.#options.connection.command(frame)).success,
174
+ ...(this.#reportsResult() ? { wrapUpSuffix: REPORT_RESULT_WRAP_UP } : {}),
135
175
  });
136
176
  }
137
177
 
178
+ /** The Ask's declared output schema, or undefined when it takes final text. */
179
+ #outputSchema(): TSchema | undefined {
180
+ return "outputSchema" in this.#options.ask ? this.#options.ask.outputSchema : undefined;
181
+ }
182
+
183
+ /** The Ask's explicit correction bound, absent unless it declared a schema. */
184
+ #maxSteers(): number | undefined {
185
+ return "maxSteers" in this.#options.ask ? this.#options.ask.maxSteers : undefined;
186
+ }
187
+
188
+ /** True when this Ask settles from a report_result call rather than from text. */
189
+ #reportsResult(): boolean {
190
+ return this.#outputSchema() !== undefined && !this.#legacyExtraction;
191
+ }
192
+
193
+ /**
194
+ * True when a Cassette recorded this Ask before ADR-0032. Such an Ask keeps
195
+ * its recorded identity and its recorded ADR-0027 behavior, so it replays
196
+ * frame for frame.
197
+ */
198
+ #recordedLegacyPolicy(): boolean {
199
+ const recorded = this.#options.transport.recordedExtractionPolicy?.(this.#options.index);
200
+ return recorded === LEGACY_ASK_OUTPUT_EXTRACTION_POLICY;
201
+ }
202
+
138
203
  #createIdleWatch(): IdleWatch | null {
139
204
  if (this.#options.ask.idleMs === undefined) return null;
140
205
  return new IdleWatch({
@@ -146,28 +211,67 @@ class AskExchange {
146
211
  });
147
212
  }
148
213
 
214
+ #createStallWatchdog(): StallWatchdog | null {
215
+ const stallMs = stallBudgetMs(this.#options.ask.stallMs);
216
+ if (stallMs === null) return null;
217
+ return new StallWatchdog({
218
+ stallMs,
219
+ ...(this.#options.stallProbeSettleMs === undefined
220
+ ? {}
221
+ : { probeSettleMs: this.#options.stallProbeSettleMs }),
222
+ command: async (frame) => await this.#options.connection.command(frame),
223
+ terminal: () => this.#turn.terminal,
224
+ recover: () => {
225
+ this.#cause = "recovered";
226
+ // The missing `agent_settled` may still arrive: it belongs to the turn
227
+ // yaag just settled by hand, never to the next Ask.
228
+ this.#drainStaleFrames = true;
229
+ this.#onSettled();
230
+ },
231
+ });
232
+ }
233
+
149
234
  #observe(): void {
150
235
  this.#options.connection.observe((frame) => {
151
236
  this.#turn.observe(frame);
152
237
  this.#limit?.observe(frame);
153
238
  if (frame.type !== "agent_settled" || this.#idle?.tripped) this.#idle?.observe(frame);
239
+ this.#stall?.observe(frame);
154
240
  this.#gap?.observe();
155
241
  this.#activity?.observe(frame);
156
242
  this.#output?.observe(frame);
157
243
  this.#nodes?.observe(frame);
244
+ this.#call?.observe(frame);
158
245
  if (this.#playback === undefined) this.#options.usage.observe(frame);
159
- if (this.#turn.settled) this.#onSettled();
246
+ // A recorded recovery has no `agent_settled`: its terminal assistant
247
+ // message is what the live watchdog settled on, so playback does the same.
248
+ if (this.#playback?.recovered === true && this.#turn.terminal) {
249
+ this.#cause = "recovered";
250
+ this.#onSettled();
251
+ }
252
+ if (this.#turn.settled) {
253
+ this.#stall?.settled();
254
+ this.#onSettled();
255
+ }
160
256
  });
161
257
  }
162
258
 
163
259
  #createControlFailure(): Promise<never> {
164
- const controls = [this.#limit?.failed, this.#idle?.failed, this.#idle?.killed].filter(
165
- (promise): promise is Promise<never> => promise !== undefined,
166
- );
260
+ const controls = [
261
+ this.#limit?.failed,
262
+ this.#idle?.failed,
263
+ this.#idle?.killed,
264
+ this.#stall?.failed,
265
+ ].filter((promise): promise is Promise<never> => promise !== undefined);
167
266
  return Promise.race(controls.length === 0 ? [new Promise<never>(() => {})] : controls).catch(
168
267
  (error: unknown) => {
169
268
  if (error instanceof IdleKillSignal)
170
269
  throw askStalledError(this.#options.agent, error.outcome);
270
+ if (error instanceof StallSignal) {
271
+ this.#cause = "stalled";
272
+ this.#drainStaleFrames = !error.outcome.destructive;
273
+ throw askStalledError(this.#options.agent, error.outcome);
274
+ }
171
275
  throw this.#failed(error instanceof Error ? error.message : "limit control command failed");
172
276
  },
173
277
  );
@@ -175,6 +279,7 @@ class AskExchange {
175
279
 
176
280
  #armSettlement(): Promise<void> {
177
281
  this.#turn.rearm();
282
+ this.#stall?.rearm();
178
283
  return new Promise<void>((resolve) => {
179
284
  this.#onSettled = resolve;
180
285
  });
@@ -186,7 +291,30 @@ class AskExchange {
186
291
  : Date.now() + this.#options.ask.timeoutMs;
187
292
  }
188
293
 
294
+ /**
295
+ * Carries this Ask's schema to the Agent's report_result tool, before the
296
+ * prompt (ADR-0032).
297
+ *
298
+ * pi routes the command to the extension and never to the model, and the
299
+ * frame belongs to this same logical Ask, so the Ask's own prompt bytes and
300
+ * its Ask index are untouched.
301
+ */
302
+ async #deliverSchema(): Promise<void> {
303
+ if (this.#legacyExtraction) return;
304
+ const command = this.#options.reportResultTool.begin(this.#contract?.outputSchema);
305
+ if (command === null) return;
306
+ const response = await this.#options.connection.command({
307
+ type: "prompt",
308
+ message: command.message,
309
+ });
310
+ if (!response.success) {
311
+ throw this.#failed(response.error ?? "report_result schema command was rejected");
312
+ }
313
+ command.accepted();
314
+ }
315
+
189
316
  async #sendPrompt(): Promise<void> {
317
+ this.#call?.arm();
190
318
  const response = await this.#options.connection.command({
191
319
  type: "prompt",
192
320
  message: this.#options.prompt,
@@ -199,6 +327,12 @@ class AskExchange {
199
327
  }
200
328
 
201
329
  async #verify(settled: Promise<void>, ignoreAbortFailure = false): Promise<void> {
330
+ // A recorded Ask that never settled has no settlement frame to wait for.
331
+ // Waiting for one would reproduce, in replay, the exact stall this
332
+ // watchdog exists to end.
333
+ if (this.#playback?.settles === false && this.#playback.recovered !== true) {
334
+ throw this.#recordedOutcome();
335
+ }
202
336
  await awaitAskSettlement({
203
337
  settled,
204
338
  closed: this.#options.connection.closed,
@@ -218,6 +352,15 @@ class AskExchange {
218
352
  if (failure !== null && !ignoreAbortFailure) throw this.#failed(failure);
219
353
  }
220
354
 
355
+ /** The rejection a recorded unsettled Ask replays as. */
356
+ #recordedOutcome(): Error {
357
+ this.#outcome = this.#playback?.limit;
358
+ if (this.#outcome !== undefined) return askLimitError(this.#options.agent, this.#outcome);
359
+ this.#stalled = this.#playback?.stalled;
360
+ if (this.#stalled !== undefined) return askStalledError(this.#options.agent, this.#stalled);
361
+ return this.#failed("recorded Ask never settled");
362
+ }
363
+
221
364
  async #lastAssistantText(): Promise<string> {
222
365
  const response = await this.#options.connection.command({ type: "get_last_assistant_text" });
223
366
  const text: unknown = response.data?.text;
@@ -227,36 +370,58 @@ class AskExchange {
227
370
  return text;
228
371
  }
229
372
 
230
- async #resolveOutput(text: string): Promise<unknown> {
231
- if (!("outputSchema" in this.#options.ask)) return text;
373
+ async #resolveOutput(): Promise<unknown> {
374
+ const schema = this.#outputSchema();
375
+ if (schema === undefined) return await this.#lastAssistantText();
376
+ if (this.#legacyExtraction) return await this.#resolveRecordedText(schema);
232
377
  if (this.#playback !== undefined) {
233
- return resolvePlaybackAskOutput(
234
- this.#options.agent,
235
- this.#options.ask.outputSchema,
236
- text,
237
- this.#playback.outcome,
238
- );
378
+ return resolvePlaybackReportedResult({
379
+ agent: this.#options.agent,
380
+ schema,
381
+ reported: this.#playback.reported,
382
+ recordedOutcome: this.#playback.outcome,
383
+ });
239
384
  }
240
- return new AskOutputSteering({
385
+ return new ReportResultSteering({
241
386
  agent: this.#options.agent,
242
- schema: this.#options.ask.outputSchema,
243
- maxSteers: this.#options.ask.maxSteers,
387
+ schema,
388
+ maxSteers: this.#maxSteers(),
389
+ reported: () => this.#call?.reported,
244
390
  beginCorrection: () => this.#limit?.enterOutputCorrection() ?? true,
245
391
  wrappingUp: () => this.#limit?.tripped ?? false,
246
392
  correct: async (message) => this.#correct(message),
247
393
  abort: async () => this.#abort(),
394
+ }).resolve();
395
+ }
396
+
397
+ /** The ADR-0027 path, reachable only for an Ask recorded under its policy. */
398
+ async #resolveRecordedText(schema: TSchema): Promise<unknown> {
399
+ const text = await this.#lastAssistantText();
400
+ if (this.#playback !== undefined) {
401
+ return resolvePlaybackAskOutput(this.#options.agent, schema, text, this.#playback.outcome);
402
+ }
403
+ return new AskOutputSteering({
404
+ agent: this.#options.agent,
405
+ schema,
406
+ maxSteers: this.#maxSteers(),
407
+ beginCorrection: () => this.#limit?.enterOutputCorrection() ?? true,
408
+ wrappingUp: () => this.#limit?.tripped ?? false,
409
+ correct: async (message) => {
410
+ await this.#correct(message);
411
+ return await this.#lastAssistantText();
412
+ },
413
+ abort: async () => this.#abort(),
248
414
  }).resolve(text);
249
415
  }
250
416
 
251
417
  // A follow-up prompt within the same logical Ask: real pi parks a post-settlement
252
418
  // steer behind queue_update and never starts a correction turn (ADR-0027 amendment).
253
- async #correct(message: string): Promise<string> {
419
+ async #correct(message: string): Promise<void> {
254
420
  try {
255
421
  const settled = this.#armSettlement();
256
422
  const response = await this.#options.connection.command({ type: "prompt", message });
257
423
  if (!response.success) throw this.#failed(response.error ?? "correction prompt was rejected");
258
424
  await this.#verify(settled);
259
- return await this.#lastAssistantText();
260
425
  } finally {
261
426
  this.#limit?.leaveOutputCorrection();
262
427
  }
@@ -276,14 +441,21 @@ class AskExchange {
276
441
 
277
442
  #cleanup(): void {
278
443
  this.#maxFrameGapMs = this.#gap?.maxGapMs;
279
- this.#stalled ??= this.#idle?.result ?? undefined;
444
+ this.#stalled ??= this.#idle?.result ?? this.#stall?.result ?? undefined;
280
445
  this.#limit?.cleanup();
281
446
  this.#idle?.cleanup();
447
+ this.#stall?.cleanup();
282
448
  this.#output?.close();
283
449
  this.#options.connection.observe(null);
450
+ if (this.#drainStaleFrames) this.#options.connection.drainStaleTurn();
284
451
  if (!this.#began) return;
285
452
  try {
286
- this.#options.transport.finishAsk(this.#outcome, this.#stalled, this.#invalidOutput);
453
+ this.#options.transport.finishAsk({
454
+ ...(this.#outcome === undefined ? {} : { limit: this.#outcome }),
455
+ ...(this.#stalled === undefined ? {} : { stalled: this.#stalled }),
456
+ ...(this.#invalidOutput === undefined ? {} : { invalidOutput: this.#invalidOutput }),
457
+ ...(this.#cause === "recovered" ? { recovered: true as const } : {}),
458
+ });
287
459
  } catch {
288
460
  // Recording must not hide the Ask outcome it was observing.
289
461
  }
package/src/ask-hash.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import type { TSchema } from "typebox";
2
2
  import { type AskOutputContract, createAskOutputContract } from "./ask-contract-identity.ts";
3
+ import type { AskOutputExtractionPolicy } from "./ask-output.ts";
3
4
  import type { AskMarkerContext } from "./transport.ts";
4
- import type { AskOptions, SpawnOptions, StructuredAskOptions } from "./types.ts";
5
+ import type { AskOptions, ResolvedSpawnOptions, StructuredAskOptions } from "./types.ts";
5
6
 
6
7
  /** The combined option shape used internally after Definition defaults merge. */
7
8
  export type EffectiveAskOptions = AskOptions | StructuredAskOptions<TSchema>;
8
9
 
9
10
  /** Inputs that identify an Ask in a Cassette. */
10
11
  export interface AskHashOptions {
11
- readonly spawnOptions: SpawnOptions;
12
+ readonly spawnOptions: ResolvedSpawnOptions;
12
13
  readonly index: number;
13
14
  readonly prompt: string;
14
15
  /** Soft-limit behavior and structured output contract, unlike timeoutMs, identify replay. */
@@ -43,6 +44,7 @@ export function askHash(options: AskHashOptions): string {
43
44
  ...(ask?.maxToolCalls === undefined ? {} : { maxToolCalls: ask.maxToolCalls }),
44
45
  ...(ask?.maxDurationMs === undefined ? {} : { maxDurationMs: ask.maxDurationMs }),
45
46
  ...(ask?.idleMs === undefined ? {} : { idleMs: ask.idleMs }),
47
+ ...(ask?.stallMs === undefined ? {} : { stallMs: ask.stallMs }),
46
48
  ...(ask?.wrapUpPrompt === undefined ? {} : { wrapUpPrompt: ask.wrapUpPrompt }),
47
49
  ...(outputContract === undefined ? {} : outputContract),
48
50
  }),
@@ -71,6 +73,7 @@ export function askMarkerContext({
71
73
  ...(ask?.maxToolCalls === undefined ? {} : { maxToolCalls: ask.maxToolCalls }),
72
74
  ...(ask?.maxDurationMs === undefined ? {} : { maxDurationMs: ask.maxDurationMs }),
73
75
  ...(ask?.idleMs === undefined ? {} : { idleMs: ask.idleMs }),
76
+ ...(ask?.stallMs === undefined ? {} : { stallMs: ask.stallMs }),
74
77
  ...(ask?.wrapUpPrompt === undefined ? {} : { wrapUpPrompt: ask.wrapUpPrompt }),
75
78
  ...(outputContract === undefined ? {} : outputContract),
76
79
  },
@@ -80,7 +83,8 @@ export function askMarkerContext({
80
83
  /** Creates structured identity only when the Ask explicitly carries a schema. */
81
84
  export function structuredOutputContract(
82
85
  ask: EffectiveAskOptions | undefined,
86
+ policy?: AskOutputExtractionPolicy,
83
87
  ): AskOutputContract | undefined {
84
88
  if (ask === undefined || !("outputSchema" in ask)) return undefined;
85
- return createAskOutputContract(ask.outputSchema, ask.maxSteers);
89
+ return createAskOutputContract(ask.outputSchema, ask.maxSteers, policy);
86
90
  }