@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/runtime",
3
- "version": "0.6.2",
3
+ "version": "0.8.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[];
@@ -27,6 +35,13 @@ export interface AgentConfig {
27
35
  readonly skills?: readonly string[];
28
36
  /** Deny-list of skill names, applied after `skills`. */
29
37
  readonly disallowedSkills?: readonly string[];
38
+ /**
39
+ * Load the Effective Config's `agents.extensions` for Agents of this
40
+ * definition. Default true. A spawn's own `SpawnOptions.configExtensions`
41
+ * wins over this value; spawn *overrides* stay topology-only, so the opt-out
42
+ * is not one of them (ADR-0019, ADR-0040).
43
+ */
44
+ readonly configExtensions?: boolean;
30
45
  /** Ask options applied to every Ask on this Agent unless overridden per call. */
31
46
  readonly askDefaults?: AskOptions;
32
47
  }
@@ -47,7 +62,9 @@ const arrayFields = ["tools", "disallowedTools", "skills", "disallowedSkills"] a
47
62
  * Throws a TypeError when `name` is missing or blank, or when `cwd`/`worktree`
48
63
  * appear — those are topology, chosen at spawn time, not baked into a definition.
49
64
  * The config is defensively copied and deep-frozen, so later mutation of the
50
- * caller's arrays cannot change the definition.
65
+ * caller's arrays cannot change the definition. `defineAgent` also copies and
66
+ * freezes a model array, so a later change of the caller's array cannot change
67
+ * Model Resolution (ADR-0037).
51
68
  */
