@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
@@ -6,6 +6,7 @@ import type {
6
6
  AskPlayback,
7
7
  Frame,
8
8
  OpenOptions,
9
+ RecordedSpawnSelection,
9
10
  TransportFactory,
10
11
  TransportStartup,
11
12
  TransportStartupObserver,
@@ -43,6 +44,12 @@ export function recordingTransport(inner: TransportFactory, sink: CassetteSink):
43
44
  throw error;
44
45
  }
45
46
  },
47
+
48
+ // The recorder is the outermost wrapper of every composition, so a
49
+ // Cassette-backed inner factory answers the recorded-selection peek.
50
+ recordedSpawn(options: OpenOptions): RecordedSpawnSelection | undefined {
51
+ return inner.recordedSpawn?.(options);
52
+ },
46
53
  };
47
54
  }
48
55
 
@@ -1,5 +1,10 @@
1
1
  import { YaagError } from "../errors.ts";
2
- import type { AskMarker, AskMarkerContext, OpenOptions } from "../transport/index.ts";
2
+ import type {
3
+ AskMarker,
4
+ AskMarkerContext,
5
+ OpenOptions,
6
+ RecordedSpawnSelection,
7
+ } from "../transport/index.ts";
3
8
  import type { CassetteAgent, CassetteAsk, CassetteSpawn } from "./cassette.ts";
4
9
 
5
10
  /** A pure description of the first strict replay identity mismatch. */
@@ -28,7 +33,13 @@ export const replayMismatch = {
28
33
  const actualHash = spawnHash(actual);
29
34
  return expectedHash === actualHash
30
35
  ? null
31
- : { kind: "spawn-options", agent: actual.name, expectedHash, actualHash };
36
+ : {
37
+ kind: "spawn-options",
38
+ agent: actual.name,
39
+ expectedHash,
40
+ actualHash,
41
+ changedFields: spawnChangedFields(expected.spawn, actual),
42
+ };
32
43
  },
33
44
 
34
45
  ask(agent: CassetteAgent, cursor: number, actual: AskMarker): ReplayMismatch | null {
@@ -73,13 +84,73 @@ export function strictReplay(mismatch: ReplayMismatch): never {
73
84
  );
74
85
  }
75
86
  const at = mismatch.index === undefined ? "spawn" : `Ask ${mismatch.index}`;
87
+ const changed =
88
+ mismatch.kind === "spawn-options" && mismatch.changedFields !== undefined
89
+ ? ` changed ${mismatch.changedFields.join(", ")};`
90
+ : "";
76
91
  throw new YaagError(
77
92
  "REPLAY_DIVERGED",
78
- `replay diverged for agent "${mismatch.agent}" at ${at}: expected ${mismatch.expectedHash}, actual ${mismatch.actualHash}`,
93
+ `replay diverged for agent "${mismatch.agent}" at ${at}:${changed} expected ${mismatch.expectedHash}, actual ${mismatch.actualHash}`,
79
94
  mismatch.agent,
80
95
  );
81
96
  }
82
97
 
