@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.
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -2
- package/src/agent/define-agent.ts +20 -3
- package/src/agent/spawn-extensions.ts +97 -0
- package/src/agent/spawn-request.ts +70 -0
- package/src/agent/spawn.ts +102 -62
- package/src/ask/ask-exchange-events.ts +10 -1
- package/src/ask/ask-exchange-options.ts +13 -0
- package/src/ask/ask-exchange.ts +77 -9
- package/src/ask/index.ts +1 -0
- package/src/cassette/cassette-publish.ts +5 -1
- package/src/cassette/cassette-replay.ts +23 -4
- package/src/cassette/cassette-schema.ts +1 -0
- package/src/cassette/cassette.ts +8 -0
- package/src/cassette/recording-transport.ts +7 -0
- package/src/cassette/replay-divergence.ts +86 -20
- package/src/cassette/replay-transport.ts +8 -1
- package/src/cassette/resume-transport.ts +14 -1
- package/src/config/config-file.ts +67 -0
- package/src/config/config-issues.ts +31 -0
- package/src/config/config-paths.ts +38 -0
- package/src/config/config-schema.ts +25 -0
- package/src/config/effective-config.ts +116 -0
- package/src/config/index.ts +22 -0
- package/src/errors.ts +56 -2
- package/src/events.ts +19 -0
- package/src/extension/extension-paths.ts +15 -6
- package/src/index.ts +14 -0
- package/src/model/index.ts +18 -0
- package/src/model/model-error-history.ts +36 -0
- package/src/model/model-failure.ts +78 -0
- package/src/model/model-fallback.ts +15 -0
- package/src/model/model-match.ts +99 -0
- package/src/model/model-resolution.ts +44 -6
- package/src/model/model-swap.ts +81 -0
- package/src/model/recorded-resolution.ts +61 -0
- package/src/model/resolution-loop.ts +115 -0
- package/src/run/run.ts +8 -0
- package/src/summary/index.ts +1 -0
- package/src/summary/summary-agent.ts +9 -1
- package/src/summary/summary-fallbacks.ts +50 -0
- package/src/summary/summary.ts +12 -0
- package/src/transport/fake-transport.ts +57 -1
- package/src/transport/index.ts +4 -0
- package/src/transport/live-transport.ts +37 -4
- package/src/transport/stderr-tail.ts +32 -0
- package/src/transport/transport.ts +16 -0
- package/src/types.ts +33 -5
- package/src/wire-constants.ts +3 -0
package/package.json
CHANGED
package/src/agent/agent.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/src/agent/spawn.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
spawnOptions,
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
: {
|
|
252
|
-
...(options.
|
|
293
|
+
: { resolvedExtensionPaths: options.resolvedExtensionPaths }),
|
|
294
|
+
...(options.declaredExtensions === undefined
|
|
253
295
|
? {}
|
|
254
|
-
: {
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
/**
|
|
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. */
|