@yaag/runtime 0.6.2 → 0.8.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 (49) hide show
  1. package/package.json +1 -1
  2. package/src/agent/agent.ts +38 -2
  3. package/src/agent/define-agent.ts +20 -3
  4. package/src/agent/spawn-extensions.ts +97 -0
  5. package/src/agent/spawn-request.ts +70 -0
  6. package/src/agent/spawn.ts +102 -62
  7. package/src/ask/ask-exchange-events.ts +10 -1
  8. package/src/ask/ask-exchange-options.ts +13 -0
  9. package/src/ask/ask-exchange.ts +77 -9
  10. package/src/ask/index.ts +1 -0
  11. package/src/cassette/cassette-publish.ts +5 -1
  12. package/src/cassette/cassette-replay.ts +23 -4
  13. package/src/cassette/cassette-schema.ts +1 -0
  14. package/src/cassette/cassette.ts +8 -0
  15. package/src/cassette/recording-transport.ts +7 -0
  16. package/src/cassette/replay-divergence.ts +86 -20
  17. package/src/cassette/replay-transport.ts +8 -1
  18. package/src/cassette/resume-transport.ts +14 -1
  19. package/src/config/config-file.ts +67 -0
  20. package/src/config/config-issues.ts +31 -0
  21. package/src/config/config-paths.ts +38 -0
  22. package/src/config/config-schema.ts +25 -0
  23. package/src/config/effective-config.ts +116 -0
  24. package/src/config/index.ts +22 -0
  25. package/src/errors.ts +56 -2
  26. package/src/events.ts +19 -0
  27. package/src/extension/extension-paths.ts +15 -6
  28. package/src/index.ts +14 -0
  29. package/src/model/index.ts +18 -0
  30. package/src/model/model-error-history.ts +36 -0
  31. package/src/model/model-failure.ts +78 -0
  32. package/src/model/model-fallback.ts +15 -0
  33. package/src/model/model-match.ts +99 -0
  34. package/src/model/model-resolution.ts +44 -6
  35. package/src/model/model-swap.ts +81 -0
  36. package/src/model/recorded-resolution.ts +61 -0
  37. package/src/model/resolution-loop.ts +115 -0
  38. package/src/run/run.ts +8 -0
  39. package/src/summary/index.ts +1 -0
  40. package/src/summary/summary-agent.ts +9 -1
  41. package/src/summary/summary-fallbacks.ts +50 -0
  42. package/src/summary/summary.ts +12 -0
  43. package/src/transport/fake-transport.ts +57 -1
  44. package/src/transport/index.ts +4 -0
  45. package/src/transport/live-transport.ts +37 -4
  46. package/src/transport/stderr-tail.ts +32 -0
  47. package/src/transport/transport.ts +16 -0
  48. package/src/types.ts +33 -5
  49. package/src/wire-constants.ts +3 -0
