@yaag/runtime 0.6.1 → 0.7.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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/agent/agent.ts +38 -2
  3. package/src/agent/define-agent.ts +13 -3
  4. package/src/agent/spawn-request.ts +64 -0
  5. package/src/agent/spawn.ts +84 -63
  6. package/src/ask/ask-exchange-events.ts +10 -1
  7. package/src/ask/ask-exchange-options.ts +13 -0
  8. package/src/ask/ask-exchange.ts +77 -9
  9. package/src/ask/index.ts +1 -0
  10. package/src/cassette/cassette-replay.ts +23 -4
  11. package/src/cassette/recording-transport.ts +7 -0
  12. package/src/cassette/replay-divergence.ts +75 -19
  13. package/src/cassette/replay-transport.ts +8 -1
  14. package/src/cassette/resume-transport.ts +14 -1
  15. package/src/errors.ts +55 -2
  16. package/src/events.ts +19 -0
  17. package/src/index.ts +2 -0
  18. package/src/model/index.ts +18 -0
  19. package/src/model/model-error-history.ts +36 -0
  20. package/src/model/model-failure.ts +78 -0
  21. package/src/model/model-fallback.ts +15 -0
  22. package/src/model/model-match.ts +99 -0
  23. package/src/model/model-resolution.ts +44 -6
  24. package/src/model/model-swap.ts +81 -0
  25. package/src/model/recorded-resolution.ts +61 -0
  26. package/src/model/resolution-loop.ts +115 -0
  27. package/src/summary/index.ts +1 -0
  28. package/src/summary/summary-agent.ts +9 -1
  29. package/src/summary/summary-fallbacks.ts +50 -0
  30. package/src/summary/summary.ts +12 -0
  31. package/src/transport/fake-transport.ts +57 -1
  32. package/src/transport/index.ts +4 -0
  33. package/src/transport/live-transport.ts +37 -4
  34. package/src/transport/stderr-tail.ts +32 -0
  35. package/src/transport/transport.ts +11 -0
  36. package/src/types.ts +28 -5
  37. package/src/wire-constants.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/runtime",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -4,11 +4,21 @@ import { exchangeAsk } from "../ask/index.ts";
4
4
  import { ReportResultTool } from "../ask-contract/index.ts";
5
5
  import { agentError } from "../errors.ts";
6
6
  import type { EventSink } from "../events.ts";
7
+ import type { ModelErrorHistory, ModelResolution } from "../model/index.ts";
7
8
  import type { AgentStats, AgentTransport } from "../transport/index.ts";
8
9
  import { Connection } from "../transport/index.ts";
9
10
  import type { AskOptions, Handle, ResolvedSpawnOptions, StructuredAskOptions } from "../types.ts";
10
11
  import { AgentUsage } from "./agent-usage.ts";
11
12
 