52
69
  export function defineAgent(config: AgentConfig): AgentDefinition {
53
70
  if (typeof config.name !== "string" || config.name.trim() === "") {
@@ -0,0 +1,97 @@
1
+ import type { ConfigExtension } from "../config/index.ts";
2
+ import { resolveExtensionPaths } from "../extension/index.ts";
3
+
4
+ /** Everything one spawn needs to turn declarations into `-e` arguments. */
5
+ export interface SpawnExtensionRequest {
6
+ /** Effective Config entries in merge order: global → project → run (spec rule 3). */
7
+ readonly configExtensions: readonly ConfigExtension[];
8
+ /** `SpawnOptions.extensions`, resolved against the program file as before. */
9
+ readonly declared?: readonly string[] | undefined;
10
+ /** False when the spawn or its definition opted out (spec rule 7). */
11
+ readonly useConfigExtensions: boolean;
12
+ /** Orchestration Program source path, the base of a relative spawn declaration. */
13
+ readonly programFile?: string | undefined;
14
+ /** Agent working directory, the project install root for `npm:`/`git:`. */
15
+ readonly projectRoot: string;
16
+ }
17
+
18
+ /** The two projections of one Agent's extensions: what launches, and what identifies. */
19
+ export interface SpawnExtensions {
20
+ /** Absolute launch-ready `-e` arguments, deduplicated, in final order. */
21
+ readonly resolvedPaths: readonly string[];
22
+ /** The surviving declarations behind them, same order, unresolved (ADR-0040). */
23
+ readonly declared: readonly string[];
24
+ }
25
+
26
+ /**
27
+ * Resolves the extensions of one Agent: config-sourced first, spawn-declared
28
+ * last, deduplicated by resolved path with the first occurrence keeping its
29
+ * place. Returns undefined when nothing is declared at all, so an
30
+ * extension-free spawn keeps its request shape.
31
+ *
32
+ * The two bases are deliberately different: a relative *path* from a config
33
+ * file resolves against that file's directory, while an `npm:`/`git:`
34
+ * specifier still installs from the Agent working directory (`projectRoot`).
35
+ * An uninstalled specifier passes through verbatim and dedups by its literal
36
+ * string.
37
+ *
38
+ * A declaration joins `declared` only when it contributed at least one new
39
+ * resolved path, so the identity list describes what actually ran. Two
40
+ * different declarations that resolve to the same path therefore keep only the
41
+ * first, which is machine-dependent by nature of the resolution itself.
42
+ */
43
+ export async function resolveSpawnExtensions(
44
+ request: SpawnExtensionRequest,
45
+ ): Promise<SpawnExtensions | undefined> {
46
+ const entries = request.useConfigExtensions ? request.configExtensions : [];
47
+ if (request.declared === undefined && entries.length === 0) return undefined;
48
+ const resolved: { declaration: string; paths: readonly string[] }[] = [];
49
+ for (const entry of entries) {
50
+ resolved.push({
51
+ declaration: entry.declaration,
52
+ paths: await resolveConfigEntry(entry, request.projectRoot),
53
+ });
54
+ }
55
+ for (const declaration of request.declared ?? []) {
56
+ resolved.push({
57
+ declaration,
58
+ // One declaration per call: `resolveExtensionPaths` keeps no cross-entry
59
+ // state, so the loop preserves its order and its error messages.
60
+ paths: await resolveExtensionPaths([declaration], {
61
+ ...(request.programFile === undefined ? {} : { programFile: request.programFile }),
62
+ projectRoot: request.projectRoot,
63
+ }),
64
+ });
65
+ }
66
+ const seen = new Set<string>();
67
+ const resolvedPaths: string[] = [];
68
+ const declared: string[] = [];
69
+ for (const { declaration, paths } of resolved) {
70
+ let contributed = false;
71
+ for (const path of paths) {
72
+ if (seen.has(path)) continue;
73
+ seen.add(path);
74
+ resolvedPaths.push(path);
75
+ contributed = true;
76
+ }
77
+ if (contributed) declared.push(declaration);
78
+ }
79
+ return { resolvedPaths, declared };
80
+ }
81
+
82
+ /** Names the declaring config file, so a bad entry points at its own source. */
83
+ async function resolveConfigEntry(
84
+ entry: ConfigExtension,
85
+ projectRoot: string,
86
+ ): Promise<readonly string[]> {
87
+ try {
88
+ return await resolveExtensionPaths([entry.declaration], {
89
+ baseDirectory: entry.baseDirectory,
90
+ projectRoot,
91
+ });
92
+ } catch (error) {
93
+ throw new Error(
94
+ `config "${entry.file}": ${error instanceof Error ? error.message : String(error)}`,
95
+ );
96
+ }
97
+ }
@@ -0,0 +1,70 @@
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 declaredExtensions?: readonly string[];
12
+ readonly sessionDir: string | undefined;
13
+ }
14
+
15
+ /**
16
+ * Builds one spawn's open request from its options.
17
+ *
18
+ * A caller that has not settled a model yet passes the declared options: the
19
+ * request then carries a literal `model`/`thinking` and carries none for an
20
+ * array or resolver spec. That request is what a Cassette-backed factory peeks
21
+ * at, which is why the resume peek blanks model and thinking before it compares
22
+ * the request with the recorded spawn (ADR-0039).
23
+ */
24
+ export function openRequest(options: OpenRequestOptions): OpenOptions {
25
+ const spawnOptions = options.spawnOptions;
26
+ const model = typeof spawnOptions.model === "string" ? spawnOptions.model : undefined;
27
+ const thinking = typeof spawnOptions.thinking === "function" ? undefined : spawnOptions.thinking;
28
+ return {
29
+ cwd: options.cwd,
30
+ name: options.name,
31
+ ...(model === undefined ? {} : { model }),
32
+ ...(spawnOptions.systemPrompt === undefined ? {} : { systemPrompt: spawnOptions.systemPrompt }),
33
+ ...(thinking === undefined ? {} : { thinking }),
34
+ ...(spawnOptions.appendSystemPrompt === undefined
35
+ ? {}
36
+ : { appendSystemPrompt: spawnOptions.appendSystemPrompt }),
37
+ ...(spawnOptions.inherit === undefined ? {} : { inherit: spawnOptions.inherit }),
38
+ ...(spawnOptions.tools === undefined ? {} : { tools: spawnOptions.tools }),
39
+ ...(spawnOptions.disallowedTools === undefined
40
+ ? {}
41
+ : { disallowedTools: spawnOptions.disallowedTools }),
42
+ ...(spawnOptions.skills === undefined ? {} : { skills: spawnOptions.skills }),
43
+ ...(spawnOptions.disallowedSkills === undefined
44
+ ? {}
45
+ : { disallowedSkills: spawnOptions.disallowedSkills }),
46
+ ...(options.resolvedExtensionPaths === undefined
47
+ ? {}
48
+ : { resolvedExtensionPaths: options.resolvedExtensionPaths }),
49
+ // An empty list means the same as no list at all, so it stays absent and an
50
+ // extension-free spawn keeps its recorded identity.
51
+ ...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
52
+ ? {}
53
+ : { declaredExtensions: options.declaredExtensions }),
54
+ ...(spawnOptions.worktree === true ? { worktree: true as const } : {}),
55
+ ...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
56
+ };
57
+ }
58
+
59
+ /** Replaces the caller's `model`/`thinking` forms with one attempt's settled selection. */
60
+ export function withSelection(
61
+ options: SpawnOptions,
62
+ selection: ModelSelection,
63
+ ): ResolvedSpawnOptions {
64
+ const { model: _model, thinking: _thinking, ...rest } = options;
65
+ return {
66
+ ...rest,
67
+ ...(selection.model === undefined ? {} : { model: selection.model }),
68
+ ...(selection.thinking === undefined ? {} : { thinking: selection.thinking }),
69
+ };
70
+ }
@@ -1,8 +1,14 @@
1
1
  import { resolve } from "node:path";