@@ -0,0 +1,116 @@
1
+ import { type ConfigLayer, readConfigLayer } from "./config-file.ts";
2
+ import {
3
+ type ConfigEnvironment,
4
+ projectConfigPath,
5
+ resolveGlobalConfigPath,
6
+ } from "./config-paths.ts";
7
+
8
+ /** Which of the three layers a config value came from. */
9
+ export type ConfigLayerName = "global" | "project" | "run";
10
+
11
+ /** One extension declaration, kept exactly as written, with its resolution base. */
12
+ export interface ConfigExtension {
13
+ /** The declaration as written in the config file; never resolved here. */
14
+ readonly declaration: string;
15
+ /** The declaring config file's own directory (spec rule 4). */
16
+ readonly baseDirectory: string;
17
+ readonly layer: ConfigLayerName;
18
+ /** The declaring file, for diagnostics. */
19
+ readonly file: string;
20
+ }
21
+
22
+ /** The merged configuration one Run reads at start. */
23
+ export interface EffectiveConfig {
24
+ /** Extension declarations, concatenated global → project → run (spec rule 3). */
25
+ readonly extensions: readonly ConfigExtension[];
26
+ /** Config files that contributed, in merge order. */
27
+ readonly files: readonly string[];
28
+ }
29
+
30
+ /** What to discover and read. */
31
+ export interface EffectiveConfigRequest {
32
+ /** Environment for Global Config discovery; defaults to `process.env`. */
33
+ readonly env?: ConfigEnvironment;
34
+ /** Program Directory of the Run; absent means no Project Config layer. */
35
+ readonly programDirectory?: string;
36
+ /** Explicit Run Config path; a missing file here is an error. */
37
+ readonly configPath?: string;
38
+ /** Suppresses the discovered layers only; an explicit `configPath` survives (spec rule 8). */
39
+ readonly noConfig?: boolean;
40
+ }
41
+
42
+ /** The Effective Config of a Run that found no config at all. */
43
+ export const EMPTY_EFFECTIVE_CONFIG: EffectiveConfig = Object.freeze({
44
+ extensions: Object.freeze([]) as readonly ConfigExtension[],
45
+ files: Object.freeze([]) as readonly string[],
46
+ });
47
+
48
+ interface LoadedLayer {
49
+ readonly layer: ConfigLayer;
50
+ readonly name: ConfigLayerName;
51
+ }
52
+
53
+ interface Candidate {
54
+ readonly path: string;
55
+ readonly layer: ConfigLayerName;
56
+ readonly required: boolean;
57
+ }
58
+
59
+ /**
60
+ * Locates, reads, validates, and merges the three config layers.
61
+ *
62
+ * The result is frozen: the Effective Config is read once at Run start, so a
63
+ * config edit mid-Run changes nothing until the next Run (spec rule 6). Nothing
64
+ * is resolved, existence-checked, or deduplicated here — dedup happens by
65
+ * resolved path once the spawn knows its base directories (ADR-0040).
66
+ */
67
+ export async function loadEffectiveConfig(
68
+ request: EffectiveConfigRequest = {},
69
+ ): Promise<EffectiveConfig> {
70
+ const found: LoadedLayer[] = [];
71
+ // Read in order, sequentially: the first failing file is the reported one.
72
+ for (const candidate of candidates(request)) {
73
+ const layer = await readConfigLayer(candidate.path, { required: candidate.required });
74
+ if (layer !== undefined) found.push({ layer, name: candidate.layer });
75
+ }
76
+ return mergeLayers(found);
77
+ }
78
+
79
+ function candidates(request: EffectiveConfigRequest): readonly Candidate[] {
80
+ const discovered: Candidate[] = [];
81
+ if (request.noConfig !== true) {
82
+ discovered.push({
83
+ path: resolveGlobalConfigPath(request.env),
84
+ layer: "global",
85
+ required: false,
86
+ });
87
+ if (request.programDirectory !== undefined) {
88
+ discovered.push({
89
+ path: projectConfigPath(request.programDirectory),
90
+ layer: "project",
91
+ required: false,
92
+ });
93
+ }
94
+ }
95
+ if (request.configPath !== undefined) {
96
+ discovered.push({ path: request.configPath, layer: "run", required: true });
97
+ }
98
+ return discovered;
99
+ }
100
+
101
+ function mergeLayers(found: readonly LoadedLayer[]): EffectiveConfig {
102
+ const extensions = found.flatMap(({ layer, name }) =>
103
+ (layer.document.agents?.extensions ?? []).map((declaration) =>
104
+ Object.freeze({
105
+ declaration,
106
+ baseDirectory: layer.directory,
107
+ layer: name,
108
+ file: layer.file,
109
+ }),
110
+ ),
111
+ );
112
+ return Object.freeze({
113
+ extensions: Object.freeze(extensions),
114
+ files: Object.freeze(found.map(({ layer }) => layer.file)),
115
+ });
116
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Public surface of the `config/` module: the Global, Project, and Run Config
3
+ * layers and the frozen Effective Config a Run reads at start (ADR-0040).
4
+ * Files inside this directory import each other directly.
5
+ */
6
+
7
+ export {
8
+ CONFIG_FILE_NAME,
9
+ type ConfigEnvironment,
10
+ PROJECT_CONFIG_DIR,
11
+ projectConfigPath,
12
+ resolveGlobalConfigPath,
13
+ } from "./config-paths.ts";
14
+ export type { YaagConfigDocument } from "./config-schema.ts";
15
+ export {
16
+ type ConfigExtension,
17
+ type ConfigLayerName,
18
+ type EffectiveConfig,
19
+ type EffectiveConfigRequest,
20
+ EMPTY_EFFECTIVE_CONFIG,
21
+ loadEffectiveConfig,
22
+ } from "./effective-config.ts";
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
@@ -39,13 +46,15 @@ export type YaagErrorCode =
39
46
  | "ASK_STALLED" // no frame arrived within the silence budget; escalated, then kill
40
47
  | "ASK_INVALID_OUTPUT" // settled text could not be extracted or satisfy outputSchema
41
48
  | "ARGS_INVALID" // arguments failed schema validation before the Run started
49
+ | "CONFIG_INVALID" // a config layer is missing, unparsable, or violates the schema
42
50
  | "OPTIONS_CONFLICT" // incompatible Run options were supplied
43
51
  | "REPLAY_DIVERGED" // replayed program differed from its Cassette
44
52
  | "RESUME_REFUSED" // resume metadata is absent or the recorded tree moved
45
53
  | "RUN_CLOSED" // spawn was requested after the Run began settling
46
54
  | "RUN_STOPPED" // the Run's abort signal fired before the program completed
47
55
  | "WORKTREE_REFUSED" // base cwd is not a clean Git repository
48
- | "SPAWN_FAILED"; // pi failed to start, e.g. unknown model
56
+ | "MODEL_RESOLUTION_FAILED" // the resolver gave up, or every candidate failed; carries the history
57
+ | "SPAWN_FAILED"; // pi failed to start, e.g. a broken tool contract or a startup timeout
49
58
 
50
59
  /** The single error class of the runtime (ADR-0003). */
51
60
  export class YaagError extends Error {
@@ -66,12 +75,22 @@ export class YaagError extends Error {
66
75
  readonly steeringEfforts?: number;
67
76
  /** Localized extraction or schema errors, present only for `ASK_INVALID_OUTPUT`. */
68
77
  readonly errors?: readonly string[];
78
+ /**
79
+ * Failed model candidates, present only for `MODEL_RESOLUTION_FAILED`. It
80
+ * holds one entry for each attempt, oldest first. A candidate that already
81
+ * failed with `not_found` or `auth` is refused again (ADR-0037).
82
+ */
83
+ readonly modelErrors?: readonly ModelError[];
69
84
 
70
85
  constructor(
71
86
  code: YaagErrorCode,
72
87
  message: string,
73
88
  agent?: string,
74
- options?: AskLimitOutcome | AskStalledOutcome | AskInvalidOutputOutcome,
89
+ options?:
90
+ | AskLimitOutcome
91
+ | AskStalledOutcome
92
+ | AskInvalidOutputOutcome
93
+ | ModelResolutionOutcome,
75
94
  ) {
76
95
  super(message);
77
96
  this.name = "YaagError";
@@ -90,6 +109,9 @@ export class YaagError extends Error {
90
109
  this.steeringEfforts = options.steeringEfforts;
91
110
  this.errors = options.errors;
92
111
  }
112
+ if (options !== undefined && "modelErrors" in options) {
113
+ this.modelErrors = options.modelErrors;
114
+ }
93
115
  }
94
116
  }
95
117
 
@@ -118,6 +140,38 @@ export function askStalledError(agent: string, outcome: AskStalledOutcome): Yaag
118
140
  );
119
141
  }