13
+ /** The mid-Ask Model Resolution one Agent's spawn handed it (ADR-0038). */
14
+ export interface AgentModelFallback {
15
+ readonly resolution: ModelResolution;
16
+ /** The Agent-wide attempt history, shared with its spawn loop. */
17
+ readonly history: ModelErrorHistory;
18
+ /** The model candidate this Agent's spawn settled on. */
19
+ readonly candidate: string;
20
+ }
21
+
12
22
  export interface AgentOptions {
13
23
  readonly name: string;
14
24
  readonly cwd: string;
@@ -17,6 +27,11 @@ export interface AgentOptions {
17
27
  readonly emit: EventSink;
18
28
  /** The options this Agent was spawned with, for the ADR-0014 Ask hash. */
19
29
  readonly spawnOptions: ResolvedSpawnOptions;
30
+ /**
31
+ * Mid-Ask Model Resolution for this Agent, sharing the spawn loop's history.
32
+ * Absent when the spawn named no candidate: there is nothing to fall back from.
33
+ */
34
+ readonly modelFallback?: AgentModelFallback;
20
35
  /** Definition-owned defaults merged below explicit per-Ask options. */
21
36
  readonly askDefaults?: AskOptions;
22
37
  /** Definition identity recorded on its Asks, outside replay identity. */
@@ -34,7 +49,6 @@ export class Agent implements Handle {
34
49
  readonly name: string;
35
50
  readonly cwd: string;
36
51
  readonly branch: string | undefined;
37
- readonly model: string;
38
52
 
39
53
  readonly #transport: AgentTransport;
40
54
  readonly #connection: Connection;
@@ -45,6 +59,9 @@ export class Agent implements Handle {
45
59
  readonly #askLimitGraceMs: number | undefined;
46
60
  readonly #idleAbortSettleMs: number | undefined;
47
61
  readonly #stallProbeSettleMs: number | undefined;
62
+ readonly #modelFallback: AgentModelFallback | undefined;
63
+ #model: string;
64
+ #candidate: string;
48
65
  readonly #usage: AgentUsage;
49
66
  readonly #reportResultTool = new ReportResultTool();
50
67
  #busy = false;
@@ -56,7 +73,9 @@ export class Agent implements Handle {
56
73
  this.name = options.name;
57
74
  this.cwd = options.cwd;
58
75
  this.branch = options.branch;
59
- this.model = options.transport.model;
76
+ this.#model = options.transport.model;
77
+ this.#modelFallback = options.modelFallback;
78
+ this.#candidate = options.modelFallback?.candidate ?? options.transport.model;
60
79
  this.#transport = options.transport;
61
80
  this.#emit = options.emit;
62
81
  this.#spawnOptions = options.spawnOptions;
@@ -71,6 +90,11 @@ export class Agent implements Handle {
71
90
  this.#connection = new Connection(options.transport, options.name);
72
91
  }
73
92
 
93
+ /** The model pi reports for this Agent now; a mid-Ask swap updates it. */
94
+ get model(): string {
95
+ return this.#model;
96
+ }
97
+
74
98
  async ask<Schema extends TSchema>(
75
99
  prompt: string,
76
100
  options: StructuredAskOptions<Schema>,
@@ -101,6 +125,18 @@ export class Agent implements Handle {
101
125
  reportResultTool: this.#reportResultTool,
102
126
  emit: this.#emit,
103
127
  usage: this.#usage,
128
+ modelFallback:
129
+ this.#modelFallback === undefined
130
+ ? undefined
131
+ : {
132
+ resolution: this.#modelFallback.resolution,
133
+ history: this.#modelFallback.history,
134
+ currentModel: () => this.#candidate,
135
+ onSwapped: (candidate: string, reportedModel: string) => {
136
+ this.#candidate = candidate;
137
+ this.#model = reportedModel;
138
+ },
139
+ },
104
140
  close: () => void this.close().catch(() => {}),
105
141
  askLimitGraceMs: this.#askLimitGraceMs,
106
142
  idleAbortSettleMs: this.#idleAbortSettleMs,
@@ -14,10 +14,18 @@ export interface AgentConfig {
14
14
  /**
15
15
  * Model id handed to `pi --model`. Unset = pi's default. An array is an ordered
16
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"`).
17
+ * and any pattern may carry an inline thinking suffix (`"opus-5:medium"`),
18
+ * which wins over `thinking`. Each attempt settles the model first, then the
19
+ * thinking level for that model. yaag starts a new attempt only when pi
20
+ * reports `not_found`, `auth`, or `rate_limited` (ADR-0037).
18
21
  */
19
22
  readonly model?: ModelSpec;
20
- /** Thinking budget for the Agent's turns, as a level or a resolver. */
23
+ /**
24
+ * Thinking budget for the Agent's turns, as a level or a resolver. The
25
+ * resolver runs again for each attempt, with the model that settled for that
26
+ * attempt. A resolver kept in a definition is frozen policy, so it must stay
27
+ * pure and synchronous (ADR-0037).
28
+ */
21
29
  readonly thinking?: ThinkingSpec;
22
30
  /** Allow-list of tool names. Unset = pi's default tool set. */
23
31
  readonly tools?: readonly string[];
@@ -47,7 +55,9 @@ const arrayFields = ["tools", "disallowedTools", "skills", "disallowedSkills"] a
47
55
  * Throws a TypeError when `name` is missing or blank, or when `cwd`/`worktree`
48
56
  * appear — those are topology, chosen at spawn time, not baked into a definition.
49
57
  * The config is defensively copied and deep-frozen, so later mutation of the
50
- * caller's arrays cannot change the definition.
58
+ * caller's arrays cannot change the definition. `defineAgent` also copies and
59
+ * freezes a model array, so a later change of the caller's array cannot change
60
+ * Model Resolution (ADR-0037).
51
61
  */
52
62
  export function defineAgent(config: AgentConfig): AgentDefinition {
53
63
  if (typeof config.name !== "string" || config.name.trim() === "") {
@@ -0,0 +1,64 @@
1
+ import type { ModelSelection } from "../model/index.ts";
2
+ import type { OpenOptions } from "../transport/index.ts";
3
+ import type { ResolvedSpawnOptions, SpawnOptions } from "../types.ts";
4
+
5
+ /** Everything one spawn needs to build its open request, before a model settles. */
6
+ export interface OpenRequestOptions {
7
+ readonly name: string;
8
+ readonly cwd: string;
9
+ readonly spawnOptions: SpawnOptions;
10
+ readonly resolvedExtensionPaths?: readonly string[];
11
+ readonly sessionDir: string | undefined;
12
+ }
13
+
14
+ /**
15
+ * Builds one spawn's open request from its options.
16
+ *
17
+ * A caller that has not settled a model yet passes the declared options: the
18
+ * request then carries a literal `model`/`thinking` and carries none for an
19
+ * array or resolver spec. That request is what a Cassette-backed factory peeks
20
+ * at, which is why the resume peek blanks model and thinking before it compares
21
+ * the request with the recorded spawn (ADR-0039).
22
+ */
23
+ export function openRequest(options: OpenRequestOptions): OpenOptions {
24
+ const spawnOptions = options.spawnOptions;
25
+ const model = typeof spawnOptions.model === "string" ? spawnOptions.model : undefined;
26
+ const thinking = typeof spawnOptions.thinking === "function" ? undefined : spawnOptions.thinking;
27
+ return {
28
+ cwd: options.cwd,
29
+ name: options.name,
30
+ ...(model === undefined ? {} : { model }),
31
+ ...(spawnOptions.systemPrompt === undefined ? {} : { systemPrompt: spawnOptions.systemPrompt }),
32
+ ...(thinking === undefined ? {} : { thinking }),
33
+ ...(spawnOptions.appendSystemPrompt === undefined
34
+ ? {}
35
+ : { appendSystemPrompt: spawnOptions.appendSystemPrompt }),
36
+ ...(spawnOptions.inherit === undefined ? {} : { inherit: spawnOptions.inherit }),
37
+ ...(spawnOptions.tools === undefined ? {} : { tools: spawnOptions.tools }),
38
+ ...(spawnOptions.disallowedTools === undefined
39
+ ? {}
40
+ : { disallowedTools: spawnOptions.disallowedTools }),
41
+ ...(spawnOptions.skills === undefined ? {} : { skills: spawnOptions.skills }),
42
+ ...(spawnOptions.disallowedSkills === undefined
43
+ ? {}
44
+ : { disallowedSkills: spawnOptions.disallowedSkills }),
45
+ ...(options.resolvedExtensionPaths === undefined
46
+ ? {}
47
+ : { resolvedExtensionPaths: options.resolvedExtensionPaths }),
48
+ ...(spawnOptions.worktree === true ? { worktree: true as const } : {}),
49
+ ...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
50
+ };
51
+ }
52
+
53
+ /** Replaces the caller's `model`/`thinking` forms with one attempt's settled selection. */
54
+ export function withSelection(
55
+ options: SpawnOptions,
56
+ selection: ModelSelection,
57
+ ): ResolvedSpawnOptions {
58
+ const { model: _model, thinking: _thinking, ...rest } = options;
59
+ return {
60
+ ...rest,
61
+ ...(selection.model === undefined ? {} : { model: selection.model }),
62
+ ...(selection.thinking === undefined ? {} : { thinking: selection.thinking }),
63
+ };
64
+ }
@@ -2,7 +2,13 @@ import { resolve } from "node:path";
2
2
  import { YaagError } from "../errors.ts";
3
3
  import type { EventSink } from "../events.ts";
4
4
  import { resolveExtensionPaths } from "../extension/index.ts";
5
- import { normalizeModelResolution } from "../model/index.ts";
5
+ import {
6
+ ModelErrorHistory,
7
+ type ModelSelection,
8
+ normalizeModelResolution,
9
+ resolveModel,
10
+ resolveRecordedModel,
11
+ } from "../model/index.ts";
6
12
  import type { RunContext } from "../run/index.ts";
7
13
  import type { AgentTransport, TransportFactory, TransportStartup } from "../transport/index.ts";
8
14
  import type {
@@ -15,6 +21,7 @@ import type {
15
21
  import { Agent } from "./agent.ts";
16
22
  import { uniqueAgentName } from "./agent-names.ts";
17
23
  import { type AgentDefinition, agentDefinitionConfig, isAgentDefinition } from "./define-agent.ts";
24
+ import { openRequest, withSelection } from "./spawn-request.ts";
18
25
 
19
26
  /** Dependencies for one Run's Agent-spawn gate. */
20
27
  export interface SpawnDependencies {
@@ -48,16 +55,70 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
48
55
  const request = resolveRequest(definitionOrOptions, overrides);
49
56
  const cwd = resolve(request.spawnOptions.cwd ?? process.cwd());
50
57
  const name = uniqueAgentName(request.spawnOptions.name, deps.agents.length, taken);
51
- const spawnOptions = resolveSpawnSelection(request.spawnOptions, name);
58
+ const resolution = normalizeModelResolution(request.spawnOptions);
59
+ // One history per Agent: its spawn loop and every mid-Ask fallback loop
60
+ // append to it, so a resolver sees every candidate that already failed.
61
+ const history = new ModelErrorHistory();
52
62
  try {
53
- const opened = await openTransport({
54
- factory: deps.factory,
55
- name,
56
- cwd,
57
- spawnOptions,
58
- sessionDir: deps.sessionDir,
59
- programFile: deps.programFile,
60
- });
63
+ // Extension paths do not vary per candidate, so a bad path fails once, generically.
64
+ const resolvedExtensionPaths =
65
+ request.spawnOptions.extensions === undefined
66
+ ? undefined
67
+ : await resolveExtensionPaths(request.spawnOptions.extensions, {
68
+ ...(deps.programFile === undefined ? {} : { programFile: deps.programFile }),
69
+ projectRoot: cwd,
70
+ }).catch((error: unknown) => {
71
+ if (error instanceof YaagError) throw error;
72
+ throw new YaagError("SPAWN_FAILED", `agent "${name}": ${String(error)}`, name);
73
+ });
74
+ const attempt = async (
75
+ selection: ModelSelection,
76
+ ): Promise<{ opened: OpenedTransport; spawnOptions: ResolvedSpawnOptions }> => {
77
+ const settled = withSelection(request.spawnOptions, selection);
78
+ return {
79
+ opened: await openTransport({
80
+ factory: deps.factory,
81
+ name,
82
+ cwd,
83
+ spawnOptions: settled,
84
+ ...(resolvedExtensionPaths === undefined ? {} : { resolvedExtensionPaths }),
85
+ sessionDir: deps.sessionDir,
86
+ }),
87
+ spawnOptions: settled,
88
+ };
89
+ };
90
+ // The peek must stay in the same synchronous block as the first open: a
91
+ // Cassette-backed factory claims Agents in open order (ADR-0013).
92
+ const recorded = deps.factory.recordedSpawn?.(
93
+ openRequest({
94
+ name,
95
+ cwd,
96
+ spawnOptions: request.spawnOptions,
97
+ ...(resolvedExtensionPaths === undefined ? {} : { resolvedExtensionPaths }),
98
+ sessionDir: deps.sessionDir,
99
+ }),
100
+ );
101
+ // A Cassette-backed spawn adopts the recorded resolved selection and skips
102
+ // the loop, so a replayed Run emits no spawn-time fallback (ADR-0039).
103
+ const adopted =
104
+ recorded === undefined ? undefined : resolveRecordedModel({ resolution, recorded });
105
+ // The adopted outcome carries the attempts the recording already spent, so
106
+ // a later mid-Ask fallback re-resolves from that attempt index (ADR-0039).
107
+ for (const skipped of adopted?.skipped ?? []) {
108
+ history.record(skipped.reason, skipped.failedModel);
109
+ }
110
+ const { opened, spawnOptions } =
111
+ adopted === undefined
112
+ ? await resolveModel({
113
+ resolution,
114
+ agent: name,
115
+ history,
116
+ onFallback: (fallback) => {
117
+ deps.emit({ type: "model_fallback", agent: name, ...fallback });
118
+ },
119
+ attempt,
120
+ })
121
+ : await attempt(adopted.selection);
61
122
  const resolvedCwd = opened.startup.worktree?.cwd ?? cwd;
62
123
  const branch = opened.startup.worktree?.branch;
63
124
  const sessionFile = opened.startup.sessionFile;
@@ -68,6 +129,11 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
68
129
  transport: opened.transport,
69
130
  emit: deps.emit,
70
131
  spawnOptions,
132
+ // An Agent that named no candidate inherits pi's default model, so a
133
+ // failing Ask has nothing to fall back from and stays an Ask failure.
134
+ ...(spawnOptions.model === undefined
135
+ ? {}
136
+ : { modelFallback: { resolution, history, candidate: spawnOptions.model } }),
71
137
  ...(request.askDefaults === undefined ? {} : { askDefaults: request.askDefaults }),
72
138
  ...(request.definitionName === undefined ? {} : { definitionName: request.definitionName }),
73
139
  });
@@ -100,23 +166,6 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
100
166
  };
101
167
  }
102
168
 
103
- /**
104
- * Attempt-0 shim for Model Resolution: one selection, no retry loop.
105
- * Ticket 02 replaces this with the fallback loop and MODEL_RESOLUTION_FAILED.
106
- */
107
- function resolveSpawnSelection(options: SpawnOptions, name: string): ResolvedSpawnOptions {
108
- const selection = normalizeModelResolution(options).resolve([]);
109
- if (selection === undefined) {
110
- throw new YaagError("SPAWN_FAILED", `agent "${name}": no model candidate to try`, name);
111
- }
112
- const { model: _model, thinking: _thinking, ...rest } = options;
113
- return {
114
- ...rest,
115
- ...(selection.model === undefined ? {} : { model: selection.model }),
116
- ...(selection.thinking === undefined ? {} : { thinking: selection.thinking }),
117
- };
118
- }
119
-
120
169
  type MutableSpawnOverrides = { -readonly [Key in keyof SpawnOverrides]: SpawnOverrides[Key] };
121
170
 
122
171
  interface SpawnRequest {
@@ -206,8 +255,8 @@ interface OpenTransportOptions {
206
255
  readonly name: string;
207
256
  readonly cwd: string;
208
257
  readonly spawnOptions: ResolvedSpawnOptions;
258
+ readonly resolvedExtensionPaths?: readonly string[];
209
259
  readonly sessionDir: string | undefined;
210
- readonly programFile: string | undefined;
211
260
  }
212
261
 
213
262
  interface OpenedTransport {
@@ -218,44 +267,16 @@ interface OpenedTransport {
218
267
  async function openTransport(options: OpenTransportOptions): Promise<OpenedTransport> {
219
268
  const startup: TransportStartup = {};
220
269
  try {
221
- const resolvedExtensionPaths =
222
- options.spawnOptions.extensions === undefined
223
- ? undefined
224
- : await resolveExtensionPaths(options.spawnOptions.extensions, {
225
- ...(options.programFile === undefined ? {} : { programFile: options.programFile }),
226
- projectRoot: options.cwd,
227
- });
228
270
  const transport = await options.factory.open(
229
- {
230
- cwd: options.cwd,
271
+ openRequest({
231
272
  name: options.name,
232
- ...(options.spawnOptions.model === undefined ? {} : { model: options.spawnOptions.model }),
233
- ...(options.spawnOptions.systemPrompt === undefined
234
- ? {}
235
- : { systemPrompt: options.spawnOptions.systemPrompt }),
236
- ...(options.spawnOptions.thinking === undefined
237
- ? {}
238
- : { thinking: options.spawnOptions.thinking }),
239
- ...(options.spawnOptions.appendSystemPrompt === undefined
240
- ? {}
241
- : { appendSystemPrompt: options.spawnOptions.appendSystemPrompt }),
242
- ...(options.spawnOptions.inherit === undefined
243
- ? {}
244
- : { inherit: options.spawnOptions.inherit }),
245
- ...(options.spawnOptions.tools === undefined ? {} : { tools: options.spawnOptions.tools }),
246
- ...(options.spawnOptions.disallowedTools === undefined
247
- ? {}
248
- : { disallowedTools: options.spawnOptions.disallowedTools }),
249
- ...(options.spawnOptions.skills === undefined
250
- ? {}
251
- : { skills: options.spawnOptions.skills }),
252
- ...(options.spawnOptions.disallowedSkills === undefined
273
+ cwd: options.cwd,
274
+ spawnOptions: options.spawnOptions,
275
+ ...(options.resolvedExtensionPaths === undefined
253
276
  ? {}
254
- : { disallowedSkills: options.spawnOptions.disallowedSkills }),
255
- ...(resolvedExtensionPaths === undefined ? {} : { resolvedExtensionPaths }),
256
- ...(options.spawnOptions.worktree === true ? { worktree: true } : {}),
257
- ...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
258
- },
277
+ : { resolvedExtensionPaths: options.resolvedExtensionPaths }),
278
+ sessionDir: options.sessionDir,
279
+ }),
259
280
  (report) => Object.assign(startup, report),
260
281
  );
261
282
  return { transport, startup };
@@ -1,4 +1,5 @@
1
1
  import type { AgentActivity, AskOutputChannel, EventSink } from "../events.ts";
2
+ import type { ModelFallback } from "../model/index.ts";
2
3
  import type { NodeSnapshot } from "../node/index.ts";
3
4
  import { agentAskPath, childPath } from "../node/index.ts";
4
5
  import { promptGist } from "../prompt/index.ts";
@@ -13,7 +14,10 @@ export interface AskEndOutcome {
13
14
  readonly cause?: SettlementCause;
14
15
  }
15
16
 
16
- /** Emits the four Ask-scoped Lifecycle Events for one exchange. */
17
+ /**
18
+ * Emits the Ask-scoped Lifecycle Events for one exchange, plus the Agent-scoped
19
+ * `model_fallback` this exchange's fallback loop reports.
20
+ */
17
21
  export class AskEvents {
18
22
  readonly #emit: EventSink;
19
23
  readonly #agent: string;
@@ -57,6 +61,11 @@ export class AskEvents {
57
61
  });
58
62
  };
59
63
 
64
+ /** Not Ask-scoped: a fallback names the Agent only, like the spawn-time loop. */
65
+ fallback = (fallback: ModelFallback): void => {
66
+ this.#emit({ type: "model_fallback", agent: this.#agent, ...fallback });
67
+ };
68
+
60
69
  /** A `normal` cause is the absent default, so ordinary settlements stay lean. */
61
70
  end(outcome: AskEndOutcome): void {
62
71
  const { durationMs, ok, maxFrameGapMs, cause = "normal" } = outcome;
@@ -1,5 +1,6 @@
1
1
  import type { ReportResultTool } from "../ask-contract/index.ts";
2
2
  import type { EventSink } from "../events.ts";
3
+ import type { ModelErrorHistory, ModelResolution } from "../model/index.ts";
3
4
  import type { AgentTransport, Connection, Frame } from "../transport/index.ts";
4
5
  import type { ResolvedSpawnOptions } from "../types.ts";
5
6
  import type { EffectiveAskOptions } from "./ask-hash.ts";
@@ -9,6 +10,16 @@ export interface FrameObserver {
9
10
  observe(frame: Frame): void;
10
11
  }
11
12
 
13
+ /** Mid-Ask Model Resolution for one Agent, shared with its spawn-time loop. */
14
+ export interface AskModelFallback {
15
+ readonly resolution: ModelResolution;
16
+ readonly history: ModelErrorHistory;
17
+ /** The model candidate the Agent runs right now; a failure is attributed to it. */
18
+ currentModel(): string;
19
+ /** Reports the candidate a successful swap applied, and the model pi now reports. */
20
+ onSwapped(candidate: string, reportedModel: string): void;
21
+ }
22
+
12
23
  /** Options that connect one Ask exchange to its Agent's identity and event stream. */
13
24
  export interface AskExchangeOptions {
14
25
  readonly agent: string;
@@ -25,6 +36,8 @@ export interface AskExchangeOptions {
25
36
  readonly emit: EventSink;
26
37
  /** The Agent's persistent usage accumulator; fed frames only during live Asks. */
27
38
  readonly usage: FrameObserver;
39
+ /** Mid-Ask Model Resolution; absent when this Agent's spawn named no candidate. */
40
+ readonly modelFallback: AskModelFallback | undefined;
28
41
  /** Kills the Agent on ASK_TIMEOUT and destructive stalls — the one upward capability. */
29
42
  readonly close: () => void;
30
43
  /** Test-only override for the fixed duration-limit grace. */
@@ -17,6 +17,7 @@ import {
17
17
  askStalledError,
18
18
  isYaagError,
19
19
  } from "../errors.ts";
20
+ import { retryOnModelFailure, swapModel } from "../model/index.ts";
20
21
  import { NodeTracker } from "../node/index.ts";
21
22
  import type { AskPlayback } from "../transport/index.ts";
22
23
  import { FrameGapTracker } from "../transport/index.ts";
@@ -43,7 +44,7 @@ export async function exchangeAsk(options: AskExchangeOptions): Promise<unknown>
43
44
  }
44
45
  class AskExchange {
45
46
  readonly #options: AskExchangeOptions;
46
- readonly #turn = new AskTurn();
47
+ #turn = new AskTurn();
47
48
  readonly #events: AskEvents;
48
49
  #limit: AskLimit | null = null;
49
50
  #idle: IdleWatch | null = null;
@@ -73,16 +74,48 @@ class AskExchange {
73
74
 
74
75
  async run(): Promise<unknown> {
75
76
  const startedAt = Date.now();
77
+ this.#openEnvelope();
76
78
  try {
77
- const result = await this.#exchange();
79
+ const result = await this.#attempts();
78
80
  this.#events.end(this.#endOutcome(startedAt, true));
79
81
  return result;
80
82
  } catch (error) {
81
83
  if (this.#began) this.#events.end(this.#endOutcome(startedAt, false));
82
84
  throw error;
85
+ } finally {
86
+ this.#finish();
83
87
  }
84
88
  }
85
89
 
90
+ /**
91
+ * Runs the Ask, retrying it on a swapped-in model when a model failure ends an
92
+ * attempt. Every attempt stays inside one Ask envelope, so one Ask still emits
93
+ * exactly one `ask_start`, one `ask_end` and one recorded Cassette Ask.
94
+ */
95
+ async #attempts(): Promise<unknown> {
96
+ const fallback = this.#options.modelFallback;
97
+ if (fallback === undefined) return await this.#exchange();
98
+ return await retryOnModelFailure({
99
+ resolution: fallback.resolution,
100
+ agent: this.#options.agent,
101
+ history: fallback.history,
102
+ onFallback: this.#events.fallback,
103
+ currentModel: () => fallback.currentModel(),
104
+ attempt: async () => await this.#exchange(),
105
+ swap: async (selection) => {
106
+ // Replay re-runs this loop and re-sends the recorded swap frames, which
107
+ // CassetteReplay matches as ordinary frames (ADR-0039). The Ask keeps its
108
+ // spawn-time identity, so the retry records under the same hash.
109
+ const swapped = await swapModel({
110
+ agent: this.#options.agent,
111
+ command: async (frame) => await this.#options.connection.command(frame),
112
+ selection,
113
+ });
114
+ fallback.onSwapped(selection.model ?? swapped.model, swapped.model);
115
+ },
116
+ });
117
+ }
118
+
86
119
  #endOutcome(startedAt: number, ok: boolean): AskEndOutcome {
87
120
  return {
88
121
  durationMs: Date.now() - startedAt,
@@ -94,8 +127,7 @@ class AskExchange {
94
127
 
95
128
  async #exchange(): Promise<unknown> {
96
129
  try {
97
- this.#begin();
98
- this.#timeoutDeadline = this.#deadline();
130
+ this.#armAttempt();
99
131
  // The schema command is answered without a turn, so settlement tracking
100
132
  // starts after it: nothing it could emit may settle this Ask (ADR-0032).
101
133
  await this.#deliverSchema();
@@ -119,11 +151,12 @@ class AskExchange {
119
151
  this.#recordInvalidOutput(error);
120
152
  throw error;
121
153
  } finally {
122
- this.#cleanup();
154
+ this.#cleanupAttempt();
123
155
  }
124
156
  }
125
157
 
126
- #begin(): void {
158
+ /** Opens the Ask envelope: identity, recording and the Ask-scoped events. Once per Ask. */
159
+ #openEnvelope(): void {
127
160
  this.#legacyExtraction = this.#recordedLegacyPolicy();
128
161
  const outputContract = structuredOutputContract(
129
162
  this.#options.ask,
@@ -147,7 +180,27 @@ class AskExchange {
147
180
  });
148
181
  this.#began = true;
149
182
  this.#playback = playback;
183
+ // `timeoutMs` is the Ask's hard ceiling and kills the Agent (ADR-0003), so
184
+ // it is Ask-scoped: retries share one deadline, unlike the soft limits.
185
+ this.#timeoutDeadline = this.#deadline();
150
186
  this.#events.start(this.#options.prompt, playback !== undefined);
187
+ }
188
+
189
+ /**
190
+ * Arms one attempt: fresh turn tracking, limits, watches and report_result
191
+ * state, so a retry starts from nothing — the failed attempt's work died with
192
+ * its model error, and its stale reported result cannot settle the retry.
193
+ */
194
+ #armAttempt(): void {
195
+ const playback = this.#playback;
196
+ this.#turn = new AskTurn();
197
+ this.#outcome = undefined;
198
+ this.#stalled = undefined;
199
+ // A failed attempt's stall recovery and invalid output describe work that
200
+ // died with it, so neither may reach the Ask's single `ask_end` or its one
201
+ // recorded completion when a later attempt settles cleanly.
202
+ this.#cause = "normal";
203
+ this.#invalidOutput = undefined;
151
204
  this.#gap = playback === undefined ? new FrameGapTracker() : null;
152
205
  this.#activity = playback === undefined ? new AskActivityTracker(this.#events.activity) : null;
153
206
  this.#output =
@@ -443,15 +496,23 @@ class AskExchange {
443
496
  this.#invalidOutput = { kind: "invalid_output", steeringEfforts: error.steeringEfforts ?? 0 };
444
497
  }
445
498
 
446
- #cleanup(): void {
447
- this.#maxFrameGapMs = this.#gap?.maxGapMs;
499
+ /** Ends one attempt; the envelope stays open, so a retry can arm the next one. */
500
+ #cleanupAttempt(): void {
501
+ this.#maxFrameGapMs = maxGap(this.#maxFrameGapMs, this.#gap?.maxGapMs);
448
502
  this.#stalled ??= this.#idle?.result ?? this.#stall?.result ?? undefined;
449
503
  this.#limit?.cleanup();
450
504
  this.#idle?.cleanup();
451
505
  this.#stall?.cleanup();
452
506
  this.#output?.close();
507
+ if (this.#drainStaleFrames) {
508
+ this.#options.connection.drainStaleTurn();
509
+ this.#drainStaleFrames = false;
510
+ }
511
+ }
512
+
513
+ /** Closes the Ask envelope: one `finishAsk` for the whole Ask, retries included. */
514
+ #finish(): void {
453
515
  this.#options.connection.observe(null);
454
- if (this.#drainStaleFrames) this.#options.connection.drainStaleTurn();
455
516
  if (!this.#began) return;
456
517
  try {
457
518
  this.#options.transport.finishAsk({
@@ -465,3 +526,10 @@ class AskExchange {
465
526
  }
466
527
  }
467
528
  }
529
+
530
+ /** The widest frame gap seen across an Ask's attempts. */
531
+ function maxGap(left: number | undefined, right: number | undefined): number | undefined {
532
+ if (left === undefined) return right;
533
+ if (right === undefined) return left;
534
+ return Math.max(left, right);
535
+ }
package/src/ask/index.ts CHANGED
@@ -3,5 +3,6 @@
3
3
  * Files inside this directory import each other directly.
4
4
  */
5
5
  export { exchangeAsk } from "./ask-exchange.ts";
6
+ export type { AskModelFallback } from "./ask-exchange-options.ts";
6
7
  export { askHash, askMarkerContext, type EffectiveAskOptions } from "./ask-hash.ts";
7
8
  export type { SettlementCause } from "./stall-watchdog.ts";
@@ -48,6 +48,11 @@ export class CassetteReplay {
48
48
  this.#releaseOneResponse();
49
49
  return;
50
50
  }
51
+ // A recorded swap ends one Ask attempt (ADR-0038): the prompt that
52
+ // follows is the retry, not an ADR-0027 correction, so the floor moves.
53
+ if (expected.type === "set_model" || expected.type === "set_thinking_level") {
54
+ this.#openAttempt(this.#sentCursor);
55
+ }
51
56
  this.#releaseFor(frame.type);
52
57
  this.#consumeControls();
53
58
  return;
@@ -72,10 +77,7 @@ export class CassetteReplay {
72
77
  this.#receivedCursor = 0;
73
78
  this.#sent = ask.sentFrames;
74
79
  this.#received = ask.receivedFrames;
75
- // 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;
80
+ this.#openAttempt(0);
79
81
  const awaitsAbortSettlement = abortSettlementFollowsFinalText(ask);
80
82
  const settles = ask.receivedFrames.some((frame) => frame.type === "agent_settled");
81
83
  const reported = recordedReportedResult(ask);
@@ -95,6 +97,19 @@ export class CassetteReplay {
95
97
  this.#queue.end();
96
98
  }
97
99
 
100
+ /**
101
+ * Opens one Ask attempt at `cursor`: the first prompt at or after it is the
102
+ * attempt's own prompt, and every later prompt is a recorded correction.
103
+ *
104
+ * A schema-bearing attempt opens with the report_result schema command, so
105
+ * its own prompt is the second sent frame, not the first (ADR-0032).
106
+ */
107
+ #openAttempt(cursor: number): void {
108
+ const first = this.#sent[cursor];
109
+ this.#promptFloor =
110
+ first !== undefined && isReportResultCommandFrame(first) ? cursor + 1 : cursor;
111
+ }
112
+
98
113
  #consumeControls(): void {
99
114
  for (;;) {
100
115
  const expected = this.#sent[this.#sentCursor];
@@ -110,6 +125,10 @@ export class CassetteReplay {
110
125
  * amendment). A recorded `get_state` is a Stall Watchdog probe: replay is
111
126
  * never silent, so it never probes, and the recorded exchange is consumed
112
127
  * here instead of blocking the frames that follow it (ADR-0029).
128
+ *
129
+ * A recorded `get_available_models`/`set_model` pair is deliberately not a
130
+ * control: replay re-runs the mid-Ask Model Resolution and sends those
131
+ * commands itself, so `send()` matches them like any other frame (ADR-0039).
113
132
  */
114
133
  #isRecordedControl(frame: Frame): boolean {
115
134
  if (frame.type === "steer" || frame.type === "abort" || frame.type === "get_state") return true;