2
+ import type { ConfigExtension } from "../config/index.ts";
2
3
  import { YaagError } from "../errors.ts";
3
4
  import type { EventSink } from "../events.ts";
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,8 @@ 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 { resolveSpawnExtensions } from "./spawn-extensions.ts";
25
+ import { openRequest, withSelection } from "./spawn-request.ts";
18
26
 
19
27
  /** Dependencies for one Run's Agent-spawn gate. */
20
28
  export interface SpawnDependencies {
@@ -23,6 +31,8 @@ export interface SpawnDependencies {
23
31
  readonly emit: EventSink;
24
32
  readonly sessionDir: string | undefined;
25
33
  readonly programFile: string | undefined;
34
+ /** Effective Config entries, in merge order; empty when the Run has no config. */
35
+ readonly configExtensions: readonly ConfigExtension[];
26
36
  }
27
37
 
28
38
  /** A RunContext spawn function that can synchronously stop accepting new Agents. */
@@ -48,16 +58,79 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
48
58
  const request = resolveRequest(definitionOrOptions, overrides);
49
59
  const cwd = resolve(request.spawnOptions.cwd ?? process.cwd());
50
60
  const name = uniqueAgentName(request.spawnOptions.name, deps.agents.length, taken);
51
- const spawnOptions = resolveSpawnSelection(request.spawnOptions, name);
61
+ const resolution = normalizeModelResolution(request.spawnOptions);
62
+ // One history per Agent: its spawn loop and every mid-Ask fallback loop
63
+ // append to it, so a resolver sees every candidate that already failed.
64
+ const history = new ModelErrorHistory();
52
65
  try {
53
- const opened = await openTransport({
54
- factory: deps.factory,
55
- name,
56
- cwd,
57
- spawnOptions,
58
- sessionDir: deps.sessionDir,
59
- programFile: deps.programFile,
66
+ // Extension paths do not vary per candidate, so a bad path fails once, generically.
67
+ const extensions = await resolveSpawnExtensions({
68
+ configExtensions: deps.configExtensions,
69
+ declared: request.spawnOptions.extensions,
70
+ useConfigExtensions: request.spawnOptions.configExtensions !== false,
71
+ ...(deps.programFile === undefined ? {} : { programFile: deps.programFile }),
72
+ projectRoot: cwd,
73
+ }).catch((error: unknown) => {
74
+ if (error instanceof YaagError) throw error;
75
+ throw new YaagError("SPAWN_FAILED", `agent "${name}": ${String(error)}`, name);
60
76
  });
77
+ // One local for both open sites, so the launch arguments and the recorded
78
+ // identity cannot drift apart.
79
+ const extensionFields =
80
+ extensions === undefined
81
+ ? {}
82
+ : {
83
+ resolvedExtensionPaths: extensions.resolvedPaths,
84
+ declaredExtensions: extensions.declared,
85
+ };
86
+ const attempt = async (
87
+ selection: ModelSelection,
88
+ ): Promise<{ opened: OpenedTransport; spawnOptions: ResolvedSpawnOptions }> => {
89
+ const settled = withSelection(request.spawnOptions, selection);
90
+ return {
91
+ opened: await openTransport({
92
+ factory: deps.factory,
93
+ name,
94
+ cwd,
95
+ spawnOptions: settled,
96
+ ...extensionFields,
97
+ sessionDir: deps.sessionDir,
98
+ }),
99
+ spawnOptions: settled,
100
+ };
101
+ };
102
+ // The peek must stay in the same synchronous block as the first open: a
103
+ // Cassette-backed factory claims Agents in open order (ADR-0013).
104
+ const recorded = deps.factory.recordedSpawn?.(
105
+ openRequest({
106
+ name,
107
+ cwd,
108
+ spawnOptions: request.spawnOptions,
109
+ ...extensionFields,
110
+ sessionDir: deps.sessionDir,
111
+ }),
112
+ );
113
+ // A Cassette-backed spawn adopts the recorded resolved selection and skips
114
+ // the loop, so a replayed Run emits no spawn-time fallback (ADR-0039).
115
+ const adopted =
116
+ recorded === undefined ? undefined : resolveRecordedModel({ resolution, recorded });
117
+ // The adopted outcome carries the attempts the recording already spent, so
118
+ // a later mid-Ask fallback re-resolves from that attempt index (ADR-0039).
119
+ for (const skipped of adopted?.skipped ?? []) {
120
+ history.record(skipped.reason, skipped.failedModel);
121
+ }
122
+ const { opened, spawnOptions } =
123
+ adopted === undefined
124
+ ? await resolveModel({
125
+ resolution,
126
+ agent: name,
127
+ history,
128
+ onFallback: (fallback) => {
129
+ deps.emit({ type: "model_fallback", agent: name, ...fallback });
130
+ },
131
+ attempt,
132
+ })
133
+ : await attempt(adopted.selection);
61
134
  const resolvedCwd = opened.startup.worktree?.cwd ?? cwd;
62
135
  const branch = opened.startup.worktree?.branch;
63
136
  const sessionFile = opened.startup.sessionFile;
@@ -68,6 +141,11 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
68
141
  transport: opened.transport,
69
142
  emit: deps.emit,
70
143
  spawnOptions,
144
+ // An Agent that named no candidate inherits pi's default model, so a
145
+ // failing Ask has nothing to fall back from and stays an Ask failure.
146
+ ...(spawnOptions.model === undefined
147
+ ? {}
148
+ : { modelFallback: { resolution, history, candidate: spawnOptions.model } }),
71
149
  ...(request.askDefaults === undefined ? {} : { askDefaults: request.askDefaults }),
72
150
  ...(request.definitionName === undefined ? {} : { definitionName: request.definitionName }),
73
151
  });
@@ -100,23 +178,6 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
100
178
  };
101
179
  }
