@aeondave/pi-persona 1.15.2 → 1.16.1
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/AGENTS.md +32 -6
- package/CHANGELOG.md +97 -0
- package/README.md +27 -8
- package/SECURITY.md +42 -0
- package/docs/ARCHITECTURE.md +52 -7
- package/docs/EXPERIENCE.md +21 -5
- package/docs/README.md +2 -0
- package/docs/REFERENCE.md +84 -14
- package/docs/STRATEGIES.md +58 -31
- package/docs/TELEMETRY.md +14 -0
- package/package.json +18 -11
- package/src/bus/broker/paths.ts +3 -2
- package/src/core/brief.ts +1 -1
- package/src/core/nudge.ts +3 -4
- package/src/core/pi-compat.ts +3 -3
- package/src/engine/adapter.ts +11 -6
- package/src/engine/async.ts +12 -6
- package/src/engine/inproc.ts +14 -6
- package/src/extension/engine.ts +25 -4
- package/src/extension/hooks.ts +68 -21
- package/src/extension.ts +233 -57
- package/src/orchestration/model-retry.ts +32 -61
- package/src/orchestration/params.ts +27 -1
- package/src/orchestration/roster.ts +80 -3
- package/src/orchestration/sdk.ts +150 -36
- package/src/orchestration/strategies/compete.ts +14 -8
- package/src/orchestration/strategies/council-rounds.ts +8 -1
- package/src/orchestration/strategies/critic-loop.ts +25 -11
- package/src/orchestration/strategies/judge.ts +18 -9
- package/src/orchestration/strategies/magi.ts +42 -93
- package/src/orchestration/strategies/map.ts +41 -19
- package/src/orchestration/strategies/pipeline.ts +2 -2
- package/src/orchestration/strategies/synthesize.ts +18 -11
- package/src/orchestration/types.ts +15 -0
- package/src/persona/model-participants.ts +240 -0
- package/src/persona/orchestrate.ts +44 -2
- package/src/persona/persona.ts +86 -14
- package/src/telemetry/contract.ts +1 -1
- package/src/telemetry/producer.ts +241 -8
- package/src/tools/council.ts +36 -8
- package/src/tools/delegate-tool.ts +162 -24
- package/src/tools/intercom-tool.ts +119 -32
- package/src/ui/agent-overlay.ts +15 -6
- package/src/ui/agent-tree.ts +39 -2
package/src/extension.ts
CHANGED
|
@@ -96,13 +96,18 @@ import { type FlowSpec, flowHash, parseFlow, verifyFlowRefs } from "./orchestrat
|
|
|
96
96
|
import { journalFileName, journalWriter, readJournal } from "./orchestration/flow-journal.ts";
|
|
97
97
|
import { runFlow } from "./orchestration/flow-run.ts";
|
|
98
98
|
import type { FlowOutcome } from "./orchestration/flow-run.ts";
|
|
99
|
-
import { type RosterMember, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
|
|
99
|
+
import { type RosterMember, roleHint, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
|
|
100
100
|
import type { AgentProgress, AgentStatus, SteerFn } from "./orchestration/sdk.ts";
|
|
101
101
|
import { knownParams, strategyNames } from "./orchestration/strategy.ts";
|
|
102
102
|
import { compactMemberResult } from "./orchestration/render.ts";
|
|
103
103
|
import type { AgentResult, FailureKind } from "./orchestration/types.ts";
|
|
104
104
|
import { type ModelHandle, PersonaController, type PersonaHost } from "./persona/controller.ts";
|
|
105
|
-
import { resolveStrategyName, runPersonaStrategy } from "./persona/orchestrate.ts";
|
|
105
|
+
import { resolveStrategyName, resolveOrchestrationMembers, runPersonaStrategy } from "./persona/orchestrate.ts";
|
|
106
|
+
import {
|
|
107
|
+
collectModelParticipants,
|
|
108
|
+
pendingParticipants,
|
|
109
|
+
type ModelParticipant,
|
|
110
|
+
} from "./persona/model-participants.ts";
|
|
106
111
|
import { expandCouncilPreset, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
|
|
107
112
|
import { bundledSpinePath, bundledWorkerSpinePath, readSpineFile, resolveSpine, type SpineSources } from "./persona/spine.ts";
|
|
108
113
|
import { readLastPersona, writeLastPersona } from "./persona/state.ts";
|
|
@@ -119,7 +124,7 @@ import { shortModel } from "./tools/delegate.ts";
|
|
|
119
124
|
import { formatInbox } from "./tools/intercom.ts";
|
|
120
125
|
import { renderTimerFire, TimerScheduler, type TimerEntry } from "./core/timer.ts";
|
|
121
126
|
import { AgentOverlay } from "./ui/agent-overlay.ts";
|
|
122
|
-
import { type AddNodeInput, type AgentNode, type AgentTreeChange, AgentTree, type AgentNodeStatus, isQueuedMarker, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
|
|
127
|
+
import { type AddNodeInput, type AgentNode, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
|
|
123
128
|
import { LiveClock } from "./ui/live-clock.ts";
|
|
124
129
|
import { filterModels, ModelPicker, orderModelRefs } from "./ui/model-picker.ts";
|
|
125
130
|
import { compactInlineText, compactVisibleText, sanitizeTerminalText } from "./ui/presentation.ts";
|
|
@@ -188,9 +193,14 @@ export function announceAsyncRunSettlement(
|
|
|
188
193
|
enqueue: (run: AsyncRun) => void,
|
|
189
194
|
): void {
|
|
190
195
|
const id = compactInlineText(run.id, { maxChars: 80 }) || "run";
|
|
191
|
-
const agent = compactInlineText(run.agent, { maxChars: 80 }) || "agent";
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
const agent = compactInlineText(run.label ?? run.agent, { maxChars: 80 }) || compactInlineText(run.agent, { maxChars: 80 }) || "agent";
|
|
197
|
+
try {
|
|
198
|
+
if (run.status === "done") notify?.(`async run ${id} (${agent}) completed`, "info");
|
|
199
|
+
else if (run.status === "stopped") notify?.(`async run ${id} (${agent}) stopped`, "info");
|
|
200
|
+
else if (run.status === "failed") notify?.(`async run ${id} (${agent}) failed: ${compactInlineText(run.error ?? "(no detail)", { maxChars: 240 })}`, "error");
|
|
201
|
+
} catch {
|
|
202
|
+
// Cosmetic notification only: a broken UI must never swallow the terminal report.
|
|
203
|
+
}
|
|
194
204
|
enqueue(run);
|
|
195
205
|
}
|
|
196
206
|
|
|
@@ -525,6 +535,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
525
535
|
// left hanging when Pi resolves a call as "immediate" and never reaches a result.
|
|
526
536
|
type PendingIntercomTelemetry = { kind: string; to: string; size: number; replyTo?: string };
|
|
527
537
|
const telemetryIntercomPending = new Map<string, PendingIntercomTelemetry>();
|
|
538
|
+
// node id → steer that one agent (in-process engine only): inject a live user message.
|
|
539
|
+
const steerRegistry = new Map<string, SteerFn>();
|
|
528
540
|
function telemetryKind(node: AgentNode): TelemetryAgentKind {
|
|
529
541
|
if (node.kind) return node.kind;
|
|
530
542
|
if (node.id.startsWith("flow:")) return "flow";
|
|
@@ -537,9 +549,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
537
549
|
// "queued" is a marker WE set (the seeded roster / a launched async run), so it is a real
|
|
538
550
|
// state. Nothing ever sets `detail` to a waiting state — it is a usage string, an error, or
|
|
539
551
|
// `toolActivity(name, args)`, which splices the tool's own argument text — so a substring
|
|
540
|
-
// test for "waiting" could only ever fire on a leg that is running normally.
|
|
552
|
+
// test for "waiting" could only ever fire on a leg that is running normally. A live in-process
|
|
553
|
+
// steer handle has the authoritative bus predicate for a supervisor ask.
|
|
541
554
|
if (isQueuedMarker(node.detail)) return "queued";
|
|
542
|
-
return "running";
|
|
555
|
+
return steerRegistry.get(node.id)?.isWaitingForSupervisor?.() === true ? "waiting" : "running";
|
|
543
556
|
}
|
|
544
557
|
function telemetryAgent(node: AgentNode): TelemetryAgentInput {
|
|
545
558
|
return {
|
|
@@ -575,6 +588,22 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
575
588
|
// unfiltered publish appends one byte-identical duplicate per chunk: a 5k-chunk leg writes
|
|
576
589
|
// ~1.5MB of them and evicts genuine history from the producer's file budget.
|
|
577
590
|
const lastAgentProjection = new Map<string, string>();
|
|
591
|
+
function publishAgentProjection(node: AgentNode): void {
|
|
592
|
+
const producer = telemetry;
|
|
593
|
+
if (!producer) return;
|
|
594
|
+
const agent = telemetryAgent(node);
|
|
595
|
+
const projection = JSON.stringify(agent);
|
|
596
|
+
if (lastAgentProjection.get(node.id) === projection) return;
|
|
597
|
+
lastAgentProjection.set(node.id, projection);
|
|
598
|
+
producer.publishAgentUpdated(node.id, agent);
|
|
599
|
+
}
|
|
600
|
+
function refreshSteeredAgentTelemetry(): void {
|
|
601
|
+
if (!telemetry || steerRegistry.size === 0) return;
|
|
602
|
+
const steeredIds = new Set(steerRegistry.keys());
|
|
603
|
+
for (const node of agentTree.snapshot()) {
|
|
604
|
+
if (steeredIds.has(node.id)) publishAgentProjection(node);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
578
607
|
function publishAgentTreeChange(change: AgentTreeChange): void {
|
|
579
608
|
const producer = telemetry;
|
|
580
609
|
if (!producer) return;
|
|
@@ -584,10 +613,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
584
613
|
return;
|
|
585
614
|
}
|
|
586
615
|
if (change.type === "updated") {
|
|
587
|
-
|
|
588
|
-
if (lastAgentProjection.get(change.node.id) === projection) return;
|
|
589
|
-
lastAgentProjection.set(change.node.id, projection);
|
|
590
|
-
producer.publishAgentUpdated(change.node.id, telemetryAgent(change.node));
|
|
616
|
+
publishAgentProjection(change.node);
|
|
591
617
|
return;
|
|
592
618
|
}
|
|
593
619
|
if (change.type === "removed") {
|
|
@@ -634,8 +660,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
634
660
|
return true;
|
|
635
661
|
}
|
|
636
662
|
|
|
637
|
-
|
|
638
|
-
|
|
663
|
+
function waitingTreeIds(nodes: readonly AgentNode[] = agentTree.snapshot()): Set<string> {
|
|
664
|
+
return deriveWaitingNodeIds(nodes, (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true);
|
|
665
|
+
}
|
|
666
|
+
function waitingAsyncRunIds(): Set<string> {
|
|
667
|
+
return new Set(tracker.running().filter((run) => steerRegistry.get(`async:${run.id}`)?.isWaitingForSupervisor?.() === true).map((run) => run.id));
|
|
668
|
+
}
|
|
639
669
|
const clearSteers = (prefix: string): void => {
|
|
640
670
|
for (const k of [...steerRegistry.keys()]) if (k === prefix || k.startsWith(`${prefix}/`)) steerRegistry.delete(k);
|
|
641
671
|
};
|
|
@@ -670,7 +700,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
670
700
|
function renderAgentWidget(nodes: readonly AgentNode[] = agentTree.snapshot()): void {
|
|
671
701
|
if (!lastCtx) return;
|
|
672
702
|
const empty = nodes.length === 0;
|
|
673
|
-
const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS });
|
|
703
|
+
const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingTreeIds(nodes) });
|
|
674
704
|
const status = empty ? undefined : String(inFlightAgentCount(nodes));
|
|
675
705
|
try {
|
|
676
706
|
if (!widgetLinesInitialized || !sameWidgetLines(lastWidgetLines, lines)) {
|
|
@@ -783,6 +813,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
783
813
|
canSteer: (id) => steerRegistry.has(id),
|
|
784
814
|
canStop: (id) => stopRegistry.has(id),
|
|
785
815
|
stallMs: STALL_FLAG_MS,
|
|
816
|
+
isWaitingForSupervisor: (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true,
|
|
786
817
|
}),
|
|
787
818
|
// Near-fullscreen: watching sub-agents work is a reading surface, not a popup.
|
|
788
819
|
{ overlay: true, overlayOptions: { width: "90%", maxHeight: "90%" } },
|
|
@@ -813,7 +844,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
813
844
|
return readPersonaConfigs(configFile);
|
|
814
845
|
};
|
|
815
846
|
let personaConfigs: PersonaConfigStore = {};
|
|
816
|
-
|
|
847
|
+
// A participant is asked for a model at most ONCE per session — dismissal included, so a
|
|
848
|
+
// closed picker never becomes a nag. Keyed by persona + assignment key, NOT by persona: a new
|
|
849
|
+
// roster (or a new strategy's arbiter) is a genuinely new participant and may ask the first time
|
|
850
|
+
// it appears, while everyone already known stays silent.
|
|
851
|
+
const promptedParticipants = new Set<string>();
|
|
852
|
+
/** The subset of this run's participants already asked this session, keyed for the pure
|
|
853
|
+
* `pendingParticipants` gate (which speaks bare assignment keys). */
|
|
854
|
+
const promptedFor = (persona: string, participants: ModelParticipant[]): Set<string> => {
|
|
855
|
+
const asked = new Set<string>();
|
|
856
|
+
for (const p of participants) {
|
|
857
|
+
if (promptedParticipants.has(`${persona}\0${p.key}`)) asked.add(p.key);
|
|
858
|
+
}
|
|
859
|
+
return asked;
|
|
860
|
+
};
|
|
817
861
|
|
|
818
862
|
// Personas/agents load ONLY from the user dir (populated by `/persona seed|restore`) and the
|
|
819
863
|
// project `.pi/agents`. The bundled defaults are a *seed source*, NOT a live discovery layer,
|
|
@@ -1033,6 +1077,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1033
1077
|
bus.onAskSettled(({ id }) => {
|
|
1034
1078
|
intercomNotifier.discard((ask) => ask.askId === id);
|
|
1035
1079
|
telemetryAskSenders.delete(id);
|
|
1080
|
+
if (!disposed) refreshSteeredAgentTelemetry();
|
|
1036
1081
|
});
|
|
1037
1082
|
// Supervisor-armable alarms: when a timer expires it WAKES the session by routing the fire
|
|
1038
1083
|
// through the same idle-delivery path (an idle delivery starts a fresh turn, so the supervisor
|
|
@@ -1114,6 +1159,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1114
1159
|
expectsReply: env.expectsReply,
|
|
1115
1160
|
size: Buffer.byteLength(env.text, "utf8"),
|
|
1116
1161
|
});
|
|
1162
|
+
if (!disposed && env.expectsReply) refreshSteeredAgentTelemetry();
|
|
1117
1163
|
if (disposed || env.to !== SUPERVISOR || !env.expectsReply) return;
|
|
1118
1164
|
// Only an ask can be replied to, so only an ask needs its sender remembered. Oldest first
|
|
1119
1165
|
// out: a long session must not grow this without bound.
|
|
@@ -1172,7 +1218,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1172
1218
|
// the full status view stays on demand via `/peek`.
|
|
1173
1219
|
const now = Date.now();
|
|
1174
1220
|
const fast = config.peekEveryMs > 0;
|
|
1175
|
-
const
|
|
1221
|
+
const waitingRuns = waitingAsyncRunIds();
|
|
1222
|
+
const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
|
|
1176
1223
|
// Drain only progress messages; blocking asks (expectsReply) are surfaced by the intercom
|
|
1177
1224
|
// notifier and left for the `intercom inbox` tool — so peek never double-shows them.
|
|
1178
1225
|
const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
|
|
@@ -1180,7 +1227,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1180
1227
|
if (stuck.length === 0 && unread.length === 0 && !dueCheckIn) return; // healthy + quiet ⇒ stay silent
|
|
1181
1228
|
const parts: string[] = [];
|
|
1182
1229
|
if (stuck.length > 0) parts.push(buildPeekAlert(stuck, { now }));
|
|
1183
|
-
else if (dueCheckIn) parts.push(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS }));
|
|
1230
|
+
else if (dueCheckIn) parts.push(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingRuns }));
|
|
1184
1231
|
if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
|
|
1185
1232
|
// Reset the check-in cadence only on a PROGRESS surfacing (a stall alert or a check-in), NOT on a
|
|
1186
1233
|
// message-only wake — else a chatty child would postpone the routine off-track glance forever.
|
|
@@ -1283,27 +1330,54 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1283
1330
|
};
|
|
1284
1331
|
};
|
|
1285
1332
|
|
|
1286
|
-
|
|
1333
|
+
/**
|
|
1334
|
+
* Every participant a run really spawns — roster members (inline ones included) plus the
|
|
1335
|
+
* auxiliary actors its strategy declares — with the model each one resolves to.
|
|
1336
|
+
*/
|
|
1337
|
+
function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }): ModelParticipant[] {
|
|
1338
|
+
const persona = controller.activePersona?.name;
|
|
1339
|
+
return collectModelParticipants({
|
|
1340
|
+
members: opts.members,
|
|
1341
|
+
strategy: opts.strategy,
|
|
1342
|
+
...(opts.params ? { params: opts.params } : {}),
|
|
1343
|
+
...(persona ? { assigned: personaModels(personaConfigs, persona) } : {}),
|
|
1344
|
+
agentModel: (agent) => agents.find((a) => a.name === agent)?.model,
|
|
1345
|
+
...(ctx.model ? { sessionModel: `${ctx.model.provider}/${ctx.model.id}` } : {}),
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Offer a model for each participant that nobody has chosen one for yet. A participant is
|
|
1351
|
+
* skipped when a model is already assigned for it — an explicit inline member model, a saved
|
|
1352
|
+
* per-persona assignment (role key or agent key), or the agent's own frontmatter model. A saved
|
|
1353
|
+
* ref that is not currently authenticated is NOT re-asked either: that is a runtime recovery
|
|
1354
|
+
* question (the SDK reroutes such a leg to the main model), not a reason to nag every session.
|
|
1355
|
+
*
|
|
1356
|
+
* Only a live UI ever sees a dialog (`ctx.hasUI`), and only when there is more than one
|
|
1357
|
+
* authenticated model to choose between.
|
|
1358
|
+
*/
|
|
1359
|
+
async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[]): Promise<void> {
|
|
1287
1360
|
const persona = controller.activePersona?.name;
|
|
1288
1361
|
if (!persona || !ctx.hasUI) return;
|
|
1289
|
-
|
|
1290
|
-
const pickable = [...new Set(roster.map((m) => rosterSpec(m)).filter((s) => !s.model).map((s) => s.agent))];
|
|
1291
|
-
if (pickable.length < 2) return; // an ensemble of one distinct core can't be diversified
|
|
1292
|
-
if (modelsPrompted.has(persona)) return;
|
|
1293
|
-
const configured = personaConfigs[persona]?.models ?? {};
|
|
1294
|
-
const missing = pickable.filter((a) => !configured[a]);
|
|
1362
|
+
const missing = pendingParticipants(participants, { prompted: promptedFor(persona, participants) });
|
|
1295
1363
|
if (missing.length === 0) return;
|
|
1296
1364
|
const available = configuredModels(ctx);
|
|
1297
1365
|
if (available.length < 2) return; // can't diversify with a single configured model
|
|
1298
|
-
modelsPrompted.add(persona);
|
|
1299
1366
|
const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
|
|
1300
1367
|
const chosen: Record<string, string> = {};
|
|
1301
1368
|
try {
|
|
1302
|
-
for (const
|
|
1369
|
+
for (const p of missing) {
|
|
1370
|
+
// Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
|
|
1371
|
+
// for this session, exactly like an answer is not asked twice.
|
|
1372
|
+
promptedParticipants.add(`${persona}\0${p.key}`);
|
|
1303
1373
|
// Name the core's verticalization in the picker too: choosing a model for "the
|
|
1304
1374
|
// Conservatore" is a different judgement than choosing one for a bare "balthasar".
|
|
1305
|
-
|
|
1306
|
-
const
|
|
1375
|
+
// An auxiliary actor names the param that declared it ("arbiter judge").
|
|
1376
|
+
const purpose = corePurpose(p.agent);
|
|
1377
|
+
const role = p.param ? ` · ${p.param}` : "";
|
|
1378
|
+
// A participant label carries a role prompt (free prose from a team file or a
|
|
1379
|
+
// caller) — sanitised here so a title can never repaint the TUI or forge a row.
|
|
1380
|
+
const title = compactInlineText(`Model for "${p.label}"${purpose ? ` (${purpose})` : ""}${role} · ${persona}`, { maxChars: 120 });
|
|
1307
1381
|
// In the TUI: a searchable picker (type to filter) whose viewport follows the
|
|
1308
1382
|
// selection — the built-in select can't scroll a hundreds-long provider list
|
|
1309
1383
|
// usefully. Outside the TUI (RPC), fall back to the built-in select.
|
|
@@ -1315,7 +1389,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1315
1389
|
{ overlay: true },
|
|
1316
1390
|
)
|
|
1317
1391
|
: await ctx.ui.select(title, options);
|
|
1318
|
-
if (pick) chosen[
|
|
1392
|
+
if (pick) chosen[p.key] = pick;
|
|
1319
1393
|
}
|
|
1320
1394
|
} catch {
|
|
1321
1395
|
/* dismissed / no UI → fall back to the default model */
|
|
@@ -1342,24 +1416,40 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1342
1416
|
return agents.find((a) => a.name === agent)?.purpose;
|
|
1343
1417
|
}
|
|
1344
1418
|
|
|
1345
|
-
/** The session's own model, for a strategy's
|
|
1346
|
-
*
|
|
1347
|
-
|
|
1348
|
-
|
|
1419
|
+
/** The session's own model, for a strategy's main-only recovery of a member whose model broke
|
|
1420
|
+
* (`orchestration/model-retry.ts`). The GETTER is the live
|
|
1421
|
+
* one: a leg can fail minutes after the run started, and the main model may have changed since
|
|
1422
|
+
* the snapshot was taken — the legacy string stays alongside it for callers that want the
|
|
1423
|
+
* value the run started with. */
|
|
1424
|
+
function sessionModelDep(ctx: ExtensionContext): { sessionModel?: string; getSessionModel?: () => string | undefined } {
|
|
1425
|
+
const ref = (c?: ExtensionContext): string | undefined => {
|
|
1426
|
+
const model = (c ?? lastCtx)?.model;
|
|
1427
|
+
return model ? `${model.provider}/${model.id}` : undefined;
|
|
1428
|
+
};
|
|
1429
|
+
const snapshot = ref(ctx);
|
|
1430
|
+
return {
|
|
1431
|
+
...(snapshot ? { sessionModel: snapshot } : {}),
|
|
1432
|
+
getSessionModel: () => ref(ctx) ?? ref(),
|
|
1433
|
+
};
|
|
1349
1434
|
}
|
|
1350
1435
|
|
|
1351
|
-
// Each core's model beside its name
|
|
1352
|
-
|
|
1436
|
+
// Each core's model beside its name, in the ONE precedence the picker saved under: the
|
|
1437
|
+
// participant's own assignment key (a role member's `agent#hash`, else the bare agent key) →
|
|
1438
|
+
// the agent's declared default → the session model. An inline model is already the participant's
|
|
1439
|
+
// explicit engine pin and wins before any saved assignment.
|
|
1440
|
+
function coreModel(ctx: ExtensionContext, agent: string, assignmentKey: string = agent, participant?: ModelParticipant | null): string | undefined {
|
|
1441
|
+
if (participant === null) return undefined; // a short-hint collision cannot identify one role safely
|
|
1442
|
+
if (participant?.source === "inline" && participant.model) return participant.model;
|
|
1353
1443
|
const persona = controller.activePersona?.name;
|
|
1354
1444
|
const configured = persona ? personaModels(personaConfigs, persona) : {};
|
|
1355
|
-
return configured[agent] ?? agents.find((a) => a.name === agent)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
|
|
1445
|
+
return configured[assignmentKey] ?? configured[agent] ?? agents.find((a) => a.name === agent)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
|
|
1356
1446
|
}
|
|
1357
|
-
function coreTelemetryMeta(ctx: ExtensionContext, agent: string): { agent: string; model?: string } {
|
|
1358
|
-
const model = coreModel(ctx, agent);
|
|
1447
|
+
function coreTelemetryMeta(ctx: ExtensionContext, agent: string, assignmentKey?: string, participant?: ModelParticipant | null): { agent: string; model?: string } {
|
|
1448
|
+
const model = coreModel(ctx, agent, assignmentKey, participant);
|
|
1359
1449
|
return { agent, ...(model ? { model } : {}) };
|
|
1360
1450
|
}
|
|
1361
|
-
function coreLabel(ctx: ExtensionContext, agent: string, key: string = agent): string {
|
|
1362
|
-
const short = shortModel(coreModel(ctx, agent));
|
|
1451
|
+
function coreLabel(ctx: ExtensionContext, agent: string, key: string = agent, assignmentKey?: string, participant?: ModelParticipant | null): string {
|
|
1452
|
+
const short = shortModel(coreModel(ctx, agent, assignmentKey, participant));
|
|
1363
1453
|
// `key` is the disambiguated node id (`agent` for a solo member, `agent · HINT` for a
|
|
1364
1454
|
// roster-role one) — display that + what the core is FOR + the model, so a watcher reads the
|
|
1365
1455
|
// council as roles rather than names, and three `reviewer` lenses still read distinctly.
|
|
@@ -1367,13 +1457,69 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1367
1457
|
// purpose would repeat it — but a plain repeat (`melchior#2`) is the same core twice and
|
|
1368
1458
|
// still wants its lens, so key off the role separator, not off `key !== agent`.
|
|
1369
1459
|
const purpose = key.includes(" · ") ? undefined : corePurpose(agent);
|
|
1370
|
-
|
|
1460
|
+
// `key` embeds the member's role HINT (derived from its role prompt), and `short` embeds a
|
|
1461
|
+
// model ref — both are dynamic, so the whole row is collapsed to one escape-free line.
|
|
1462
|
+
return compactInlineText([key, purpose, short].filter(Boolean).join(" · "), { maxChars: 120 });
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** The assignment key for a tree node key when no participant was seeded under it: strip the lens
|
|
1466
|
+
* hint and the occurrence suffix, leaving the bare agent — the legacy key shape, which always
|
|
1467
|
+
* resolves (a role-less agent is exactly how those assignments were saved). */
|
|
1468
|
+
function bareAssignmentKey(nodeKey: string): string {
|
|
1469
|
+
return (nodeKey.split(" · ")[0] ?? nodeKey).replace(/#\d+$/, "");
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Map every picker participant to the SDK's display base. A short role hint is not unique when
|
|
1473
|
+
* different role prompts share it, so ambiguous nodes omit the model rather than borrowing
|
|
1474
|
+
* another lens' assignment. Repeated SDK keys (`#2`, …) resolve through their base and share the
|
|
1475
|
+
* same participant. */
|
|
1476
|
+
function participantByNode(participants: ModelParticipant[]): Map<string, ModelParticipant | null> {
|
|
1477
|
+
const groups = new Map<string, Map<string, ModelParticipant>>();
|
|
1478
|
+
for (const participant of participants) {
|
|
1479
|
+
const base = participant.role ? `${participant.agent} · ${roleHint(participant.role)}` : participant.agent;
|
|
1480
|
+
const group = groups.get(base) ?? new Map<string, ModelParticipant>();
|
|
1481
|
+
group.set(participant.key, participant);
|
|
1482
|
+
groups.set(base, group);
|
|
1483
|
+
}
|
|
1484
|
+
const out = new Map<string, ModelParticipant | null>();
|
|
1485
|
+
for (const [base, group] of groups) {
|
|
1486
|
+
if (group.size !== 1) {
|
|
1487
|
+
out.set(base, null);
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
const participant = group.values().next().value as ModelParticipant | undefined;
|
|
1491
|
+
if (participant) out.set(base, participant);
|
|
1492
|
+
}
|
|
1493
|
+
return out;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function participantForNode(nodeParticipants: ReadonlyMap<string, ModelParticipant | null>, nodeKey: string): ModelParticipant | null | undefined {
|
|
1497
|
+
const exact = nodeParticipants.get(nodeKey);
|
|
1498
|
+
if (exact !== undefined) return exact;
|
|
1499
|
+
const base = nodeKey.replace(/#\d+$/, "");
|
|
1500
|
+
return base === nodeKey ? undefined : nodeParticipants.get(base);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/** Surface the SDK's main-model recovery in the UI: a broken member is re-run on the session's own
|
|
1504
|
+
* model rather than dropped from the council, and the operator deserves to see that switch.
|
|
1505
|
+
* Strictly cosmetic — a toast that throws must never corrupt a run's result. */
|
|
1506
|
+
function modelFallbackDep(ctx: ExtensionContext): { onModelFallback?: (info: { agent: string; from?: string; to: string; key: string }) => void } {
|
|
1507
|
+
return {
|
|
1508
|
+
onModelFallback: (info) => {
|
|
1509
|
+
if (!ctx.hasUI) return;
|
|
1510
|
+
try {
|
|
1511
|
+
ctx.ui.notify(compactInlineText(`${info.agent}: ${info.from ?? "its model"} failed, re-running on ${info.to}`, { maxChars: 120 }), "warning");
|
|
1512
|
+
} catch {
|
|
1513
|
+
/* cosmetic */
|
|
1514
|
+
}
|
|
1515
|
+
},
|
|
1516
|
+
};
|
|
1371
1517
|
}
|
|
1372
1518
|
|
|
1373
1519
|
// The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
|
|
1374
1520
|
// `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
|
|
1375
1521
|
// cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
|
|
1376
|
-
function strategyTreeDeps(ctx: ExtensionContext, rootId: string) {
|
|
1522
|
+
function strategyTreeDeps(ctx: ExtensionContext, rootId: string, nodeParticipants: ReadonlyMap<string, ModelParticipant | null> = new Map()) {
|
|
1377
1523
|
return {
|
|
1378
1524
|
onAgentStart: (agent: string, abort: () => void, key?: string) => {
|
|
1379
1525
|
stopRegistry.set(`${rootId}/${key ?? agent}`, abort);
|
|
@@ -1390,7 +1536,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1390
1536
|
// time, not run time, so a queued leg would otherwise show elapsed time it never
|
|
1391
1537
|
// spent running and could raise a false stall badge once it goes live.
|
|
1392
1538
|
const now = Date.now();
|
|
1393
|
-
|
|
1539
|
+
const participant = participantForNode(nodeParticipants, nodeKey);
|
|
1540
|
+
const assignmentKey = participant?.key ?? bareAssignmentKey(nodeKey);
|
|
1541
|
+
agentTree.add({ id, label: coreLabel(ctx, agent, nodeKey, assignmentKey, participant), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, agent, assignmentKey, participant), detail: "", startedAt: now, lastAdvanceAt: now });
|
|
1394
1542
|
return;
|
|
1395
1543
|
}
|
|
1396
1544
|
stopRegistry.delete(id);
|
|
@@ -1406,7 +1554,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1406
1554
|
onAgentProgress: (agent: string, p: AgentProgress, key?: string) => {
|
|
1407
1555
|
const id = `${rootId}/${key ?? agent}`;
|
|
1408
1556
|
if (p.toolEvent) publishAgentTool(id, p.toolEvent);
|
|
1409
|
-
|
|
1557
|
+
const now = Date.now();
|
|
1558
|
+
agentTree.update(id, progressPatch(p, now));
|
|
1559
|
+
// Child engine events refresh liveness, not proof of useful work; repaint alone never advances it.
|
|
1560
|
+
agentTree.update(rootId, { lastAdvanceAt: now });
|
|
1410
1561
|
},
|
|
1411
1562
|
};
|
|
1412
1563
|
}
|
|
@@ -1426,8 +1577,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1426
1577
|
signal?: AbortSignal,
|
|
1427
1578
|
) {
|
|
1428
1579
|
const label = resolveStrategyName(orch) ?? "strategy";
|
|
1429
|
-
const roster = orch
|
|
1430
|
-
|
|
1580
|
+
const roster = resolveOrchestrationMembers(orch, teams);
|
|
1581
|
+
const participants = runParticipants(ctx, { strategy: label, ...(orch.params ? { params: orch.params } : {}), members: roster });
|
|
1582
|
+
await ensurePersonaModels(ctx, participants);
|
|
1583
|
+
const participantsByNode = participantByNode(participants);
|
|
1431
1584
|
const rootId = nextRootId(`${idPrefix}:${label}`);
|
|
1432
1585
|
agentTree.add({ id: rootId, label, status: "running", kind: "council" });
|
|
1433
1586
|
// Seed the whole roster at once (cores show by name immediately); "queued" until the
|
|
@@ -1436,13 +1589,15 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1436
1589
|
roster.forEach((m, i) => {
|
|
1437
1590
|
const a = rosterSpec(m).agent;
|
|
1438
1591
|
const key = seedKeys[i] ?? a;
|
|
1439
|
-
|
|
1592
|
+
const participant = participantForNode(participantsByNode, key);
|
|
1593
|
+
const assignmentKey = participant?.key ?? bareAssignmentKey(key);
|
|
1594
|
+
agentTree.add({ id: `${rootId}/${key}`, label: coreLabel(ctx, a, key, assignmentKey, participant), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a, assignmentKey, participant), detail: "queued" });
|
|
1440
1595
|
});
|
|
1441
1596
|
try {
|
|
1442
1597
|
// The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
|
|
1443
1598
|
// cooperatively between rounds, so an aborted run stops convening instead of running every
|
|
1444
1599
|
// remaining round against an already-cancelled engine (docs/STRATEGIES.md).
|
|
1445
|
-
const result = await runPersonaStrategy(orch, task, { engine: buildEngine(signal), teams, limits: RUN_LIMITS, ...sessionModelDep(ctx), ...(signal ? { signal } : {}), ...strategyTreeDeps(ctx, rootId), ...strategySpawnGate() });
|
|
1600
|
+
const result = await runPersonaStrategy(orch, task, { engine: buildEngine(signal, undefined, { providerFallback: false }), teams, limits: RUN_LIMITS, ...sessionModelDep(ctx), ...(signal ? { signal } : {}), ...strategyTreeDeps(ctx, rootId, participantsByNode), ...strategySpawnGate(), ...modelFallbackDep(ctx) });
|
|
1446
1601
|
agentTree.update(rootId, { status: signal?.aborted ? "stopped" : result?.ok === false ? "failed" : "done" });
|
|
1447
1602
|
return result ?? undefined;
|
|
1448
1603
|
} catch (error) {
|
|
@@ -1500,8 +1655,23 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1500
1655
|
const journalPath = join(journalDir, journalFileName(spec)); // encodes the name — any name is storable
|
|
1501
1656
|
const resume = readJournal(journalPath, hash);
|
|
1502
1657
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1658
|
+
// Preflight: every participant the flow's phases will spawn — roster members AND the
|
|
1659
|
+
// auxiliary actors each phase's strategy declares (an arbiter, a synthesiser) — resolved
|
|
1660
|
+
// once, up front, so the whole run's models are settled before the first leg starts.
|
|
1661
|
+
// An unknown team resolves to nothing here; `verifyFlowRefs` reports it properly below.
|
|
1662
|
+
const phasePlans = new Map(
|
|
1663
|
+
spec.phases.map((phase) => {
|
|
1664
|
+
let members: RosterMember[] = [];
|
|
1665
|
+
try {
|
|
1666
|
+
members = resolveOrchestrationMembers(phase.roster ? { roster: phase.roster } : {}, teams);
|
|
1667
|
+
} catch {
|
|
1668
|
+
members = [];
|
|
1669
|
+
}
|
|
1670
|
+
const participants = runParticipants(ctx, { strategy: phase.strategy, ...(phase.params ? { params: phase.params } : {}), members });
|
|
1671
|
+
return [phase.id, { members, participants }] as const;
|
|
1672
|
+
}),
|
|
1673
|
+
);
|
|
1674
|
+
await ensurePersonaModels(ctx, spec.phases.flatMap((p) => phasePlans.get(p.id)?.participants ?? []));
|
|
1505
1675
|
|
|
1506
1676
|
const flowRoot = nextRootId(`flow:${spec.name}`);
|
|
1507
1677
|
agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow" });
|
|
@@ -1546,23 +1716,28 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1546
1716
|
},
|
|
1547
1717
|
runPhase: async ({ phase, task }) => {
|
|
1548
1718
|
const pid = `${flowRoot}/${phase.id}`;
|
|
1549
|
-
const
|
|
1719
|
+
const plan = phasePlans.get(phase.id) ?? { members: [], participants: [] };
|
|
1720
|
+
const roster = plan.members;
|
|
1550
1721
|
const seedKeys = rosterNodeKeys(roster);
|
|
1722
|
+
const participantsByNode = participantByNode(plan.participants);
|
|
1551
1723
|
roster.forEach((m, i) => {
|
|
1552
1724
|
const a = rosterSpec(m).agent;
|
|
1553
1725
|
const key = seedKeys[i] ?? a;
|
|
1554
|
-
|
|
1726
|
+
const participant = participantForNode(participantsByNode, key);
|
|
1727
|
+
const assignmentKey = participant?.key ?? bareAssignmentKey(key);
|
|
1728
|
+
agentTree.add({ id: `${pid}/${key}`, label: coreLabel(ctx, a, key, assignmentKey, participant), parentId: pid, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a, assignmentKey, participant), detail: "queued" });
|
|
1555
1729
|
});
|
|
1556
1730
|
const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
|
|
1557
1731
|
if (phase.roster) orch.roster = phase.roster;
|
|
1558
1732
|
const r = await runPersonaStrategy(orch, task, {
|
|
1559
|
-
engine: buildEngine(signal),
|
|
1733
|
+
engine: buildEngine(signal, undefined, { providerFallback: false }),
|
|
1560
1734
|
teams,
|
|
1561
1735
|
limits: RUN_LIMITS,
|
|
1562
1736
|
...sessionModelDep(ctx),
|
|
1563
1737
|
...(signal ? { signal } : {}), // cooperative per-round abort inside the phase's strategy, not just the engine
|
|
1564
|
-
...strategyTreeDeps(ctx, pid),
|
|
1738
|
+
...strategyTreeDeps(ctx, pid, participantsByNode),
|
|
1565
1739
|
...strategySpawnGate(),
|
|
1740
|
+
...modelFallbackDep(ctx),
|
|
1566
1741
|
});
|
|
1567
1742
|
return r ?? { agent: phase.id, output: `unknown strategy: ${phase.strategy}`, usage: emptyUsage(), ok: false, error: "unknown strategy" };
|
|
1568
1743
|
},
|
|
@@ -1874,6 +2049,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1874
2049
|
bus,
|
|
1875
2050
|
SUPERVISOR,
|
|
1876
2051
|
STALL_FLAG_MS,
|
|
2052
|
+
waitingRunIds: waitingAsyncRunIds,
|
|
1877
2053
|
missingRunMessage,
|
|
1878
2054
|
stopAgent,
|
|
1879
2055
|
steerAgent,
|
|
@@ -2109,9 +2285,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2109
2285
|
const id = args.trim();
|
|
2110
2286
|
if (id) {
|
|
2111
2287
|
const run = tracker.peek(id);
|
|
2112
|
-
ctx.ui.notify(run ? buildPeekDigest([run], { now: Date.now(), stallMs: STALL_FLAG_MS }) : `peek: no run "${id}"`, run ? "info" : "warning");
|
|
2288
|
+
ctx.ui.notify(run ? buildPeekDigest([run], { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingAsyncRunIds() }) : `peek: no run "${id}"`, run ? "info" : "warning");
|
|
2113
2289
|
} else {
|
|
2114
|
-
ctx.ui.notify(buildPeekDigest(tracker.list(), { now: Date.now(), stallMs: STALL_FLAG_MS }), "info");
|
|
2290
|
+
ctx.ui.notify(buildPeekDigest(tracker.list(), { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingAsyncRunIds() }), "info");
|
|
2115
2291
|
}
|
|
2116
2292
|
},
|
|
2117
2293
|
});
|
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Main-only model retry policy — when a leg fails for a MODEL reason, which model should it be
|
|
3
|
+
* re-run on? Exactly one: the session's own (main) model.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* That single candidate is a deliberate, user-authorised exception to the strict provider-pin rule
|
|
6
|
+
* (`engine/fallback.ts` never crosses a pinned provider, and a strategy never picks another
|
|
7
|
+
* provider's model): a previously chosen, persisted, or inline-pinned model can simply be
|
|
8
|
+
* UNREACHABLE — retired ref, dead auth, vanished route — and the model the user is running right
|
|
9
|
+
* now is the only one with live evidence behind it. Earlier this policy also preferred a healthy
|
|
10
|
+
* PEER's model; that is gone. Borrowing one silently re-shaped the panel (breaking a council's
|
|
11
|
+
* uncorrelated-error bias guard for `magi`/`judge`) and crossed somebody else's provider pin, so
|
|
12
|
+
* recovery is now main-only and disclosed. The SDK applies it; this module is the pure policy.
|
|
10
13
|
*
|
|
11
|
-
*
|
|
12
|
-
* 1. the session's own model, when a peer proved it — the user is running on it, so it is the
|
|
13
|
-
* most likely to be authed, in budget, and available;
|
|
14
|
-
* 2. otherwise the first healthy peer's model in roster order (deterministic, not iteration order);
|
|
15
|
-
* 3. otherwise the session model unproven — the case where the failing core is the first or the
|
|
16
|
-
* only one left, so no peer evidence exists at all.
|
|
17
|
-
*
|
|
18
|
-
* A council's whole value is UNCORRELATED errors from distinct reasoners, so this deliberately
|
|
19
|
-
* never runs as a preference: it is what keeps a broken core from silently dropping out of the
|
|
20
|
-
* vote, and callers are expected to surface it. Pure — no engine, no I/O, no clock.
|
|
14
|
+
* Pure — no engine, no I/O, no clock.
|
|
21
15
|
*/
|
|
22
16
|
|
|
23
|
-
import type { AgentResult, FailureKind } from "./types.ts";
|
|
17
|
+
import type { AgentResult, FailureKind, ModelRecovery } from "./types.ts";
|
|
24
18
|
|
|
25
19
|
/** The only failures a DIFFERENT model can fix. `provider` is the provider rejecting or breaking
|
|
26
20
|
* (auth, outage, 5xx, model-not-supported); `unknown-model` is a ref that does not resolve at all.
|
|
@@ -31,55 +25,32 @@ export const RETRYABLE_MODEL_FAILURES = ["provider", "unknown-model"] as const s
|
|
|
31
25
|
|
|
32
26
|
const retryable = new Set<string>(RETRYABLE_MODEL_FAILURES);
|
|
33
27
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
agent: string;
|
|
38
|
-
/** The model that just failed, when the engine resolved one. */
|
|
39
|
-
from?: string;
|
|
40
|
-
/** The model to re-run on — never the one that just failed. */
|
|
41
|
-
model: string;
|
|
42
|
-
/** Which evidence chose it, for an honest breadcrumb in the log and the tree. */
|
|
43
|
-
reason: "peer" | "session";
|
|
28
|
+
/** A planned single recovery. `reason` is always `"session"`: there is no other source. */
|
|
29
|
+
export interface ModelRetry extends ModelRecovery {
|
|
30
|
+
reason: "session";
|
|
44
31
|
}
|
|
45
32
|
|
|
46
33
|
export interface ModelRetryDeps {
|
|
47
|
-
/** The model
|
|
34
|
+
/** The model's `provider/id` — the run's MAIN model. */
|
|
48
35
|
sessionModel?: string;
|
|
36
|
+
/** The model this leg ASKED for (`spec.model`), which the engine may never have resolved. */
|
|
37
|
+
requested?: string;
|
|
49
38
|
}
|
|
50
39
|
|
|
51
40
|
/**
|
|
52
|
-
* Plan at most one
|
|
53
|
-
*
|
|
54
|
-
*
|
|
41
|
+
* Plan at most one recovery for one failed leg, or `null` when nothing is worth re-running:
|
|
42
|
+
* the leg succeeded, the failure was not model-caused, no main model is known, or the main model is
|
|
43
|
+
* the one that just failed (resolved OR requested) — re-running there is a second bill for an
|
|
44
|
+
* identical outcome.
|
|
55
45
|
*/
|
|
56
|
-
export function
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
?
|
|
63
|
-
:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
? [session]
|
|
67
|
-
: [];
|
|
68
|
-
|
|
69
|
-
const plan: ModelRetry[] = [];
|
|
70
|
-
results.forEach((result, index) => {
|
|
71
|
-
if (result.ok || !result.failureKind || !retryable.has(result.failureKind)) return;
|
|
72
|
-
// Re-running on the model that just failed cannot produce a different outcome. Keep walking:
|
|
73
|
-
// a later healthy peer may still provide a distinct model worth trying.
|
|
74
|
-
const candidate = candidates.find((model) => model !== result.modelUsed);
|
|
75
|
-
if (!candidate) return;
|
|
76
|
-
plan.push({
|
|
77
|
-
index,
|
|
78
|
-
agent: result.agent,
|
|
79
|
-
...(result.modelUsed ? { from: result.modelUsed } : {}),
|
|
80
|
-
model: candidate,
|
|
81
|
-
reason: healthy.includes(candidate) ? "peer" : "session",
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
return plan;
|
|
46
|
+
export function planModelRecovery(result: AgentResult, deps: ModelRetryDeps): ModelRetry | null {
|
|
47
|
+
if (result.ok || !result.failureKind || !retryable.has(result.failureKind)) return null;
|
|
48
|
+
const main = deps.sessionModel?.trim();
|
|
49
|
+
if (!main) return null;
|
|
50
|
+
if (main === result.modelUsed || main === deps.requested) return null;
|
|
51
|
+
return {
|
|
52
|
+
...(result.modelUsed ? { from: result.modelUsed } : {}),
|
|
53
|
+
to: main,
|
|
54
|
+
reason: "session",
|
|
55
|
+
};
|
|
85
56
|
}
|