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