102
180
 
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
181
  type MutableSpawnOverrides = { -readonly [Key in keyof SpawnOverrides]: SpawnOverrides[Key] };
121
182
 
122
183
  interface SpawnRequest {
@@ -145,6 +206,9 @@ function resolveRequest(
145
206
  ...(config.tools === undefined ? {} : { tools: config.tools }),
146
207
  ...(config.disallowedTools === undefined ? {} : { disallowedTools: config.disallowedTools }),
147
208
  ...(config.skills === undefined ? {} : { skills: config.skills }),
209
+ ...(config.configExtensions === undefined
210
+ ? {}
211
+ : { configExtensions: config.configExtensions }),
148
212
  ...(config.disallowedSkills === undefined
149
213
  ? {}
150
214
  : { disallowedSkills: config.disallowedSkills }),
@@ -206,8 +270,9 @@ interface OpenTransportOptions {
206
270
  readonly name: string;
207
271
  readonly cwd: string;
208
272
  readonly spawnOptions: ResolvedSpawnOptions;
273
+ readonly resolvedExtensionPaths?: readonly string[];
274
+ readonly declaredExtensions?: readonly string[];
209
275
  readonly sessionDir: string | undefined;
210
- readonly programFile: string | undefined;
211
276
  }
212
277
 
213
278
  interface OpenedTransport {
@@ -218,44 +283,19 @@ interface OpenedTransport {
218
283
  async function openTransport(options: OpenTransportOptions): Promise<OpenedTransport> {
219
284
  const startup: TransportStartup = {};
220
285
  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
286
  const transport = await options.factory.open(
229
- {
230
- cwd: options.cwd,
287
+ openRequest({
231
288
  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
289
+ cwd: options.cwd,
290
+ spawnOptions: options.spawnOptions,
291
+ ...(options.resolvedExtensionPaths === undefined
250
292
  ? {}
251
- : { skills: options.spawnOptions.skills }),
252
- ...(options.spawnOptions.disallowedSkills === undefined
293
+ : { resolvedExtensionPaths: options.resolvedExtensionPaths }),
294
+ ...(options.declaredExtensions === undefined
253
295
  ? {}
254
- : { disallowedSkills: options.spawnOptions.disallowedSkills }),
255
- ...(resolvedExtensionPaths === undefined ? {} : { resolvedExtensionPaths }),
256
- ...(options.spawnOptions.worktree === true ? { worktree: true } : {}),
257
- ...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
258
- },
296
+ : { declaredExtensions: options.declaredExtensions }),
297
+ sessionDir: options.sessionDir,
298
+ }),
259
299
  (report) => Object.assign(startup, report),
260
300
  );
261
301
  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. */