120
142
 
143
+ /** The MODEL_RESOLUTION_FAILED rejection for an exhausted or self-repeating resolver. */
144
+ export function modelResolutionError(
145
+ agent: string,
146
+ errors: readonly ModelError[],
147
+ refusedCandidate?: string,
148
+ ): YaagError {
149
+ if (errors.length === 0) {
150
+ return new YaagError(
151
+ "MODEL_RESOLUTION_FAILED",
152
+ `agent "${agent}": no model candidate to try`,
153
+ agent,
154
+ { modelErrors: errors },
155
+ );
156
+ }
157
+ const history = errors.map((error) => `${error.failedModel} (${error.reason})`).join(", ");
158
+ // The guard only refuses a candidate that has a matching permanent entry.
159
+ const refusal =
160
+ refusedCandidate === undefined
161
+ ? ""
162
+ : `; refused to retry "${refusedCandidate}", which already failed with ${
163
+ errors.find((error) => error.failedModel === refusedCandidate)?.reason
164
+ }`;
165
+ return new YaagError(
166
+ "MODEL_RESOLUTION_FAILED",
167
+ `agent "${agent}": model resolution gave up after ${errors.length} attempt${
168
+ errors.length === 1 ? "" : "s"
169
+ }: ${history}${refusal}`,
170
+ agent,
171
+ { modelErrors: errors },
172
+ );
173
+ }
174
+
121
175
  /** Narrows an unknown rejection reason to a YaagError. */