98
+ /** The identity fields a changed spawn altered, for the Divergence report (ADR-0039). */
99
+ function spawnChangedFields(expected: CassetteSpawn, actual: OpenOptions): readonly string[] {
100
+ return changedAmong(SPAWN_OPEN_FIELDS, expected, actual);
101
+ }
102
+
103
+ /**
104
+ * True when a live spawn matches the recorded one on every identity field
105
+ * except the resolved model and thinking, so a resume may adopt the recorded
106
+ * selection instead of re-running Model Resolution (ADR-0039).
107
+ */
108
+ export function spawnMatchesExceptModel(expected: CassetteSpawn, actual: OpenOptions): boolean {
109
+ return (
110
+ spawnHash({ ...expected, model: undefined, thinking: undefined }) ===
111
+ spawnHash({ ...actual, model: undefined, thinking: undefined })
112
+ );
113
+ }
114
+
115
+ /** The recorded resolved selection of one Agent, or `undefined` when none is recorded. */
116
+ export function recordedSpawnSelection(
117
+ agent: CassetteAgent | null,
118
+ ): RecordedSpawnSelection | undefined {
119
+ if (agent === null) return undefined;
120
+ return {
121
+ ...(agent.spawn.model === undefined ? {} : { model: agent.spawn.model }),
122
+ ...(agent.spawn.thinking === undefined ? {} : { thinking: agent.spawn.thinking }),
123
+ };
124
+ }
125
+
126
+ /** The spawn fields both Divergence reports compare, so the two cannot drift apart. */
127
+ const SPAWN_IDENTITY_FIELDS = [
128
+ "cwd",
129
+ "model",
130
+ "systemPrompt",
131
+ "thinking",
132
+ "appendSystemPrompt",
133
+ "tools",
134
+ "disallowedTools",
135
+ "skills",
136
+ "disallowedSkills",
137
+ "worktree",
138
+ ] as const;
139
+
140
+ /** The spawn identity fields plus the Agent name, which only an open request carries. */
141
+ const SPAWN_OPEN_FIELDS = ["name", ...SPAWN_IDENTITY_FIELDS] as const;
142
+
143
+ /** The listed fields whose canonical JSON differs between two identity records. */
144
+ function changedAmong<Key extends string>(
145
+ fields: readonly Key[],
146
+ expected: { readonly [Field in Key]?: unknown },
147
+ actual: { readonly [Field in Key]?: unknown },
148
+ ): readonly Key[] {
149
+ return fields.filter(
150
+ (field) => JSON.stringify(expected[field]) !== JSON.stringify(actual[field]),
151
+ );
152
+ }
153
+
83
154
  function askMatches(expected: CassetteAsk, actual: AskMarker): boolean {
84
155
  return (
85
156
  expected.index === actual.index &&
@@ -93,22 +164,7 @@ function askMatches(expected: CassetteAsk, actual: AskMarker): boolean {
93
164
  function changedFields(expected: AskMarkerContext, actual: AskMarkerContext): readonly string[] {
94
165
  const fields: string[] = [];
95
166
  if (expected.prompt !== actual.prompt) fields.push("prompt");
96
- for (const field of [
97
- "cwd",
98
- "model",
99
- "systemPrompt",
100
- "thinking",
101
- "appendSystemPrompt",
102
- "tools",
103
- "disallowedTools",
104
- "skills",
105
- "disallowedSkills",
106
- "worktree",
107
- ] as const) {
108
- if (JSON.stringify(expected.spawn[field]) !== JSON.stringify(actual.spawn[field])) {
109
- fields.push(field);
110
- }
111
- }
167
+ fields.push(...changedAmong(SPAWN_IDENTITY_FIELDS, expected.spawn, actual.spawn));
112
168
  for (const field of [
113
169
  "maxTurns",
114
170
  "maxToolCalls",
@@ -5,12 +5,13 @@ import type {
5
5
  AskPlayback,
6
6
  Frame,
7
7
  OpenOptions,
8
+ RecordedSpawnSelection,
8
9
  TransportFactory,
9
10
  TransportStartupObserver,
10
11
  } from "../transport/index.ts";
11
12
  import type { Cassette } from "./cassette.ts";
12
13
  import { CassetteReplay } from "./cassette-replay.ts";
13
- import { replayMismatch, strictReplay } from "./replay-divergence.ts";
14
+ import { recordedSpawnSelection, replayMismatch, strictReplay } from "./replay-divergence.ts";
14
15
 
15
16
  /**
16
17
  * Creates a strict Cassette-backed transport factory without starting pi.
@@ -32,6 +33,12 @@ export function replayTransport(cassette: Cassette): TransportFactory {
32
33
  if (agent.worktree !== undefined) observeStartup?.({ worktree: agent.worktree });
33
34
  return new ReplayTransport(new CassetteReplay(agent));
34
35
  },
36
+
37
+ // Strict replay always offers the recorded selection: an outcome the
38
+ // declared spec can no longer produce must diverge at open (ADR-0039).
39
+ recordedSpawn(): RecordedSpawnSelection | undefined {
40
+ return recordedSpawnSelection(cassette.agents[spawnCursor] ?? null);
41
+ },
35
42
  };
36
43
  }
37
44
 
@@ -6,13 +6,18 @@ import type {
6
6
  AskPlayback,
7
7
  Frame,
8
8
  OpenOptions,
9
+ RecordedSpawnSelection,
9
10
  TransportFactory,
10
11
  TransportStartupObserver,
11
12
  } from "../transport/index.ts";
12
13
  import { FrameQueue } from "../transport/index.ts";
13
14
  import type { Cassette, CassetteAgent } from "./cassette.ts";
14
15
  import { CassetteReplay } from "./cassette-replay.ts";
15
- import { replayMismatch } from "./replay-divergence.ts";
16
+ import {
17
+ recordedSpawnSelection,
18
+ replayMismatch,
19
+ spawnMatchesExceptModel,
20
+ } from "./replay-divergence.ts";
16
21
  import { checkResumePreconditions } from "./resume-preconditions.ts";
17
22
 
18
23
  /**
@@ -39,6 +44,14 @@ export function resumeTransport(cassette: Cassette, live: TransportFactory): Tra
39
44
  });
40
45
  return new ResumeTransport(agent, live, options);
41
46
  },
47
+
48
+ // A changed spawn must not silently inherit the recorded model, so the
49
+ // recorded selection is offered only when every other field matches.
50
+ recordedSpawn(options: OpenOptions): RecordedSpawnSelection | undefined {
51
+ const agent = cassette.agents[spawnCursor] ?? null;
52
+ if (agent === null || !spawnMatchesExceptModel(agent.spawn, options)) return undefined;
53
+ return recordedSpawnSelection(agent);
54
+ },
42
55
  };
43
56
  }
44
57
 
package/src/errors.ts CHANGED
@@ -7,8 +7,10 @@ export interface AskLimitOutcome {
7
7
  readonly count: number;
8
8
  }
9
9
 
10
+ import type { ModelError } from "./model/index.ts";
10
11
  import type { AgentProgress } from "./transport/index.ts";
11
12
 
13
+ export type { ModelError } from "./model/index.ts";
12
14
  export type { AgentProgress } from "./transport/index.ts";
13
15
 
14
16
  /** The recorded result when yaag rejects an Ask because no frame arrived within `idleMs`. */
@@ -29,6 +31,11 @@ export interface AskInvalidOutputOutcome {
29
31
  readonly errors: readonly string[];
30
32
  }
31
33
 
34
+ /** The recorded history when every model candidate of one Model Resolution loop failed. */
35
+ export interface ModelResolutionOutcome {
36
+ readonly modelErrors: readonly ModelError[];
37
+ }
38
+
32
39
  /** Why an Ask, spawn, or Run failed. Programs may branch on this; most won't. */
33
40
  export type YaagErrorCode =
34
41
  | "AGENT_FAILED" // turn settled with stopReason error/aborted, or empty text
@@ -45,7 +52,8 @@ export type YaagErrorCode =
45
52
  | "RUN_CLOSED" // spawn was requested after the Run began settling
46
53
  | "RUN_STOPPED" // the Run's abort signal fired before the program completed
47
54
  | "WORKTREE_REFUSED" // base cwd is not a clean Git repository
48
- | "SPAWN_FAILED"; // pi failed to start, e.g. unknown model
55
+ | "MODEL_RESOLUTION_FAILED" // the resolver gave up, or every candidate failed; carries the history
56
+ | "SPAWN_FAILED"; // pi failed to start, e.g. a broken tool contract or a startup timeout
49
57
 
50
58
  /** The single error class of the runtime (ADR-0003). */
51
59
  export class YaagError extends Error {
@@ -66,12 +74,22 @@ export class YaagError extends Error {
66
74
  readonly steeringEfforts?: number;
67
75
  /** Localized extraction or schema errors, present only for `ASK_INVALID_OUTPUT`. */
68
76
  readonly errors?: readonly string[];
77
+ /**
78
+ * Failed model candidates, present only for `MODEL_RESOLUTION_FAILED`. It
79
+ * holds one entry for each attempt, oldest first. A candidate that already
80
+ * failed with `not_found` or `auth` is refused again (ADR-0037).
81
+ */
82
+ readonly modelErrors?: readonly ModelError[];
69
83
 
70
84
  constructor(
71
85
  code: YaagErrorCode,
72
86
  message: string,
73
87
  agent?: string,
74
- options?: AskLimitOutcome | AskStalledOutcome | AskInvalidOutputOutcome,
88
+ options?:
89
+ | AskLimitOutcome
90
+ | AskStalledOutcome
91
+ | AskInvalidOutputOutcome
92
+ | ModelResolutionOutcome,
75
93
  ) {
76
94
  super(message);
77
95
  this.name = "YaagError";
@@ -90,6 +108,9 @@ export class YaagError extends Error {
90
108
  this.steeringEfforts = options.steeringEfforts;
91
109
  this.errors = options.errors;
92
110
  }
111
+ if (options !== undefined && "modelErrors" in options) {
112
+ this.modelErrors = options.modelErrors;
113
+ }
93
114
  }
94
115
  }
95
116
 
@@ -118,6 +139,38 @@ export function askStalledError(agent: string, outcome: AskStalledOutcome): Yaag
118
139
  );
119
140
  }
120
141
 
142
+ /** The MODEL_RESOLUTION_FAILED rejection for an exhausted or self-repeating resolver. */
143
+ export function modelResolutionError(
144
+ agent: string,
145
+ errors: readonly ModelError[],
146
+ refusedCandidate?: string,
147
+ ): YaagError {
148
+ if (errors.length === 0) {
149
+ return new YaagError(
150
+ "MODEL_RESOLUTION_FAILED",
151
+ `agent "${agent}": no model candidate to try`,
152
+ agent,
153
+ { modelErrors: errors },
154
+ );
155
+ }
156
+ const history = errors.map((error) => `${error.failedModel} (${error.reason})`).join(", ");
157
+ // The guard only refuses a candidate that has a matching permanent entry.
158
+ const refusal =
159
+ refusedCandidate === undefined
160
+ ? ""
161
+ : `; refused to retry "${refusedCandidate}", which already failed with ${
162
+ errors.find((error) => error.failedModel === refusedCandidate)?.reason
163
+ }`;
164
+ return new YaagError(
165
+ "MODEL_RESOLUTION_FAILED",
166
+ `agent "${agent}": model resolution gave up after ${errors.length} attempt${
167
+ errors.length === 1 ? "" : "s"
168
+ }: ${history}${refusal}`,
169
+ agent,
170
+ { modelErrors: errors },
171
+ );
172
+ }
173
+
121
174
  /** Narrows an unknown rejection reason to a YaagError. */
122
175
  export function isYaagError(value: unknown): value is YaagError {
123
176
  return value instanceof YaagError;
package/src/events.ts CHANGED
@@ -7,9 +7,11 @@
7
7
  * dedicated fd arrives with the extension.
8
8
  */
9
9
  import type { SettlementCause } from "./ask/index.ts";
10
+ import type { ModelErrorReason } from "./model/index.ts";
10
11
  import type { TokenBreakdown, WorktreeResolution } from "./transport/index.ts";
11
12
 
12
13
  export type { SettlementCause } from "./ask/index.ts";
14
+ export type { ModelErrorReason } from "./model/index.ts";
13
15
 
14
16
  /**
15
17
  * A Run's outcome (ADR-0022). `paused` arrives with the pause slice.
@@ -118,6 +120,23 @@ export type LifecycleEventBody =
118
120
  */
119
121
  readonly cause?: SettlementCause;
120
122
  }
123
+ | {
124
+ /**
125
+ * One failed Model Resolution attempt and the candidate that replaced it.
126
+ * yaag emits it at spawn time and inside an Ask (ADR-0037, ADR-0038).
127
+ * A resolver that gives up emits none: the Run fails with
128
+ * MODEL_RESOLUTION_FAILED, which already carries the full history.
129
+ */
130
+ readonly type: "model_fallback";
131
+ readonly agent: string;
132
+ /** The candidate pattern that failed, after inline-suffix stripping. */
133
+ readonly failedModel: string;
134
+ readonly reason: ModelErrorReason;
135
+ /** 0-based index of the failed attempt, as the resolver's history numbers it. */
136
+ readonly attempt: number;
137
+ /** The candidate resolution picked next. */
138
+ readonly resolvedModel: string;
139
+ }
121
140
  | {
122
141
  readonly type: "agent_usage";
123
142
  readonly agent: string;
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export type {
23
23
  AskLimitKind,
24
24
  AskLimitOutcome,
25
25
  AskStalledOutcome,
26
+ ModelResolutionOutcome,
26
27
  YaagErrorCode,
27
28
  } from "./errors.ts";
28
29
  export { isYaagError, YaagError } from "./errors.ts";
@@ -68,6 +69,7 @@ export type {
68
69
  EndedRunSummary,
69
70
  ExitedAgentInfo,
70
71
  IdleAgentInfo,
72
+ ModelFallbackInfo,
71
73
  NodeInfo,
72
74
  RunningRunSummary,
73
75
  RunOutcome,
@@ -2,9 +2,14 @@
2
2
  * Public surface of the `model/` module: model resolution.
3
3
  * Files inside this directory import each other directly.
4
4
  */
5
+ export { ModelErrorHistory } from "./model-error-history.ts";
6
+ export { classifyAskFailure, classifyModelFailure } from "./model-failure.ts";
7
+ export type { ModelFallback, ModelFallbackSink } from "./model-fallback.ts";
8
+ export type { AvailableModel } from "./model-match.ts";
5
9
  export {
6
10
  type ModelError,
7
11
  type ModelErrorReason,
12
+ type ModelResolution,
8
13
  type ModelResolver,
9
14
  type ModelSelection,
10
15
  type ModelSpec,
@@ -12,4 +17,17 @@ export {
12
17
  type ThinkingResolver,
13
18
  type ThinkingSpec,
14
19
  } from "./model-resolution.ts";
20
+ export { type ModelSwapResult, swapModel } from "./model-swap.ts";
21
+ export {
22
+ type RecordedResolution,
23
+ type RecordedResolutionOptions,
24
+ type RecordedSpawnSelection,
25
+ resolveRecordedModel,
26
+ } from "./recorded-resolution.ts";
27
+ export {
28
+ type FallbackLoopOptions,
29
+ type ResolutionLoopOptions,
30
+ resolveModel,
31
+ retryOnModelFailure,
32
+ } from "./resolution-loop.ts";
15
33
  export type { ThinkingLevel } from "./thinking-level.ts";
@@ -0,0 +1,36 @@
1
+ import type { ModelError, ModelErrorReason } from "./model-resolution.ts";
2
+
3
+ /**
4
+ * The Model Resolution attempt history of one Agent.
5
+ *
6
+ * The spawn-time loop and every mid-Ask fallback loop of the same Agent share
7
+ * one history, so a resolver sees every candidate that already failed, whenever
8
+ * it failed.
9
+ */
10
+ export class ModelErrorHistory {
11
+ readonly #errors: ModelError[] = [];
12
+
13
+ /** Every failed attempt so far, oldest first. */
14
+ get errors(): readonly ModelError[] {
15
+ return this.#errors;
16
+ }
17
+
18
+ /**
19
+ * Appends one failed attempt and returns it; the attempt number is the
20
+ * history length before the push.
21
+ */
22
+ record(reason: ModelErrorReason, failedModel: string): ModelError {
23
+ const error: ModelError = { reason, failedModel, attempt: this.#errors.length };
24
+ this.#errors.push(error);
25
+ return error;
26
+ }
27
+
28
+ /** True when this candidate already failed for a reason a retry cannot fix. */
29
+ failedPermanently(candidate: string): boolean {
30
+ return this.#errors.some(
31
+ (error) =>
32
+ error.failedModel === candidate &&
33
+ (error.reason === "not_found" || error.reason === "auth"),
34
+ );
35
+ }
36
+ }
@@ -0,0 +1,78 @@
1
+ import { isYaagError } from "../errors.ts";
2
+ import type { ModelErrorReason } from "./model-resolution.ts";
3
+
4
+ /**
5
+ * Classification reads pi's English diagnostics, and pi offers nothing else
6
+ * (ADR-0037). A reworded upstream message matches no pattern, so the failure
7
+ * stays generic and fallback stops: the degradation never fires wrongly.
8
+ *
9
+ * Message patterns pi emits when a model pattern matches nothing.
10
+ * Sources: `core/model-resolver.js` (`resolveCliModel`), `modes/rpc/rpc-mode.js`
11
+ * (`set_model` miss) and the CLI catalog guard in `core/model-registry.js`.
12
+ */
13
+ const NOT_FOUND: readonly RegExp[] = [
14
+ /model "[^"]*" not found/i,
15
+ // Covers `Model not found: <p>/<id>` and `Model not found or no API key - …`.
16
+ /model not found/i,
17
+ /no models match pattern/i,
18
+ /unknown provider "/i,
19
+ /is ambiguous across providers/i,
20
+ /no models available/i,
21
+ ];
22
+
23
+ /**
24
+ * Message patterns pi emits when credentials are missing or rejected.
25
+ * Sources: `core/model-registry.js` (`No API key found for "<provider>"`),
26
+ * `core/agent-session.js` (`No API key for <provider>/<id>`) and provider HTTP errors.
27
+ */
28
+ const AUTH: readonly RegExp[] = [
29
+ // Covers `No API key for <p>/<id>`, `No API key found for "<p>"` and
30
+ // `No API key provided for provider <p>`.
31
+ /no api key\b/i,
32
+ /\b401\b|\b403\b/,
33
+ /unauthorized|invalid api key|authentication/i,
34
+ ];
35
+
36
+ /** Provider throttling. Declared here so mid-Ask fallback reuses one table. */
37
+ const RATE_LIMITED: readonly RegExp[] = [/\b429\b/, /rate.?limit/i, /quota exceeded/i];
38
+
39
+ /**
40
+ * Classifies a spawn or Ask failure into a Model Resolution trigger, or
41
+ * `undefined` for a generic failure that must stay an ordinary failure.
42
+ *
43
+ * Evaluation order is `not_found` → `auth` → `rate_limited`, first match wins:
44
+ * a "model not found" diagnostic may also mention API keys in its help text, so
45
+ * the most specific table has to be consulted first.
46
+ */
47
+ export function classifyModelFailure(error: unknown): ModelErrorReason | undefined {
48
+ const message = messageOf(error);
49
+ if (message === "") return undefined;
50
+ if (NOT_FOUND.some((pattern) => pattern.test(message))) return "not_found";
51
+ if (AUTH.some((pattern) => pattern.test(message))) return "auth";
52
+ if (RATE_LIMITED.some((pattern) => pattern.test(message))) return "rate_limited";
53
+ return undefined;
54
+ }
55
+
56
+ /**
57
+ * Classifies one Ask rejection into a Model Resolution trigger.
58
+ *
59
+ * Only AGENT_FAILED can be a trigger: ASK_STALLED is generic and never enters
60
+ * resolution (ADR-0029), ASK_LIMIT/ASK_TIMEOUT/ASK_INVALID_OUTPUT are yaag's own
61
+ * verdicts, and AGENT_DIED leaves nothing to swap a model on.
62
+ */
63
+ export function classifyAskFailure(error: unknown): ModelErrorReason | undefined {
64
+ if (!isYaagError(error) || error.code !== "AGENT_FAILED") return undefined;
65
+ return classifyModelFailure(error);
66
+ }
67
+
68
+ /** The error's own message plus its cause chain, so a wrapped diagnostic still classifies. */
69
+ function messageOf(error: unknown): string {
70
+ if (!(error instanceof Error)) return String(error ?? "");
71
+ const parts: string[] = [error.message];
72
+ let cause: unknown = error.cause;
73
+ for (let depth = 0; cause !== undefined && cause !== null && depth < 5; depth += 1) {
74
+ parts.push(cause instanceof Error ? cause.message : String(cause));
75
+ cause = cause instanceof Error ? cause.cause : undefined;
76
+ }
77
+ return parts.join(" ");
78
+ }
@@ -0,0 +1,15 @@
1
+ import type { ModelErrorReason } from "./model-resolution.ts";
2
+
3
+ /** One failed Model Resolution attempt and the candidate that replaced it. */
4
+ export interface ModelFallback {
5
+ /** The candidate pattern that failed, after inline-suffix stripping. */
6
+ readonly failedModel: string;
7
+ readonly reason: ModelErrorReason;
8
+ /** 0-based index of the failed attempt in the Agent's shared history. */
9
+ readonly attempt: number;
10
+ /** The candidate the resolver picked next. */
11
+ readonly resolvedModel: string;
12
+ }
13
+
14
+ /** Where a Model Resolution loop reports each fallback it takes. */
15
+ export type ModelFallbackSink = (fallback: ModelFallback) => void;
@@ -0,0 +1,99 @@
1
+ /** One entry of pi's `get_available_models` snapshot. */
2
+ export interface AvailableModel {
3
+ readonly provider: string;
4
+ readonly id: string;
5
+ readonly name?: string;
6
+ }
7
+
8
+ /** What matching one yaag model pattern against pi's snapshot produced. */
9
+ export type ModelMatch =
10
+ | { readonly kind: "matched"; readonly model: AvailableModel }
11
+ | { readonly kind: "no_match" }
12
+ | { readonly kind: "ambiguous"; readonly candidates: readonly string[] };
13
+
14
+ /**
15
+ * Matches one yaag model pattern against the models pi reports as available.
16
+ *
17
+ * A reduced mirror of pi's `core/model-resolver.js` (`resolveCliModel` /
18
+ * `tryMatchModel`): exact `provider/id`, exact bare `id`, then a known provider
19
+ * prefix, then a partial `id`/`name` search. A bare id that exists under several
20
+ * providers is refused rather than guessed, because yaag cannot see pi's
21
+ * configured-auth ordering. No suffix parsing happens here: an inline thinking
22
+ * suffix is already stripped by `normalizeModelResolution`.
23
+ */
24
+ export function matchAvailableModel(
25
+ pattern: string,
26
+ models: readonly AvailableModel[],
27
+ ): ModelMatch {
28
+ const needle = pattern.trim().toLowerCase();
29
+ if (needle === "" || models.length === 0) return { kind: "no_match" };
30
+
31
+ const exactPair = models.filter((model) => qualified(model) === needle);
32
+ if (exactPair.length > 0) return matched(exactPair[0]);
33
+
34
+ const exactId = models.filter((model) => model.id.toLowerCase() === needle);
35
+ if (exactId.length === 1) return matched(exactId[0]);
36
+ if (exactId.length > 1) return ambiguous(exactId);
37
+
38
+ const slash = needle.indexOf("/");
39
+ if (slash > 0) {
40
+ const provider = needle.slice(0, slash);
41
+ const rest = needle.slice(slash + 1);
42
+ const scoped = models.filter((model) => model.provider.toLowerCase() === provider);
43
+ if (scoped.length > 0) return search(rest, scoped);
44
+ return { kind: "no_match" };
45
+ }
46
+ return search(needle, models);
47
+ }
48
+
49
+ /** Reads pi's `get_available_models` payload, or `null` when it is not one. */
50
+ export function readAvailableModels(data: unknown): readonly AvailableModel[] | null {
51
+ if (typeof data !== "object" || data === null) return null;
52
+ const list: unknown = (data as { models?: unknown }).models;
53
+ if (!Array.isArray(list)) return null;
54
+ const models: AvailableModel[] = [];
55
+ for (const entry of list) {
56
+ const model = readAvailableModel(entry);
57
+ if (model !== null) models.push(model);
58
+ }
59
+ return models;
60
+ }
61
+
62
+ /** Reads one pi Model object, or `null` when the payload is not one. */
63
+ export function readAvailableModel(entry: unknown): AvailableModel | null {
64
+ if (typeof entry !== "object" || entry === null) return null;
65
+ const record = entry as { provider?: unknown; id?: unknown; name?: unknown };
66
+ if (typeof record.provider !== "string" || typeof record.id !== "string") return null;
67
+ return {
68
+ provider: record.provider,
69
+ id: record.id,
70
+ ...(typeof record.name === "string" ? { name: record.name } : {}),
71
+ };
72
+ }
73
+
74
+ function search(needle: string, models: readonly AvailableModel[]): ModelMatch {
75
+ const exact = models.filter((model) => model.id.toLowerCase() === needle);
76
+ if (exact.length === 1) return matched(exact[0]);
77
+ if (exact.length > 1) return ambiguous(exact);
78
+ const partial = models.filter(
79
+ (model) =>
80
+ model.id.toLowerCase().includes(needle) || (model.name ?? "").toLowerCase().includes(needle),
81
+ );
82
+ if (partial.length === 0) return { kind: "no_match" };
83
+ // pi prefers its aliases and its newest dated ids; the highest-sorting id is a
84
+ // deterministic stand-in yaag can compute without pi's catalog metadata.
85
+ const sorted = [...partial].sort((left, right) => right.id.localeCompare(left.id));
86
+ return matched(sorted[0]);
87
+ }
88
+
89
+ function matched(model: AvailableModel | undefined): ModelMatch {
90
+ return model === undefined ? { kind: "no_match" } : { kind: "matched", model };
91
+ }
92
+
93
+ function ambiguous(models: readonly AvailableModel[]): ModelMatch {
94
+ return { kind: "ambiguous", candidates: models.map(qualified) };
95
+ }
96
+
97
+ function qualified(model: AvailableModel): string {
98
+ return `${model.provider}/${model.id}`.toLowerCase();
99
+ }