@yaag/extension 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/package.json +4 -4
- package/src/record/run-registry.ts +30 -10
- package/src/record/run-restore.ts +9 -2
- package/src/record/run-settle-record.ts +67 -0
- package/src/record/run-summary-parse.ts +46 -0
- package/src/tool/describe-tool.ts +6 -0
- package/src/tool/stop-tool.ts +38 -10
- package/src/tool/system-prompt-append.ts +1 -1
- package/src/view/run-details.ts +33 -3
package/README.md
CHANGED
|
@@ -138,6 +138,8 @@ export default defineRun({
|
|
|
138
138
|
run: async (ctx) => {
|
|
139
139
|
const agent = await ctx.spawn({
|
|
140
140
|
name: "reviewer",
|
|
141
|
+
model: ["anthropic/claude-opus-4:medium", "anthropic/claude-haiku-4"],
|
|
142
|
+
thinking: (model) => (model.includes("haiku") ? "low" : "high"),
|
|
141
143
|
tools: ["read", "grep"],
|
|
142
144
|
disallowedTools: ["yaag_run"],
|
|
143
145
|
skills: ["review"],
|
|
@@ -173,6 +175,14 @@ is recoverable and the Handle can be asked again. `wrapUpPrompt` replaces the
|
|
|
173
175
|
default steering message. This is intentionally different from `timeoutMs`, the
|
|
174
176
|
destructive fallback that rejects with `ASK_TIMEOUT` and closes the Agent.
|
|
175
177
|
|
|
178
|
+
`model` takes one pattern, an ordered list, or a function of the failures so
|
|
179
|
+
far; `thinking` takes a level or a function of the settled model. A pattern can
|
|
180
|
+
end with a thinking suffix (`"opus-5:medium"`), and the suffix wins over
|
|
181
|
+
`thinking`. The fallback rules — trigger classes, retry, termination — are
|
|
182
|
+
stated on the types in `<program dir>/.yaag/types/runtime/index.d.ts` and in
|
|
183
|
+
the root [`README.md`](../../README.md#models-and-fallback); the sequences are
|
|
184
|
+
in [`../../docs/architecture.md`](../../docs/architecture.md) §4 and §6.
|
|
185
|
+
|
|
176
186
|
The tool call:
|
|
177
187
|
|
|
178
188
|
```json
|
|
@@ -231,3 +241,6 @@ Provided commands: `/yaag-status`, `/yaag-setup-workspace [dir]`.
|
|
|
231
241
|
- [`../../docs/architecture.md`](../../docs/architecture.md) — the diagrams
|
|
232
242
|
- [`../../docs/adr/`](../../docs/adr) — why it is built this way
|
|
233
243
|
- [ADR-0018: foreign workspaces use CLI aliases and vendored types](../../docs/adr/0018-foreign-workspaces-use-cli-aliases-and-vendored-types.md) — workspace setup and foreign-program support
|
|
244
|
+
- [ADR-0037: model resolution triggers are read from pi's stderr diagnostic](../../docs/adr/0037-model-resolution-triggers-are-read-from-pis-stderr-diagnostic.md) — which failures start a fallback
|
|
245
|
+
- [ADR-0038: a mid-Ask model swap is a yaag-side match, then `set_model`](../../docs/adr/0038-a-mid-ask-model-swap-is-a-yaag-side-match-then-set-model.md) — how a live Agent changes model
|
|
246
|
+
- [ADR-0039: replay adopts the recorded resolved model](../../docs/adr/0039-replay-adopts-the-recorded-resolved-model.md) — why a replay skips the resolution loop
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yaag/extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@earendil-works/pi-tui": "^0.84.0",
|
|
27
|
-
"@yaag/cli": "0.
|
|
28
|
-
"@yaag/runtime": "0.
|
|
29
|
-
"@yaag/tui": "0.
|
|
27
|
+
"@yaag/cli": "0.7.0",
|
|
28
|
+
"@yaag/runtime": "0.7.0",
|
|
29
|
+
"@yaag/tui": "0.7.0",
|
|
30
30
|
"nanoid": "^6.0.1"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RunSummary, RunOutcome as SummaryOutcome } from "@yaag/runtime";
|
|
2
2
|
import type { ProcessIdentity, RunOutcome } from "../process/index.ts";
|
|
3
3
|
import { pickInlineResumeSource } from "./resume-source.ts";
|
|
4
4
|
import { type RunLaunch, type RunRecord, startedRecord } from "./run-record.ts";
|
|
5
5
|
import { restoreRecords } from "./run-restore.ts";
|
|
6
|
+
import { failedSummary, settledRecord } from "./run-settle-record.ts";
|
|
6
7
|
import type { RunStore } from "./run-store.ts";
|
|
7
8
|
|
|
8
9
|
export type { RunLaunch } from "./run-record.ts";
|
|
@@ -148,8 +149,30 @@ export class RunRegistry {
|
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
152
|
+
* Persists the settled form of a Run read back from disk, and refreshes the
|
|
153
|
+
* retained entry so the next snapshot of this session shows the verdict.
|
|
154
|
+
*
|
|
155
|
+
* Only a `restored` Run settles here: a live Run settles through its own
|
|
156
|
+
* outcome, and a finished Run is history (ADR-0036), so either is refused
|
|
157
|
+
* rather than written over. A Run this session never listed stays unlisted —
|
|
158
|
+
* it is written, but not adopted.
|
|
159
|
+
*/
|
|
160
|
+
async settleRestored(record: RunRecord, outcome: SummaryOutcome): Promise<RestoredRun> {
|
|
161
|
+
const known = this.#runs.get(record.id);
|
|
162
|
+
if (known !== undefined && known.state !== "restored")
|
|
163
|
+
throw new Error(`RunRegistry: Run ${record.id} is ${known.state}, not a restored Run`);
|
|
164
|
+
const settled = settledRecord({ record, outcome, now: this.#now() });
|
|
165
|
+
this.#records.set(settled.id, settled);
|
|
166
|
+
if (known !== undefined)
|
|
167
|
+
this.#runs.set(settled.id, { state: "restored", run: restored(settled) });
|
|
168
|
+
await this.#store?.save(settled);
|
|
169
|
+
return restored(settled);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `restoreRecords` already settled every stale `live` record, and every
|
|
174
|
+
* `orphaned` record whose process is gone, so this check can never adopt a
|
|
175
|
+
* Run as live (ADR-0036).
|
|
153
176
|
*/
|
|
154
177
|
#adoptable(record: RunRecord): boolean {
|
|
155
178
|
if (record.state === "orphaned" || record.state === "interrupted") return true;
|
|
@@ -228,7 +251,10 @@ export class RunRegistry {
|
|
|
228
251
|
reject(id: string, reason: unknown): void {
|
|
229
252
|
const record = this.#runs.get(id);
|
|
230
253
|
if (record === undefined || record.state !== "live") return;
|
|
231
|
-
this.#settle(id, failedSummary(record.run.summary), {
|
|
254
|
+
this.#settle(id, failedSummary(record.run.summary, this.#now().getTime()), {
|
|
255
|
+
kind: "rejected",
|
|
256
|
+
reason,
|
|
257
|
+
});
|
|
232
258
|
}
|
|
233
259
|
|
|
234
260
|
/**
|
|
@@ -310,9 +336,3 @@ function persistedOutcome(outcome: RunSettlement): RunRecord["outcome"] {
|
|
|
310
336
|
function errorMessage(reason: unknown): string {
|
|
311
337
|
return reason instanceof Error ? reason.message : String(reason);
|
|
312
338
|
}
|
|
313
|
-
|
|
314
|
-
function failedSummary(summary: RunSummary): EndedRunSummary {
|
|
315
|
-
if (summary.runState === "ended")
|
|
316
|
-
return summary.ok ? { ...summary, outcome: "failed", ok: false } : summary;
|
|
317
|
-
return { ...summary, runState: "ended", outcome: "failed", ok: false, endedAt: null };
|
|
318
|
-
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isProcessAlive, type ProcessIdentity } from "../process/index.ts";
|
|
2
2
|
import type { RunRecord } from "./run-record.ts";
|
|
3
|
+
import { settledRecord } from "./run-settle-record.ts";
|
|
3
4
|
import type { RunStore } from "./run-store.ts";
|
|
4
5
|
|
|
5
6
|
/** Decides whether the process a record names is still running. */
|
|
@@ -19,6 +20,11 @@ export interface RestoreOptions {
|
|
|
19
20
|
* gone: its child is dead and the Run is `interrupted`, or its child still runs
|
|
20
21
|
* without an owner and the Run is `orphaned`. Both re-writes are persisted, so
|
|
21
22
|
* the verdict survives this session too (ticket 02).
|
|
23
|
+
*
|
|
24
|
+
* An `orphaned` record is probed again: its child can have ended since the
|
|
25
|
+
* session that orphaned it, and a dead one settles to `interrupted` with an end
|
|
26
|
+
* instant and an ended Summary. An orphan that still runs is left as it is, so
|
|
27
|
+
* a session start does not rewrite every record.
|
|
22
28
|
*/
|
|
23
29
|
export async function restoreRecords(options: RestoreOptions): Promise<readonly RunRecord[]> {
|
|
24
30
|
const isAlive = options.isAlive ?? isProcessAlive;
|
|
@@ -33,11 +39,12 @@ async function settleStale(
|
|
|
33
39
|
now: () => Date,
|
|
34
40
|
store: RunStore,
|
|
35
41
|
): Promise<RunRecord> {
|
|
36
|
-
if (record.state !== "live") return record;
|
|
42
|
+
if (record.state !== "live" && record.state !== "orphaned") return record;
|
|
37
43
|
const alive = record.process !== null && isAlive(record.process);
|
|
44
|
+
if (alive && record.state === "orphaned") return record;
|
|
38
45
|
const settled: RunRecord = alive
|
|
39
46
|
? { ...record, state: "orphaned" }
|
|
40
|
-
: {
|
|
47
|
+
: settledRecord({ record, outcome: "interrupted", now: now() });
|
|
41
48
|
await store.save(settled);
|
|
42
49
|
return settled;
|
|
43
50
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { EndedRunSummary, RunOutcome, RunSummary } from "@yaag/runtime";
|
|
2
|
+
import type { RunRecord } from "./run-record.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Marks a folded Summary ended, keeping every accounting fact it holds.
|
|
6
|
+
*
|
|
7
|
+
* An already-ended fold is history and is returned unchanged: a Run keeps the
|
|
8
|
+
* first outcome observed for it, the invariant the registry keeps when it
|
|
9
|
+
* retains a settlement. `endedAt` stays null because it is the `run_end.at`
|
|
10
|
+
* event fact and no such event was seen.
|
|
11
|
+
*/
|
|
12
|
+
export function endedSummary(
|
|
13
|
+
summary: RunSummary,
|
|
14
|
+
outcome: RunOutcome,
|
|
15
|
+
endedAtMs: number,
|
|
16
|
+
): EndedRunSummary {
|
|
17
|
+
if (summary.runState === "ended") return summary;
|
|
18
|
+
return {
|
|
19
|
+
...summary,
|
|
20
|
+
runState: "ended",
|
|
21
|
+
outcome,
|
|
22
|
+
ok: outcome === "completed",
|
|
23
|
+
endedAt: null,
|
|
24
|
+
durationMs: durationOf(summary, endedAtMs),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The fold never synthesizes a duration from a clock (architecture §8), so the
|
|
30
|
+
* wall-clock value is supplied here, once, by the caller that settles the Run.
|
|
31
|
+
* Without it a settled Run reads `0s` instead of how long it really ran.
|
|
32
|
+
*/
|
|
33
|
+
function durationOf(summary: RunSummary, endedAtMs: number): number {
|
|
34
|
+
if (summary.durationMs !== 0) return summary.durationMs;
|
|
35
|
+
if (summary.startedAt === null) return 0;
|
|
36
|
+
return Math.max(0, endedAtMs - summary.startedAt);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Marks a fold ended as failed, for a Run whose child rejected. */
|
|
40
|
+
export function failedSummary(summary: RunSummary, endedAtMs: number): EndedRunSummary {
|
|
41
|
+
if (summary.runState === "ended")
|
|
42
|
+
return summary.ok ? { ...summary, outcome: "failed", ok: false } : summary;
|
|
43
|
+
return endedSummary(summary, "failed", endedAtMs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The settled form of a record no process is attached to any more.
|
|
48
|
+
*
|
|
49
|
+
* The persisted state is `interrupted`: no new state member is added, so an
|
|
50
|
+
* older yaag still reads the record. The verdict lives in the Summary fold —
|
|
51
|
+
* `stopped` when yaag_stop killed the group, `interrupted` when the process was
|
|
52
|
+
* already gone. The persisted outcome stays null, because the Run gave no exit
|
|
53
|
+
* code and no rejection reason.
|
|
54
|
+
*/
|
|
55
|
+
export function settledRecord(options: {
|
|
56
|
+
readonly record: RunRecord;
|
|
57
|
+
readonly outcome: RunOutcome;
|
|
58
|
+
readonly now: Date;
|
|
59
|
+
}): RunRecord {
|
|
60
|
+
const { record, outcome, now } = options;
|
|
61
|
+
return {
|
|
62
|
+
...record,
|
|
63
|
+
state: "interrupted",
|
|
64
|
+
endedAt: now.toISOString(),
|
|
65
|
+
summary: endedSummary(record.summary, outcome, now.getTime()),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AgentActivity,
|
|
3
3
|
AgentInfo,
|
|
4
4
|
AgentState,
|
|
5
|
+
ModelFallbackInfo,
|
|
5
6
|
NodeInfo,
|
|
6
7
|
NodeState,
|
|
7
8
|
RunOutcome,
|
|
@@ -56,6 +57,7 @@ interface SummaryBase {
|
|
|
56
57
|
readonly incomplete: boolean;
|
|
57
58
|
readonly durationMs: number;
|
|
58
59
|
readonly worstFrameGapMs: number;
|
|
60
|
+
readonly modelFallbacks: number;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
function parseBase(stored: Record<string, unknown>): SummaryBase | null {
|
|
@@ -88,6 +90,9 @@ function parseBase(stored: Record<string, unknown>): SummaryBase | null {
|
|
|
88
90
|
incomplete: stored.incomplete,
|
|
89
91
|
durationMs: stored.durationMs,
|
|
90
92
|
worstFrameGapMs: stored.worstFrameGapMs,
|
|
93
|
+
// Tolerant, unlike the strict fields above: a record written before the
|
|
94
|
+
// fallback counter existed must still load instead of disappearing.
|
|
95
|
+
modelFallbacks: typeof stored.modelFallbacks === "number" ? stored.modelFallbacks : 0,
|
|
91
96
|
};
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -110,7 +115,9 @@ function parseAgent(value: unknown): AgentInfo | null {
|
|
|
110
115
|
const nodes = parseNodes(stored.nodes);
|
|
111
116
|
const tokens = parseTokens(stored.tokens);
|
|
112
117
|
const activity = parseActivity(stored.activity);
|
|
118
|
+
const fallbacks = parseFallbacks(stored.modelFallbacks);
|
|
113
119
|
if (
|
|
120
|
+
fallbacks === null ||
|
|
114
121
|
nodes === null ||
|
|
115
122
|
tokens === undefined ||
|
|
116
123
|
activity === undefined ||
|
|
@@ -142,6 +149,10 @@ function parseAgent(value: unknown): AgentInfo | null {
|
|
|
142
149
|
askStartedAt: stored.askStartedAt,
|
|
143
150
|
nodes,
|
|
144
151
|
finishedNodesPruned: stored.finishedNodesPruned,
|
|
152
|
+
modelFallbacks: fallbacks,
|
|
153
|
+
// Tolerant for the same reason as the Run-level counter.
|
|
154
|
+
modelFallbacksPruned:
|
|
155
|
+
typeof stored.modelFallbacksPruned === "number" ? stored.modelFallbacksPruned : 0,
|
|
145
156
|
};
|
|
146
157
|
if (state === "asking") {
|
|
147
158
|
if (typeof stored.askIndex !== "number") return null;
|
|
@@ -189,6 +200,37 @@ function parseNodes(value: unknown): NodeInfo[] | null {
|
|
|
189
200
|
return nodes;
|
|
190
201
|
}
|
|
191
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The per-Agent fallback table: `[]` when a record predates it, `null` when the
|
|
205
|
+
* stored value is malformed.
|
|
206
|
+
*/
|
|
207
|
+
function parseFallbacks(value: unknown): ModelFallbackInfo[] | null {
|
|
208
|
+
if (value === undefined) return [];
|
|
209
|
+
if (!Array.isArray(value)) return null;
|
|
210
|
+
const fallbacks: ModelFallbackInfo[] = [];
|
|
211
|
+
for (const entry of value) {
|
|
212
|
+
const stored = asRecord(entry);
|
|
213
|
+
if (
|
|
214
|
+
stored === null ||
|
|
215
|
+
typeof stored.failedModel !== "string" ||
|
|
216
|
+
typeof stored.resolvedModel !== "string" ||
|
|
217
|
+
typeof stored.attempt !== "number" ||
|
|
218
|
+
!isReason(stored.reason) ||
|
|
219
|
+
!isNullableNumber(stored.at)
|
|
220
|
+
) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
fallbacks.push({
|
|
224
|
+
failedModel: stored.failedModel,
|
|
225
|
+
resolvedModel: stored.resolvedModel,
|
|
226
|
+
attempt: stored.attempt,
|
|
227
|
+
reason: stored.reason,
|
|
228
|
+
at: stored.at,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return fallbacks;
|
|
232
|
+
}
|
|
233
|
+
|
|
192
234
|
/**
|
|
193
235
|
* `undefined` means the stored value is malformed or missing; a stored `null`
|
|
194
236
|
* is an Agent or Node whose usage was never reported.
|
|
@@ -250,6 +292,10 @@ function isAgentState(value: unknown): value is AgentState {
|
|
|
250
292
|
return value === "idle" || value === "asking" || value === "exited";
|
|
251
293
|
}
|
|
252
294
|
|
|
295
|
+
function isReason(value: unknown): value is ModelFallbackInfo["reason"] {
|
|
296
|
+
return value === "not_found" || value === "auth" || value === "rate_limited";
|
|
297
|
+
}
|
|
298
|
+
|
|
253
299
|
function isNodeState(value: unknown): value is NodeState {
|
|
254
300
|
return value === "running" || value === "exited" || value === "failed";
|
|
255
301
|
}
|
|
@@ -20,6 +20,12 @@ const DESCRIPTION = [
|
|
|
20
20
|
"handle.ask supports maxTurns, maxToolCalls, maxDurationMs, and wrapUpPrompt as recoverable",
|
|
21
21
|
"ASK_LIMIT controls. These differ from timeoutMs, the destructive Agent-killing fallback.",
|
|
22
22
|
"",
|
|
23
|
+
"model takes one pattern, an ordered list of patterns, or a function of the failures so far;",
|
|
24
|
+
"thinking takes a level or a function of the settled model. A pattern can end with a thinking",
|
|
25
|
+
'suffix ("opus-5:medium"), which wins over thinking. yaag falls back only on not_found, auth,',
|
|
26
|
+
"and rate_limited, at spawn and inside an Ask. Exhausted candidates fail with",
|
|
27
|
+
"MODEL_RESOLUTION_FAILED.",
|
|
28
|
+
"",
|
|
23
29
|
"Describing imports the program and executes its module top level. Programs should keep",
|
|
24
30
|
"module top level side-effect free; use this only for reviewed, user-authored files.",
|
|
25
31
|
].join("\n");
|
package/src/tool/stop-tool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { RunSummary } from "@yaag/runtime";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { isProcessAlive } from "../process/index.ts";
|
|
4
|
+
import { isProcessAlive, type ProcessIdentity } from "../process/index.ts";
|
|
5
5
|
import type { RestoredRun, RunRegistry } from "../record/index.ts";
|
|
6
6
|
import { toUsage } from "../view/index.ts";
|
|
7
7
|
|
|
@@ -21,9 +21,22 @@ const DESCRIPTION = [
|
|
|
21
21
|
"it spent before it stopped.",
|
|
22
22
|
"",
|
|
23
23
|
"An orphaned Run left behind by an earlier Host Session is stopped too: its",
|
|
24
|
-
"process group is killed, and the report holds its last
|
|
24
|
+
"process group is killed, its record is settled, and the report holds its last",
|
|
25
|
+
"persisted Summary. If its process already ended, the record is settled too and",
|
|
26
|
+
"the report says the Run was no longer running.",
|
|
25
27
|
].join("\n");
|
|
26
28
|
|
|
29
|
+
/** Seams the tests replace, so no test kills a real process group. */
|
|
30
|
+
export interface StopToolOptions {
|
|
31
|
+
readonly isAlive?: (identity: ProcessIdentity) => boolean;
|
|
32
|
+
readonly kill?: (pid: number) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface StopDeps {
|
|
36
|
+
readonly isAlive: (identity: ProcessIdentity) => boolean;
|
|
37
|
+
readonly kill: (pid: number) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/** What a stopped Run got through and what it cost, from the fold (ADR-0006). */
|
|
28
41
|
export function stopReport(id: string, summary: RunSummary): string {
|
|
29
42
|
const agents = Object.keys(summary.agents).length;
|
|
@@ -71,18 +84,28 @@ function agentStateReport(agent: RunSummary["agents"][string]): string {
|
|
|
71
84
|
* pipes belong to a dead Host Session — so the process group is killed outright
|
|
72
85
|
* (ADR-0008). The pid is re-probed first, because a record can name a pid that
|
|
73
86
|
* a different process has since inherited (ticket 02).
|
|
87
|
+
*
|
|
88
|
+
* The record is settled either way: a process that already ended is not an
|
|
89
|
+
* error, it only means the verdict is `interrupted` instead of `stopped`.
|
|
74
90
|
*/
|
|
75
|
-
function stopOrphan(
|
|
91
|
+
async function stopOrphan(
|
|
92
|
+
run: RestoredRun,
|
|
93
|
+
registry: RunRegistry,
|
|
94
|
+
deps: StopDeps,
|
|
95
|
+
): Promise<AgentToolResult<StopDetails>> {
|
|
76
96
|
const { record } = run;
|
|
77
97
|
if (record.state !== "orphaned" || record.process === null)
|
|
78
98
|
throw new Error(`yaag_stop: Run ${record.id} has already finished`);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
99
|
+
const alive = deps.isAlive(record.process);
|
|
100
|
+
if (alive) deps.kill(record.process.pid);
|
|
101
|
+
const settled = await registry.settleRestored(record, alive ? "stopped" : "interrupted");
|
|
102
|
+
const note = alive
|
|
103
|
+
? ""
|
|
104
|
+
: `Run ${record.id} was no longer running; its record is settled as interrupted.\n`;
|
|
82
105
|
return {
|
|
83
|
-
content: [{ type: "text", text: stopReport(record.id,
|
|
84
|
-
details: { summary:
|
|
85
|
-
usage: toUsage(
|
|
106
|
+
content: [{ type: "text", text: `${note}${stopReport(record.id, settled.summary)}` }],
|
|
107
|
+
details: { summary: settled.summary },
|
|
108
|
+
usage: toUsage(settled.summary),
|
|
86
109
|
};
|
|
87
110
|
}
|
|
88
111
|
|
|
@@ -111,7 +134,12 @@ function toError(reason: unknown): Error {
|
|
|
111
134
|
*/
|
|
112
135
|
export function createStopTool(
|
|
113
136
|
registry: RunRegistry,
|
|
137
|
+
options: StopToolOptions = {},
|
|
114
138
|
): ToolDefinition<typeof parameters, StopDetails> {
|
|
139
|
+
const deps: StopDeps = {
|
|
140
|
+
isAlive: options.isAlive ?? isProcessAlive,
|
|
141
|
+
kill: options.kill ?? killGroup,
|
|
142
|
+
};
|
|
115
143
|
return {
|
|
116
144
|
name: "yaag_stop",
|
|
117
145
|
label: "Stop Run",
|
|
@@ -123,7 +151,7 @@ export function createStopTool(
|
|
|
123
151
|
case "unknown":
|
|
124
152
|
throw new Error(`yaag_stop: no Run with id ${params.id}`);
|
|
125
153
|
case "restored":
|
|
126
|
-
return stopOrphan(status.run);
|
|
154
|
+
return await stopOrphan(status.run, registry, deps);
|
|
127
155
|
case "finished":
|
|
128
156
|
throw new Error(`yaag_stop: Run ${params.id} has already finished`);
|
|
129
157
|
case "live": {
|
|
@@ -24,7 +24,7 @@ export function yaagPromptBlock(directories: readonly string[]): string {
|
|
|
24
24
|
"});",
|
|
25
25
|
"```",
|
|
26
26
|
"",
|
|
27
|
-
"Full authoring surface (defineAgent, args schemas, ask limits, worktrees):",
|
|
27
|
+
"Full authoring surface (defineAgent, args schemas, ask limits, worktrees, model fallback):",
|
|
28
28
|
"read `<program dir>/.yaag/types/runtime/index.d.ts`.",
|
|
29
29
|
].join("\n");
|
|
30
30
|
}
|
package/src/view/run-details.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AgentActivity,
|
|
3
3
|
AgentInfo,
|
|
4
4
|
LifecycleEvent,
|
|
5
|
+
ModelFallbackInfo,
|
|
5
6
|
NodeInfo,
|
|
6
7
|
RunSummary,
|
|
7
8
|
TokenBreakdown,
|
|
@@ -58,7 +59,8 @@ function isSummaryBase(value: Record<string, unknown>): boolean {
|
|
|
58
59
|
nullableTokens(value.tokens) &&
|
|
59
60
|
typeof value.incomplete === "boolean" &&
|
|
60
61
|
natural(value.durationMs) &&
|
|
61
|
-
natural(value.worstFrameGapMs)
|
|
62
|
+
natural(value.worstFrameGapMs) &&
|
|
63
|
+
(value.modelFallbacks === undefined || natural(value.modelFallbacks))
|
|
62
64
|
);
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -78,9 +80,11 @@ function normalizeAgents(value: RunSummary): RunSummary {
|
|
|
78
80
|
...agent,
|
|
79
81
|
nodes: agent.nodes ?? [],
|
|
80
82
|
finishedNodesPruned: agent.finishedNodesPruned ?? 0,
|
|
83
|
+
modelFallbacks: agent.modelFallbacks ?? [],
|
|
84
|
+
modelFallbacksPruned: agent.modelFallbacksPruned ?? 0,
|
|
81
85
|
};
|
|
82
86
|
}
|
|
83
|
-
return { ...value, agents };
|
|
87
|
+
return { ...value, agents, modelFallbacks: value.modelFallbacks ?? 0 };
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
function optionalNodes(value: unknown): boolean {
|
|
@@ -103,6 +107,24 @@ function isNodeState(value: unknown): boolean {
|
|
|
103
107
|
return value === "running" || value === "exited" || value === "failed";
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
function optionalFallbacks(value: unknown): boolean {
|
|
111
|
+
return value === undefined || (Array.isArray(value) && value.every(isFallback));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isFallback(value: unknown): value is ModelFallbackInfo {
|
|
115
|
+
return (
|
|
116
|
+
isRecord(value) &&
|
|
117
|
+
strings(value.failedModel, value.resolvedModel) &&
|
|
118
|
+
isReason(value.reason) &&
|
|
119
|
+
natural(value.attempt) &&
|
|
120
|
+
nullableNumber(value.at)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isReason(value: unknown): boolean {
|
|
125
|
+
return value === "not_found" || value === "auth" || value === "rate_limited";
|
|
126
|
+
}
|
|
127
|
+
|
|
106
128
|
function optionalNodeUsage(value: unknown): boolean {
|
|
107
129
|
if (value === undefined) return true;
|
|
108
130
|
if (!isRecord(value)) return false;
|
|
@@ -142,7 +164,9 @@ function isAgentBase(value: Record<string, unknown>): boolean {
|
|
|
142
164
|
nullableNumber(value.usageUpdatedAt) &&
|
|
143
165
|
nullableNumber(value.askStartedAt) &&
|
|
144
166
|
optionalNodes(value.nodes) &&
|
|
145
|
-
(value.finishedNodesPruned === undefined || natural(value.finishedNodesPruned))
|
|
167
|
+
(value.finishedNodesPruned === undefined || natural(value.finishedNodesPruned)) &&
|
|
168
|
+
optionalFallbacks(value.modelFallbacks) &&
|
|
169
|
+
(value.modelFallbacksPruned === undefined || natural(value.modelFallbacksPruned))
|
|
146
170
|
);
|
|
147
171
|
}
|
|
148
172
|
|
|
@@ -191,6 +215,12 @@ function isEvent(value: unknown): value is LifecycleEvent {
|
|
|
191
215
|
typeof value.ok === "boolean" &&
|
|
192
216
|
(value.maxFrameGapMs === undefined || natural(value.maxFrameGapMs))
|
|
193
217
|
);
|
|
218
|
+
case "model_fallback":
|
|
219
|
+
return (
|
|
220
|
+
strings(value.agent, value.failedModel, value.resolvedModel) &&
|
|
221
|
+
natural(value.attempt) &&
|
|
222
|
+
isReason(value.reason)
|
|
223
|
+
);
|
|
194
224
|
case "agent_usage":
|
|
195
225
|
return strings(value.agent) && isTokens(value.tokens) && number(value.cost);
|
|
196
226
|
case "agent_exit":
|