@nredd/pi-subagents 0.22.0-nredd.6 → 0.22.0-nredd.8
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 +160 -43
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +418 -118
- package/src/agent-runner.ts +155 -62
- package/src/cross-extension-rpc.ts +11 -36
- package/src/custom-agents.ts +9 -5
- package/src/default-agents.ts +2 -4
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +5 -5
- package/src/home-paths.ts +27 -0
- package/src/index.ts +231 -107
- package/src/invocation-config.ts +8 -35
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +9 -5
- package/src/model-resolver.ts +41 -11
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +159 -0
- package/src/model-tiers.ts +171 -0
- package/src/nested-tools.ts +31 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +346 -0
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +41 -19
- package/src/settings.ts +111 -8
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +33 -0
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +15 -4
- package/src/ui/conversation-viewer.ts +6 -3
- package/src/ui/fallback-flag.ts +90 -0
- package/src/ui/fleet-list.ts +363 -53
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +18 -2
- package/src/ui/workflow-card.ts +5 -1
- package/src/ui/workflow-dialog.ts +10 -0
- package/src/workflow/host.ts +43 -45
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/progress.ts +4 -0
- package/src/workflow/runtime.ts +25 -8
- package/src/workflow/saved.ts +19 -17
- package/src/workflow/task.ts +10 -0
- package/src/workflow/worker-source.ts +156 -38
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Session-journal authority for bounded fleet alerts and settled disclosure choices. */
|
|
2
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export const FLEET_STATE_ENTRY = "subagents:fleet-state";
|
|
5
|
+
export const FLEET_STATE_VERSION = 1;
|
|
6
|
+
export const MAX_FLEET_ALERT_DETAILS = 128;
|
|
7
|
+
export const MAX_COMPACT_ALERT_COUNT = 99;
|
|
8
|
+
export const MAX_ALERT_ID_LENGTH = 160;
|
|
9
|
+
export const MAX_ALERT_TYPE_LENGTH = 80;
|
|
10
|
+
export const MAX_ALERT_DESCRIPTION_LENGTH = 240;
|
|
11
|
+
const ALERT_FIELD_COUNT = 5;
|
|
12
|
+
|
|
13
|
+
export type FailureKind = "error" | "aborted" | "failed" | "killed";
|
|
14
|
+
/** No AgentRecord, result, session, promise or usage references may cross this boundary. */
|
|
15
|
+
export interface FleetAlert {
|
|
16
|
+
id: string;
|
|
17
|
+
type: string;
|
|
18
|
+
description: string;
|
|
19
|
+
state: FailureKind;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
export interface FleetSnapshot {
|
|
23
|
+
version: typeof FLEET_STATE_VERSION;
|
|
24
|
+
collapsed: boolean;
|
|
25
|
+
alerts: FleetAlert[];
|
|
26
|
+
overflow: Record<FailureKind, number>;
|
|
27
|
+
/** Identity history lives in the existing journal, not an unbounded UI set. */
|
|
28
|
+
transition?: { alert: FleetAlert; acknowledged: boolean };
|
|
29
|
+
}
|
|
30
|
+
export interface FleetJournalEntry { type: string; customType?: string; data?: unknown }
|
|
31
|
+
const KINDS: readonly FailureKind[] = ["error", "aborted", "failed", "killed"];
|
|
32
|
+
const emptyOverflow = (): Record<FailureKind, number> => ({ error: 0, aborted: 0, failed: 0, killed: 0 });
|
|
33
|
+
|
|
34
|
+
/** Validate whole checkpoints; corrupt/unknown entries cannot partially replace valid state. */
|
|
35
|
+
function snapshot(data: unknown): FleetSnapshot | undefined {
|
|
36
|
+
if (!data || typeof data !== "object") return undefined;
|
|
37
|
+
const value = data as FleetSnapshot;
|
|
38
|
+
if (value.version !== FLEET_STATE_VERSION || typeof value.collapsed !== "boolean") return undefined;
|
|
39
|
+
// Earlier disclosure-only entries are valid empty checkpoints.
|
|
40
|
+
if (value.alerts === undefined && value.overflow === undefined) return { version: FLEET_STATE_VERSION, collapsed: value.collapsed, alerts: [], overflow: emptyOverflow() };
|
|
41
|
+
if (!Array.isArray(value.alerts) || value.alerts.length > MAX_FLEET_ALERT_DETAILS || !value.alerts.every(validAlert)
|
|
42
|
+
|| new Set(value.alerts.map(alert => alert.id)).size !== value.alerts.length
|
|
43
|
+
|| !value.overflow || !KINDS.every(kind => Number.isSafeInteger(value.overflow[kind]) && value.overflow[kind] >= 0)
|
|
44
|
+
|| (value.transition !== undefined && (!value.transition || !validAlert(value.transition.alert) || typeof value.transition.acknowledged !== "boolean"))) return undefined;
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validAlert(value: unknown): value is FleetAlert {
|
|
49
|
+
if (!value || typeof value !== "object") return false;
|
|
50
|
+
const alert = value as FleetAlert;
|
|
51
|
+
return typeof alert.id === "string" && alert.id.length > 0 && alert.id.length <= MAX_ALERT_ID_LENGTH
|
|
52
|
+
&& typeof alert.type === "string" && alert.type.length <= MAX_ALERT_TYPE_LENGTH
|
|
53
|
+
&& typeof alert.description === "string" && alert.description.length <= MAX_ALERT_DESCRIPTION_LENGTH
|
|
54
|
+
&& Object.keys(alert).length === ALERT_FIELD_COUNT
|
|
55
|
+
&& KINDS.includes(alert.state) && Number.isFinite(alert.timestamp);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Bounded strings and plain values only, including when a detached spool supplied the input. */
|
|
59
|
+
export function fleetAlert(value: unknown): FleetAlert | undefined {
|
|
60
|
+
if (!value || typeof value !== "object") return undefined;
|
|
61
|
+
const record = value as { id?: unknown; type?: unknown; description?: unknown; status?: unknown; completedAt?: unknown; startedAt?: unknown };
|
|
62
|
+
if (typeof record.id !== "string" || !record.id || record.id.length > MAX_ALERT_ID_LENGTH || typeof record.type !== "string"
|
|
63
|
+
|| typeof record.description !== "string" || !KINDS.includes(record.status as FailureKind)) return undefined;
|
|
64
|
+
const timestamp = record.completedAt ?? record.startedAt;
|
|
65
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return undefined;
|
|
66
|
+
const clean = (text: string, limit: number) => stripTerminalSequences(text).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, limit);
|
|
67
|
+
return { id: record.id, type: clean(record.type, MAX_ALERT_TYPE_LENGTH), description: clean(record.description, MAX_ALERT_DESCRIPTION_LENGTH), state: record.status as FailureKind, timestamp };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* At most N detailed alerts plus four exact overflow counters in RAM. Each transition
|
|
72
|
+
* checkpoints bounded state. For an overflow id, scan the active branch backwards for
|
|
73
|
+
* its last transition (O(journal length) time, O(1) extra space). The host already owns
|
|
74
|
+
* that journal; no extra id/ack set, file, or global registry is retained here.
|
|
75
|
+
*/
|
|
76
|
+
export class FleetJournal {
|
|
77
|
+
collapsed = true;
|
|
78
|
+
private readonly details = new Map<string, FleetAlert>();
|
|
79
|
+
private overflow = emptyOverflow();
|
|
80
|
+
private entries: () => readonly FleetJournalEntry[] = () => [];
|
|
81
|
+
private append: ((state: FleetSnapshot) => void) | undefined;
|
|
82
|
+
private settledCollapsed = true;
|
|
83
|
+
|
|
84
|
+
restore(entries: () => readonly FleetJournalEntry[], append: (state: FleetSnapshot) => void): void {
|
|
85
|
+
this.entries = entries;
|
|
86
|
+
this.append = append;
|
|
87
|
+
this.details.clear();
|
|
88
|
+
this.overflow = emptyOverflow();
|
|
89
|
+
this.collapsed = true;
|
|
90
|
+
const branch = entries();
|
|
91
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
92
|
+
const entry = branch[i];
|
|
93
|
+
if (entry.type !== "custom" || entry.customType !== FLEET_STATE_ENTRY) continue;
|
|
94
|
+
const state = snapshot(entry.data);
|
|
95
|
+
if (!state) continue;
|
|
96
|
+
this.collapsed = state.collapsed;
|
|
97
|
+
this.overflow = { ...state.overflow };
|
|
98
|
+
for (const alert of state.alerts) this.details.set(alert.id, { ...alert });
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
this.settledCollapsed = this.collapsed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get alerts(): readonly FleetAlert[] { return [...this.details.values()]; }
|
|
105
|
+
get overflowCount(): number { return KINDS.reduce((sum, kind) => sum + this.overflow[kind], 0); }
|
|
106
|
+
get count(): number { return this.details.size + this.overflowCount; }
|
|
107
|
+
|
|
108
|
+
/** Duplicate notices and polling are not new failures; acknowledged ids stay read. */
|
|
109
|
+
report(alert: FleetAlert): void {
|
|
110
|
+
const detail = this.details.get(alert.id);
|
|
111
|
+
const previous = detail ? { alert: detail, acknowledged: false } : this.previous(alert.id);
|
|
112
|
+
if (previous && previous.alert.timestamp >= alert.timestamp) return;
|
|
113
|
+
// A resumed agent keeps its id. Replace its unread attempt, or reopen an
|
|
114
|
+
// acknowledged identity only for a newer failure, never for a stale notice.
|
|
115
|
+
if (previous && !previous.acknowledged) {
|
|
116
|
+
if (detail) this.details.set(alert.id, alert);
|
|
117
|
+
else { this.overflow[previous.alert.state]--; this.overflow[alert.state]++; }
|
|
118
|
+
} else if (this.details.size < MAX_FLEET_ALERT_DETAILS) this.details.set(alert.id, alert);
|
|
119
|
+
else this.overflow[alert.state]++;
|
|
120
|
+
this.write({ alert, acknowledged: false });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
acknowledge(id: string): void {
|
|
124
|
+
const detail = this.details.get(id);
|
|
125
|
+
const previous = detail ? { alert: detail, acknowledged: false } : this.previous(id);
|
|
126
|
+
if (!previous || previous.acknowledged) return;
|
|
127
|
+
if (!this.details.delete(id)) this.overflow[previous.alert.state]--;
|
|
128
|
+
this.write({ alert: previous.alert, acknowledged: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Debounced by the controller; a net-no-change burst writes nothing. */
|
|
132
|
+
flushDisclosure(): void {
|
|
133
|
+
if (this.collapsed !== this.settledCollapsed) this.write();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private previous(id: string): FleetSnapshot["transition"] {
|
|
137
|
+
const branch = this.entries();
|
|
138
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
139
|
+
const entry = branch[i];
|
|
140
|
+
if (entry.type !== "custom" || entry.customType !== FLEET_STATE_ENTRY) continue;
|
|
141
|
+
const data = entry.data as FleetSnapshot | undefined;
|
|
142
|
+
if (data?.version === FLEET_STATE_VERSION && data.transition?.alert?.id === id) {
|
|
143
|
+
const valid = snapshot(data);
|
|
144
|
+
if (valid) return valid.transition;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private write(transition?: FleetSnapshot["transition"]): void {
|
|
151
|
+
this.append?.({ version: FLEET_STATE_VERSION, collapsed: this.collapsed, alerts: [...this.details.values()], overflow: { ...this.overflow }, ...(transition ? { transition } : {}) });
|
|
152
|
+
this.settledCollapsed = this.collapsed;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -15,6 +15,7 @@ import { pathToFileURL } from "node:url";
|
|
|
15
15
|
import { type Component, hyperlink, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
16
16
|
import type { NotificationDetails } from "../types.js";
|
|
17
17
|
import { formatCost, formatMs, formatTokens, formatTurns, type Theme } from "./agent-widget.js";
|
|
18
|
+
import { fallbackFlag, fallbackFlagText, hasFallback } from "./fallback-flag.js";
|
|
18
19
|
|
|
19
20
|
/** Lines of the result preview shown when expanded. */
|
|
20
21
|
const EXPANDED_PREVIEW_LINES = 30;
|
|
@@ -41,7 +42,10 @@ function statParts(d: NotificationDetails, showCost: boolean): string[] {
|
|
|
41
42
|
/** `✓ desc · 12 tools · 33.8k token · ~$0.0042 · 41.2s`. */
|
|
42
43
|
export function noticeSummary(d: NotificationDetails, theme: Theme, showCost: boolean): string {
|
|
43
44
|
const dot = ` ${theme.fg("dim", "·")} `;
|
|
44
|
-
|
|
45
|
+
// Leading, before the unbounded description: the collapsed line is truncated
|
|
46
|
+
// from the right, and a substituted model must not be what gets cut.
|
|
47
|
+
const flag = fallbackFlag(theme, d) ? `${fallbackFlag(theme, d)} ` : "";
|
|
48
|
+
const head = `${icon(d, theme)} ${flag}${theme.bold(d.description)}`;
|
|
45
49
|
const stats = statParts(d, showCost).map(p => theme.fg("dim", p));
|
|
46
50
|
return [head, ...stats].join(dot);
|
|
47
51
|
}
|
|
@@ -49,7 +53,18 @@ export function noticeSummary(d: NotificationDetails, theme: Theme, showCost: bo
|
|
|
49
53
|
/** `3 agents finished · ✓ a, ✓ b, ✗ c`. */
|
|
50
54
|
export function groupSummary(all: NotificationDetails[], theme: Theme): string {
|
|
51
55
|
const names = all.map(d => `${icon(d, theme)} ${d.description}`).join(theme.fg("dim", ", "));
|
|
52
|
-
|
|
56
|
+
// In the heading, ahead of the member list that truncation eats from the end,
|
|
57
|
+
// so a fallback member listed last is still counted.
|
|
58
|
+
const groups = new Map<string, { carrier: NotificationDetails; count: number }>();
|
|
59
|
+
for (const carrier of all.filter(d => d.modelTier !== undefined || hasFallback(d))) {
|
|
60
|
+
const key = `${hasFallback(carrier)}:${fallbackFlagText(1, carrier)}`;
|
|
61
|
+
const group = groups.get(key);
|
|
62
|
+
if (group) group.count++;
|
|
63
|
+
else groups.set(key, { carrier, count: 1 });
|
|
64
|
+
}
|
|
65
|
+
const flags = [...groups.values()].map(({ carrier, count }) => fallbackFlag(theme, carrier, count)).join(" ");
|
|
66
|
+
const flag = flags ? ` ${flags}` : "";
|
|
67
|
+
return `${theme.bold(`${all.length} agents finished`)}${flag} ${theme.fg("dim", "·")} ${names}`;
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
/** The transcript as an OSC 8 hyperlink, so a click opens it. */
|
|
@@ -68,6 +83,7 @@ function detailLines(d: NotificationDetails, theme: Theme, showCost: boolean, in
|
|
|
68
83
|
stats.push(...statParts(d, showCost));
|
|
69
84
|
if (stats.length > 0) lines.push(indent + theme.fg("dim", stats.join(" · ")));
|
|
70
85
|
if (d.error) lines.push(indent + theme.fg("error", d.error));
|
|
86
|
+
if (d.modelNote) lines.push(indent + theme.fg("warning", d.modelNote));
|
|
71
87
|
for (const line of d.resultPreview.split("\n").slice(0, EXPANDED_PREVIEW_LINES)) {
|
|
72
88
|
lines.push(indent + theme.fg("dim", line));
|
|
73
89
|
}
|
package/src/ui/workflow-card.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
type WorkflowRunStatus,
|
|
47
47
|
} from "../workflow/progress.js";
|
|
48
48
|
import type { Theme } from "./agent-widget.js";
|
|
49
|
+
import { fallbackFlagText, hasFallback } from "./fallback-flag.js";
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Header re-render cadence. Claude Code ticks the workflow clock once a second,
|
|
@@ -355,12 +356,15 @@ export function layoutWorkflowCard(input: WorkflowCardInput): WorkflowCardLine[]
|
|
|
355
356
|
const rail = lastGroup ? " " : `${glyphs.vertical} `;
|
|
356
357
|
group.agents.forEach((entry, agentIndex) => {
|
|
357
358
|
const lastAgent = agentIndex === group.agents.length - 1;
|
|
359
|
+
const flag = entry.modelTier || hasFallback(entry) ? fallbackFlagText(1, entry) : "";
|
|
358
360
|
const segments: WorkflowCardLine = [
|
|
359
361
|
{ text: " " },
|
|
360
362
|
{ text: rail, color: "dim" },
|
|
361
363
|
{ text: `${lastAgent ? glyphs.lastBranch : glyphs.branch} `, color: "dim" },
|
|
362
364
|
rowGlyph(entry, glyphs),
|
|
363
365
|
{ text: " " },
|
|
366
|
+
// Leading, ahead of the label: the row is clamped from the right.
|
|
367
|
+
...(flag ? [{ text: `${flag} `, color: hasFallback(entry) ? "warning" as const : "dim" as const }] : []),
|
|
364
368
|
];
|
|
365
369
|
|
|
366
370
|
// Prepended rather than folded into `agentStatSegments`, which is the
|
|
@@ -370,7 +374,7 @@ export function layoutWorkflowCard(input: WorkflowCardInput): WorkflowCardLine[]
|
|
|
370
374
|
const statParts = entry.cached
|
|
371
375
|
? [REPLAYED_ANNOTATION, ...agentStatSegments(entry)]
|
|
372
376
|
: agentStatSegments(entry);
|
|
373
|
-
const pad = Math.max(0, labelColumn - visibleWidth(entry.label));
|
|
377
|
+
const pad = Math.max(0, labelColumn - visibleWidth(entry.label) - (flag ? visibleWidth(flag) + 1 : 0));
|
|
374
378
|
segments.push({ text: statParts.length > 0 ? entry.label + " ".repeat(pad) : entry.label });
|
|
375
379
|
for (const part of statParts) {
|
|
376
380
|
segments.push({ text: " · ", color: "dim" }, { text: part, color: "dim" });
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
type WorkflowEntry,
|
|
63
63
|
} from "../workflow/progress.js";
|
|
64
64
|
import { SPINNER, type Theme } from "./agent-widget.js";
|
|
65
|
+
import { fallbackFlagText, hasFallback } from "./fallback-flag.js";
|
|
65
66
|
import {
|
|
66
67
|
ASCII_GLYPHS,
|
|
67
68
|
clampLine,
|
|
@@ -643,6 +644,8 @@ function agentRow(options: {
|
|
|
643
644
|
{ text: " " },
|
|
644
645
|
dialogRowGlyph(display, glyphs, options.spinnerFrame),
|
|
645
646
|
{ text: " " },
|
|
647
|
+
// Leading, ahead of the label, in both pane modes: the row clamps from the right.
|
|
648
|
+
...(entry.modelTier || hasFallback(entry) ? [{ text: `${fallbackFlagText(1, entry)} `, color: hasFallback(entry) ? "warning" as const : "dim" as const }] : []),
|
|
646
649
|
{ text: entry.label, color: selected ? "accent" : undefined },
|
|
647
650
|
];
|
|
648
651
|
// The narrow pane holds the label and nothing else; there is no room for a
|
|
@@ -796,6 +799,13 @@ export function layoutWorkflowDialog(input: WorkflowDialogInput): WorkflowCardLi
|
|
|
796
799
|
if (stats.length > 0) {
|
|
797
800
|
detailRows.push(clampLine([{ text: ` ${stats.join(" · ")}`, color: "dim" }], rightWidth));
|
|
798
801
|
}
|
|
802
|
+
// A substituted model is never cosmetic: name the failed input and what ran.
|
|
803
|
+
// Wrapped, not clamped: this is the one place the full note is readable.
|
|
804
|
+
if (entry.modelNote) {
|
|
805
|
+
for (const line of wrapTextWithAnsi(entry.modelNote, Math.max(1, rightWidth - 1))) {
|
|
806
|
+
detailRows.push(clampLine([{ text: ` ${line}`, color: "warning" }], rightWidth));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
799
809
|
|
|
800
810
|
const prompt = previewLines(entry.promptPreview);
|
|
801
811
|
const collapsed = !state.promptExpanded && prompt.length > PROMPT_COLLAPSED_LINES;
|
package/src/workflow/host.ts
CHANGED
|
@@ -36,8 +36,7 @@ import { existsSync } from "node:fs";
|
|
|
36
36
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
37
|
import type { AgentManager } from "../agent-manager.js";
|
|
38
38
|
import { getAgentConfig, resolveSpawnType } from "../agent-types.js";
|
|
39
|
-
import {
|
|
40
|
-
import { checkModelScope } from "../model-scope.js";
|
|
39
|
+
import { modelSelectionNote, selectModel } from "../model-selection.js";
|
|
41
40
|
import type { AgentRecord, ThinkingLevel } from "../types.js";
|
|
42
41
|
import { getLifetimeTotal } from "../usage.js";
|
|
43
42
|
import type { WorkflowGateResult, WorkflowHost, WorkflowSpawnResult } from "./runtime.js";
|
|
@@ -51,6 +50,8 @@ import { resolveWorkflowSource } from "./saved.js";
|
|
|
51
50
|
export const DEFAULT_GATE_TIMEOUT_MS = 10 * 60_000;
|
|
52
51
|
|
|
53
52
|
export interface WorkflowHostOptions {
|
|
53
|
+
/** Home directory for nested saved workflows. */
|
|
54
|
+
homeDir?: string;
|
|
54
55
|
pi: ExtensionAPI;
|
|
55
56
|
ctx: ExtensionContext;
|
|
56
57
|
manager: AgentManager;
|
|
@@ -113,10 +114,13 @@ function resolvedInfo(record: AgentRecord | undefined) {
|
|
|
113
114
|
modelId: invocation.modelId,
|
|
114
115
|
thinking: invocation.thinking,
|
|
115
116
|
requestedThinking: invocation.requestedThinking,
|
|
117
|
+
// From the record, so a resumed child reports the model it actually ran on.
|
|
118
|
+
...(modelSelectionNote(record) ? { modelNote: modelSelectionNote(record) } : {}),
|
|
119
|
+
...(record?.modelTier ? { modelTier: record.modelTier } : {}),
|
|
116
120
|
};
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
function toSpawnResult(record: AgentRecord): WorkflowSpawnResult {
|
|
123
|
+
function toSpawnResult(record: AgentRecord, opts: { resumed?: boolean } = {}): WorkflowSpawnResult {
|
|
120
124
|
const tokens = getLifetimeTotal(record.lifetimeUsage);
|
|
121
125
|
// Reported separately from `tokens`, which is the lifetime total. The script's
|
|
122
126
|
// `budget` counts *output* tokens, as Claude Code's does — billing the input
|
|
@@ -131,15 +135,26 @@ function toSpawnResult(record: AgentRecord): WorkflowSpawnResult {
|
|
|
131
135
|
...(cwd !== undefined ? { cwd } : {}),
|
|
132
136
|
};
|
|
133
137
|
|
|
138
|
+
// Disclosed in the text/error the script reads AND as a field. A schema'd
|
|
139
|
+
// payload is JSON the script parses, so the note is only a field there.
|
|
140
|
+
const note = modelSelectionNote(record);
|
|
141
|
+
const lead = note ? `${note}\n\n` : "";
|
|
142
|
+
const modelNote = { ...(note ? { modelNote: note } : {}), ...(record.modelTier ? { modelTier: record.modelTier } : {}) };
|
|
143
|
+
|
|
134
144
|
if (succeeded(record)) {
|
|
135
145
|
return {
|
|
136
146
|
...common,
|
|
147
|
+
...modelNote,
|
|
137
148
|
ok: true,
|
|
138
149
|
// The schema'd payload when there is one: `result` is prose, and for a
|
|
139
150
|
// worktree child it has had the branch note appended, so it would not
|
|
140
151
|
// parse. A child asked for a schema that produced none never reaches
|
|
141
152
|
// here — `runAgent` reports that through `failure`.
|
|
142
|
-
|
|
153
|
+
// A RESUMED child answers in prose (resume forbids `schema`), but its record
|
|
154
|
+
// still holds the original run's `structuredJson`; returning that would hand
|
|
155
|
+
// the script a stale payload with no note, and the worker only injects or
|
|
156
|
+
// logs for schema calls. So a resume reads `result` and leads with the note.
|
|
157
|
+
text: !opts.resumed && record.structuredJson !== undefined ? record.structuredJson : `${lead}${record.result ?? ""}`,
|
|
143
158
|
...(record.structuredRetried ? { structuredRetried: true } : {}),
|
|
144
159
|
};
|
|
145
160
|
}
|
|
@@ -147,9 +162,9 @@ function toSpawnResult(record: AgentRecord): WorkflowSpawnResult {
|
|
|
147
162
|
// fleet list, a workflow abort. That is the same thing the workflows dialog's
|
|
148
163
|
// skip action means, so it renders as skipped rather than failed.
|
|
149
164
|
if (record.status === "stopped") {
|
|
150
|
-
return { ...common, ok: false, skipped: true, error: record.error ?? "Stopped." };
|
|
165
|
+
return { ...common, ...modelNote, ok: false, skipped: true, error: `${lead}${record.error ?? "Stopped."}` };
|
|
151
166
|
}
|
|
152
|
-
return { ...common, ok: false, error: record.error ?? `Agent ${record.status}.` };
|
|
167
|
+
return { ...common, ...modelNote, ok: false, error: `${lead}${record.error ?? `Agent ${record.status}.`}` };
|
|
153
168
|
}
|
|
154
169
|
|
|
155
170
|
/** Shell used to run a `gate` command, mirroring how a user would type it. */
|
|
@@ -196,45 +211,17 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
196
211
|
const dispatch = resolveSpawnType(request.agentType);
|
|
197
212
|
if (!dispatch.ok) return { ok: false, error: dispatch.message };
|
|
198
213
|
|
|
199
|
-
// Same precedence as the Agent tool: the caller's model wins, then the
|
|
200
|
-
// `agentModels` setting, the agent definition's is next, and the parent's
|
|
201
|
-
// is the floor. A model the script
|
|
202
|
-
// named and we cannot resolve is an error; one the definition named falls
|
|
203
|
-
// back to the parent silently, because the script never asked for it.
|
|
204
|
-
let model = ctx.model;
|
|
205
214
|
const config = getAgentConfig(dispatch.type);
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (request.model !== undefined) return { ok: false, error: resolved };
|
|
211
|
-
} else {
|
|
212
|
-
model = resolved;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Same scopeModels policy as the Agent tool and the nested delegation
|
|
217
|
-
// tools: a script's `agent({ model })` is a runtime LLM choice, and the
|
|
218
|
-
// script is written by the model, so it must not reach a model the user's
|
|
219
|
-
// enabledModels list excludes. `callerSupplied` keys off `request.model`
|
|
220
|
-
// and NOT `modelInput` — the latter has already absorbed the agent file's
|
|
221
|
-
// own `model:`, which is user-authored config and so earns the
|
|
222
|
-
// warn-and-proceed branch rather than a refusal.
|
|
223
|
-
const scopeVerdict = checkModelScope({
|
|
224
|
-
model,
|
|
225
|
-
cwd: ctx.cwd,
|
|
226
|
-
modelRegistry: ctx.modelRegistry,
|
|
227
|
-
callerSupplied: request.model !== undefined,
|
|
228
|
-
agentLabel: config?.displayName ?? dispatch.type,
|
|
229
|
-
modelInput,
|
|
215
|
+
const selection = selectModel({
|
|
216
|
+
homeDir: deps.homeDir,
|
|
217
|
+
agentType: dispatch.type, definitionModel: config?.model, paramModel: request.model,
|
|
218
|
+
parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd, agentLabel: config?.displayName,
|
|
230
219
|
});
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
warnedScopeMessages.add(scopeVerdict.message);
|
|
237
|
-
ctx.ui.notify(scopeVerdict.message, "warning");
|
|
220
|
+
if (typeof selection === "string") return { ok: false, error: selection };
|
|
221
|
+
const model = selection.model;
|
|
222
|
+
if (selection.scopeWarning && !warnedScopeMessages.has(selection.scopeWarning)) {
|
|
223
|
+
warnedScopeMessages.add(selection.scopeWarning);
|
|
224
|
+
ctx.ui.notify(selection.scopeWarning, "warning");
|
|
238
225
|
}
|
|
239
226
|
|
|
240
227
|
/**
|
|
@@ -298,6 +285,7 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
298
285
|
request.prompt,
|
|
299
286
|
{
|
|
300
287
|
description: request.label,
|
|
288
|
+
modelSelection: selection,
|
|
301
289
|
// Same as the Agent tool: `isolated` comes off the agent definition.
|
|
302
290
|
// Left out, a hermetic agent's script-spawned children loaded every
|
|
303
291
|
// host extension and picked up its MCP and web tools.
|
|
@@ -343,6 +331,10 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
343
331
|
reportResolved();
|
|
344
332
|
},
|
|
345
333
|
);
|
|
334
|
+
if (record.modelTier) {
|
|
335
|
+
const finalInfo = resolvedInfo(record);
|
|
336
|
+
if (finalInfo) request.onResolved?.(finalInfo);
|
|
337
|
+
}
|
|
346
338
|
return { ...toSpawnResult(record), ...(gate !== undefined ? { gate } : {}) };
|
|
347
339
|
} catch (error) {
|
|
348
340
|
// Strict worktree isolation rejects out of `awaitStartup` — the child
|
|
@@ -364,6 +356,12 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
364
356
|
if (id === undefined) {
|
|
365
357
|
return { ok: false, error: `Cannot resume "${agentId}" — it never started.` };
|
|
366
358
|
}
|
|
359
|
+
// The provenance is already on the record, so the running row shows it
|
|
360
|
+
// now rather than only once the resume has finished.
|
|
361
|
+
const existing = manager.getRecord(id);
|
|
362
|
+
if (modelSelectionNote(existing)) {
|
|
363
|
+
onResolved?.({ recordId: id, modelNote: modelSelectionNote(existing), modelTier: existing?.modelTier });
|
|
364
|
+
}
|
|
367
365
|
const record = await manager.resume(id, prompt, deps.signal);
|
|
368
366
|
if (record === undefined) {
|
|
369
367
|
return {
|
|
@@ -379,7 +377,7 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
379
377
|
onResolved?.({ recordId: id });
|
|
380
378
|
const info = resolvedInfo(record);
|
|
381
379
|
if (info !== undefined) onResolved?.(info);
|
|
382
|
-
return toSpawnResult(record);
|
|
380
|
+
return toSpawnResult(record, { resumed: true });
|
|
383
381
|
},
|
|
384
382
|
|
|
385
383
|
/**
|
|
@@ -387,7 +385,7 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
387
385
|
* comes back is a workflow; this only finds it.
|
|
388
386
|
*/
|
|
389
387
|
loadWorkflow(ref) {
|
|
390
|
-
return resolveWorkflowSource(ref, ctx.cwd);
|
|
388
|
+
return resolveWorkflowSource(ref, ctx.cwd, deps.homeDir);
|
|
391
389
|
},
|
|
392
390
|
|
|
393
391
|
// Reached only for a gate the spawn did not already run — a child with no
|
package/src/workflow/journal.ts
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
import { createHash } from "node:crypto";
|
|
48
48
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
49
|
+
import { isTierDisclosure, type TierDisclosure, tierDisclosure } from "../model-tiers.js";
|
|
49
50
|
|
|
50
51
|
/** One settled agent call, as replayed. */
|
|
51
52
|
export interface WorkflowJournalEntry {
|
|
@@ -57,6 +58,14 @@ export interface WorkflowJournalEntry {
|
|
|
57
58
|
ok: boolean;
|
|
58
59
|
/** The agent's answer, when it had one. */
|
|
59
60
|
text?: string;
|
|
61
|
+
/**
|
|
62
|
+
* The `Note:` line when the child ran on another model than configured.
|
|
63
|
+
* Replayed with the text so a resumed run discloses it too; absent on
|
|
64
|
+
* journals written before it existed, which means "no note", not an error.
|
|
65
|
+
*/
|
|
66
|
+
modelNote?: string;
|
|
67
|
+
/** The original tier outcome, retained on replay and journal-of-journal runs. */
|
|
68
|
+
modelTier?: TierDisclosure;
|
|
60
69
|
/**
|
|
61
70
|
* Whether the call continued an earlier child (`agent({ resume })`).
|
|
62
71
|
*
|
|
@@ -144,7 +153,8 @@ export function readJournal(path: string): WorkflowJournalEntry[] {
|
|
|
144
153
|
/** Append one settled call. Failure to write is not failure to run. */
|
|
145
154
|
export function appendJournal(path: string, entry: WorkflowJournalEntry): void {
|
|
146
155
|
try {
|
|
147
|
-
|
|
156
|
+
const persisted = entry.modelTier ? { ...entry, modelTier: tierDisclosure(entry.modelTier) } : entry;
|
|
157
|
+
appendFileSync(path, `${JSON.stringify(persisted)}\n`, "utf-8");
|
|
148
158
|
} catch {
|
|
149
159
|
// A journal that cannot be written costs a future resume, nothing more.
|
|
150
160
|
}
|
|
@@ -159,6 +169,8 @@ function isEntry(value: unknown): value is WorkflowJournalEntry {
|
|
|
159
169
|
typeof entry.key === "string" &&
|
|
160
170
|
typeof entry.ok === "boolean" &&
|
|
161
171
|
(entry.text === undefined || typeof entry.text === "string") &&
|
|
172
|
+
(entry.modelNote === undefined || typeof entry.modelNote === "string") &&
|
|
173
|
+
(entry.modelTier === undefined || isTierDisclosure(entry.modelTier)) &&
|
|
162
174
|
(entry.resumed === undefined || entry.resumed === true)
|
|
163
175
|
);
|
|
164
176
|
}
|
package/src/workflow/progress.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* unit-testable without a terminal.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import type { TierDisclosure } from "../model-tiers.js";
|
|
23
24
|
import type { WorkflowMeta, WorkflowPhaseMeta } from "./meta.js";
|
|
24
25
|
|
|
25
26
|
/** Raw entry lifecycle, as written by the runtime. */
|
|
@@ -93,6 +94,9 @@ export interface WorkflowAgentEntry {
|
|
|
93
94
|
* it.
|
|
94
95
|
*/
|
|
95
96
|
requestedThinking?: string;
|
|
97
|
+
/** `Note:` line set when the child ran on the parent model because its configured one did not resolve. */
|
|
98
|
+
modelNote?: string;
|
|
99
|
+
modelTier?: TierDisclosure;
|
|
96
100
|
fallbackModel?: string;
|
|
97
101
|
isolation?: "worktree";
|
|
98
102
|
error?: string;
|
package/src/workflow/runtime.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { cpus } from "node:os";
|
|
17
17
|
import { Worker } from "node:worker_threads";
|
|
18
|
+
import type { TierDisclosure } from "../model-tiers.js";
|
|
18
19
|
import { type JournalKeyInput, journalKey, type WorkflowJournalEntry } from "./journal.js";
|
|
19
20
|
import { type CompiledSchema, compileJsonSchema } from "./json-schema.js";
|
|
20
21
|
import { extractMeta, type WorkflowMeta } from "./meta.js";
|
|
@@ -95,6 +96,9 @@ export interface WorkflowSpawnRequest {
|
|
|
95
96
|
modelId?: string;
|
|
96
97
|
thinking?: string;
|
|
97
98
|
requestedThinking?: string;
|
|
99
|
+
/** `Note:` line when the child ran on the parent model because its configured one did not resolve. */
|
|
100
|
+
modelNote?: string;
|
|
101
|
+
modelTier?: TierDisclosure;
|
|
98
102
|
}): void;
|
|
99
103
|
/**
|
|
100
104
|
* Compiled from the script's `agent({ schema })`.
|
|
@@ -125,6 +129,9 @@ export interface WorkflowSpawnResult {
|
|
|
125
129
|
text?: string;
|
|
126
130
|
/** Why it failed. Present when not `ok`. */
|
|
127
131
|
error?: string;
|
|
132
|
+
/** `Note:` line when the child ran on a different model than configured; also leads `text`/`error` (not a schema'd payload). */
|
|
133
|
+
modelNote?: string;
|
|
134
|
+
modelTier?: TierDisclosure;
|
|
128
135
|
/** The user dismissed it rather than it failing; renders as skipped. */
|
|
129
136
|
skipped?: boolean;
|
|
130
137
|
tokens?: number;
|
|
@@ -315,6 +322,8 @@ export interface WorkflowRunResult {
|
|
|
315
322
|
/** The script's return value, JSON-checked at the boundary. */
|
|
316
323
|
value?: unknown;
|
|
317
324
|
error?: string;
|
|
325
|
+
/** Authored-source stack, kept separately from the concise failure message. */
|
|
326
|
+
errorStack?: string;
|
|
318
327
|
/** The append-only log, in emission order. */
|
|
319
328
|
progress: WorkflowEntry[];
|
|
320
329
|
/** Agents scheduled, including those that failed. */
|
|
@@ -733,14 +742,14 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
|
|
|
733
742
|
options.onProgress?.(entries);
|
|
734
743
|
};
|
|
735
744
|
|
|
736
|
-
const respond = (callId: number, ok: boolean, value?: unknown, error?: string, fatal?: boolean) => {
|
|
745
|
+
const respond = (callId: number, ok: boolean, value?: unknown, error?: string, fatal?: boolean, modelNote?: string) => {
|
|
737
746
|
// Cleared before the settled check: a launch answered by a run that is
|
|
738
747
|
// already finishing is not an unawaited launch either.
|
|
739
748
|
openLaunches.delete(callId);
|
|
740
749
|
if (settled) return;
|
|
741
750
|
// `spent` rides on every response, so the worker's `budget.spent()` is a
|
|
742
751
|
// mirror of this number rather than a second tally of its own.
|
|
743
|
-
worker.postMessage({ type: "response", callId, ok, value, error, fatal, spent: spentOutputTokens });
|
|
752
|
+
worker.postMessage({ type: "response", callId, ok, value, error, fatal, modelNote, spent: spentOutputTokens });
|
|
744
753
|
};
|
|
745
754
|
|
|
746
755
|
const finish = (result: Omit<WorkflowRunResult, "meta" | "progress" | "agentCount" | "replayedCount">) => {
|
|
@@ -908,6 +917,8 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
|
|
|
908
917
|
// dialog to annotate it "from resume journal" rather than letting a
|
|
909
918
|
// 0ms agent look like one that did the work impossibly fast.
|
|
910
919
|
cached: true,
|
|
920
|
+
...(replayed.modelNote !== undefined ? { modelNote: replayed.modelNote } : {}),
|
|
921
|
+
...(replayed.modelTier ? { modelTier: replayed.modelTier } : {}),
|
|
911
922
|
resultPreview: preview(replayedText),
|
|
912
923
|
},
|
|
913
924
|
]);
|
|
@@ -915,8 +926,8 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
|
|
|
915
926
|
// Re-recorded so this run's journal is complete on its own terms: a
|
|
916
927
|
// resume of a resume must not have to walk back through a chain of
|
|
917
928
|
// earlier files to find the prefix.
|
|
918
|
-
recordJournal?.({ index, key: replayed.key, ok: true, text: replayedText });
|
|
919
|
-
respond(callId, true, replayedText);
|
|
929
|
+
recordJournal?.({ index, key: replayed.key, ok: true, text: replayedText, ...(replayed.modelNote !== undefined ? { modelNote: replayed.modelNote } : {}), ...(replayed.modelTier ? { modelTier: replayed.modelTier } : {}) });
|
|
930
|
+
respond(callId, true, replayedText, undefined, undefined, replayed.modelNote);
|
|
920
931
|
return;
|
|
921
932
|
}
|
|
922
933
|
|
|
@@ -992,12 +1003,16 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
|
|
|
992
1003
|
modelId?: string;
|
|
993
1004
|
thinking?: string;
|
|
994
1005
|
requestedThinking?: string;
|
|
1006
|
+
modelNote?: string;
|
|
1007
|
+
modelTier?: TierDisclosure;
|
|
995
1008
|
}) => {
|
|
996
1009
|
if (info.recordId !== undefined) base.recordId = info.recordId;
|
|
997
1010
|
if (info.modelName !== undefined) base.model = info.modelName;
|
|
998
1011
|
if (info.modelId !== undefined) base.modelId = info.modelId;
|
|
999
1012
|
if (info.thinking !== undefined) base.thinking = info.thinking;
|
|
1000
1013
|
if (info.requestedThinking !== undefined) base.requestedThinking = info.requestedThinking;
|
|
1014
|
+
if (info.modelNote !== undefined) base.modelNote = info.modelNote;
|
|
1015
|
+
if (info.modelTier !== undefined) base.modelTier = info.modelTier;
|
|
1001
1016
|
// `base.state` is still "start", so emitting after the row reached a
|
|
1002
1017
|
// terminal state would revert it to running under last-write-wins.
|
|
1003
1018
|
// Not reachable from this repo's host, which reports during startup
|
|
@@ -1091,13 +1106,15 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
|
|
|
1091
1106
|
durationMs: finishedAt - startedAt,
|
|
1092
1107
|
...(result.tokens !== undefined ? { tokens: result.tokens } : {}),
|
|
1093
1108
|
...(result.toolCalls !== undefined ? { toolCalls: result.toolCalls } : {}),
|
|
1109
|
+
...(result.modelNote !== undefined ? { modelNote: result.modelNote } : {}),
|
|
1110
|
+
...(result.modelTier ? { modelTier: result.modelTier } : {}),
|
|
1094
1111
|
};
|
|
1095
1112
|
|
|
1096
1113
|
if (result.ok) {
|
|
1097
1114
|
const text = result.text ?? "";
|
|
1098
1115
|
emit([{ ...common, state: "done", resultPreview: preview(text) }]);
|
|
1099
|
-
recordJournal?.({ index, key, ok: true, text, ...resumeMark });
|
|
1100
|
-
respond(callId, true, text);
|
|
1116
|
+
recordJournal?.({ index, key, ok: true, text, ...(result.modelNote !== undefined ? { modelNote: result.modelNote } : {}), ...(result.modelTier ? { modelTier: result.modelTier } : {}), ...resumeMark });
|
|
1117
|
+
respond(callId, true, text, undefined, undefined, result.modelNote);
|
|
1101
1118
|
return;
|
|
1102
1119
|
}
|
|
1103
1120
|
// Recorded as a failure rather than left out: a gap would be read as an
|
|
@@ -1118,7 +1135,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
|
|
|
1118
1135
|
...(result.skipped ? { skipped: true } : {}),
|
|
1119
1136
|
},
|
|
1120
1137
|
]);
|
|
1121
|
-
respond(callId, true, null);
|
|
1138
|
+
respond(callId, true, null, undefined, undefined, result.modelNote);
|
|
1122
1139
|
return;
|
|
1123
1140
|
}
|
|
1124
1141
|
} finally {
|
|
@@ -1198,7 +1215,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
|
|
|
1198
1215
|
break;
|
|
1199
1216
|
}
|
|
1200
1217
|
case "error":
|
|
1201
|
-
finish({ status: "failed", error: message.message });
|
|
1218
|
+
finish({ status: "failed", error: message.message, errorStack: message.stack });
|
|
1202
1219
|
break;
|
|
1203
1220
|
}
|
|
1204
1221
|
});
|