122
176
  export function isYaagError(value: unknown): value is YaagError {
123
177
  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;
@@ -7,16 +7,24 @@ import { parseExtensionSource } from "./extension-source.ts";
7
7
  export interface ExtensionResolutionOptions extends ExtensionInstallRoots {
8
8
  /** Orchestration Program source path, required by relative declarations. */
9
9
  readonly programFile?: string;
10
+ /**
11
+ * Directory a relative declaration resolves against, and which wins over
12
+ * `programFile`: a config-sourced entry resolves from its own config file's
13
+ * directory (ADR-0040, spec rule 4).
14
+ */
15
+ readonly baseDirectory?: string;
10
16
  }
11
17
 
12
18
  /**
13
19
  * Resolves declared extensions into launch-ready `pi -e` arguments before transport opening.
14
20
  *
15
- * Filesystem declarations are resolved against the Orchestration Program directory and
21
+ * Filesystem declarations are resolved against `baseDirectory` when given, and against
22
+ * the Orchestration Program directory otherwise, and are
16
23
  * checked for existence. `npm:`/`git:` specifiers expand to the entry points of the
17
24
  * already-installed package (project install shadows the user install); an uninstalled
18
25
  * specifier passes through verbatim so pi temp-installs it. Results retain declaration
19
- * order. Rejects missing paths and a relative declaration without `programFile`.
26
+ * order. Rejects missing paths, and a relative declaration that has neither
27
+ * `baseDirectory` nor `programFile`.
20
28
  */
21
29
  export async function resolveExtensionPaths(
22
30
  declarations: readonly string[],
@@ -35,7 +43,7 @@ async function resolveOne(
35
43
  ): Promise<readonly string[]> {
36
44
  const source = parseExtensionSource(declaration);
37
45
  if (source.kind === "path") {
38
- const path = resolveDeclaration(declaration, options.programFile);
46
+ const path = resolveDeclaration(declaration, options);
39
47
  await requireExists(declaration, path);
40
48
  return [path];
41
49
  }
@@ -45,12 +53,13 @@ async function resolveOne(
45
53
  return installed;
46
54
  }
47
55
 
48
- function resolveDeclaration(declaration: string, programFile: string | undefined): string {
56
+ function resolveDeclaration(declaration: string, options: ExtensionResolutionOptions): string {
49
57
  if (isAbsolute(declaration)) return declaration;
50
- if (programFile === undefined) {
58
+ if (options.baseDirectory !== undefined) return resolve(options.baseDirectory, declaration);
59
+ if (options.programFile === undefined) {
51
60
  throw new Error(`relative extension "${declaration}" requires RunOptions.programFile`);
52
61
  }
53
- return resolve(dirname(programFile), declaration);
62
+ return resolve(dirname(options.programFile), declaration);
54
63
  }
55
64
 
56
65
  async function requireExists(declaration: string, path: string): Promise<void> {
package/src/index.ts CHANGED
@@ -18,11 +18,24 @@ export {
18
18
  replayTransport,
19
19
  resumeTransport,
20
20
  } from "./cassette/index.ts";
21
+ export type {
22
+ ConfigEnvironment,
23
+ ConfigExtension,
24
+ ConfigLayerName,
25
+ EffectiveConfig,
26
+ EffectiveConfigRequest,
27
+ } from "./config/index.ts";
28
+ export {
29
+ EMPTY_EFFECTIVE_CONFIG,
30
+ loadEffectiveConfig,
31
+ PROJECT_CONFIG_DIR,
32
+ } from "./config/index.ts";
21
33
  export type {
22
34
  AskInvalidOutputOutcome,
23
35
  AskLimitKind,
24
36
  AskLimitOutcome,
25
37
  AskStalledOutcome,
38
+ ModelResolutionOutcome,
26
39
  YaagErrorCode,
27
40
  } from "./errors.ts";
28
41
  export { isYaagError, YaagError } from "./errors.ts";
@@ -68,6 +81,7 @@ export type {
68
81
  EndedRunSummary,
69
82
  ExitedAgentInfo,
70
83
  IdleAgentInfo,
84
+ ModelFallbackInfo,
71
85
  NodeInfo,
72
86
  RunningRunSummary,
73
87
  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
+ }