@aeondave/pi-persona 1.17.4 → 1.17.5
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 +13 -0
- package/CHANGELOG.md +30 -0
- package/README.md +11 -5
- package/docs/ARCHITECTURE.md +109 -18
- package/docs/STRATEGIES.md +6 -0
- package/package.json +2 -2
- package/src/bridge.ts +8 -15
- package/src/bus/broker/client.ts +32 -9
- package/src/bus/broker/host.ts +7 -3
- package/src/bus/broker/messages.ts +4 -1
- package/src/bus/contact-transport.ts +8 -0
- package/src/bus/contact.ts +10 -7
- package/src/bus/peers.ts +8 -6
- package/src/core/companion.ts +64 -0
- package/src/engine/adapter.ts +18 -2
- package/src/engine/async.ts +4 -1
- package/src/engine/child.ts +1 -0
- package/src/engine/inproc.ts +11 -1
- package/src/extension/companion.ts +38 -0
- package/src/extension/engine.ts +20 -8
- package/src/extension/hooks.ts +18 -9
- package/src/extension/memory-owner.ts +54 -0
- package/src/extension/mind-worker.ts +129 -0
- package/src/extension/results.ts +14 -12
- package/src/extension.ts +117 -134
- package/src/orchestration/agent-attempt.ts +15 -0
- package/src/orchestration/flow-run.ts +4 -1
- package/src/orchestration/sdk.ts +19 -15
- package/src/persona/orchestrate.ts +4 -7
- package/src/telemetry/contract.ts +1 -1
- package/src/ui/activity-rows.ts +34 -0
- package/src/ui/agent-overlay.ts +13 -11
- package/src/ui/agent-tree.ts +30 -11
- package/src/ui/strategy-leg.ts +40 -0
package/src/extension.ts
CHANGED
|
@@ -37,7 +37,8 @@ import {
|
|
|
37
37
|
type SpineLegacySelection,
|
|
38
38
|
} from "./core/seed.ts";
|
|
39
39
|
import { type BriefFlow, buildDelegationBrief } from "./core/brief.ts";
|
|
40
|
-
import { canCallTool, canDelegateTo, canFanOut, type RunLimits } from "./core/capabilities.ts";
|
|
40
|
+
import { canCallTool, canDelegateTo, canFanOut, type RunLimits } from "./core/capabilities.ts";
|
|
41
|
+
import { isAllowed } from "./core/permissions.ts";
|
|
41
42
|
import { fenceUntrusted } from "./core/fence.ts";
|
|
42
43
|
import { sanitizeDisplayLabel } from "./core/display-label.ts";
|
|
43
44
|
import { DelegationNudge, orderByMention, PersistenceNudge, PollGuard, renderNudgeModels, renderNudgeRoster } from "./core/nudge.ts";
|
|
@@ -48,7 +49,11 @@ import { installedPiVersion, MIN_PI_VERSION, satisfiesFloor } from "./core/pi-co
|
|
|
48
49
|
import { type EngineAdapterBroker } from "./engine/adapter.ts";
|
|
49
50
|
import { SupervisorBroker } from "./extension/broker-host.ts";
|
|
50
51
|
import { configuredModels, createBuildEngine, DEFAULT_ENGINE_FACTORIES, type EngineFactories } from "./extension/engine.ts";
|
|
51
|
-
import { installHooks, type HookHost } from "./extension/hooks.ts";
|
|
52
|
+
import { installHooks, type HookHost } from "./extension/hooks.ts";
|
|
53
|
+
import { installActivityIngress, installCompanionPresence } from "./extension/companion.ts";
|
|
54
|
+
import { installMemoryOwner } from "./extension/memory-owner.ts";
|
|
55
|
+
import { installMindWorker } from "./extension/mind-worker.ts";
|
|
56
|
+
import { inheritedMemoryPersona, MEMORY_PERSONA_ENV } from "./core/companion.ts";
|
|
52
57
|
import {
|
|
53
58
|
canDeliverPersonaNotification,
|
|
54
59
|
compactNameList,
|
|
@@ -110,14 +115,16 @@ import { clearJournal, journalFileName, journalWriter, readJournal } from "./orc
|
|
|
110
115
|
import { runFlow } from "./orchestration/flow-run.ts";
|
|
111
116
|
import { Semaphore } from "./orchestration/parallel.ts";
|
|
112
117
|
import { flowDisposition, type FlowOutcome } from "./orchestration/flow-run.ts";
|
|
113
|
-
import { type RosterMember,
|
|
118
|
+
import { type RosterMember, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
|
|
119
|
+
import { agentAttemptIdentity, type AgentAttemptIdentity } from "./orchestration/agent-attempt.ts";
|
|
120
|
+
import { snapshotStrategyModels, strategyLegMetadata, type StrategyLegMetadata, type StrategyModels } from "./ui/strategy-leg.ts";
|
|
114
121
|
import type { AgentProgress, AgentStatus, SteerFn } from "./orchestration/sdk.ts";
|
|
115
122
|
import { withResultRefs } from "./orchestration/sdk.ts";
|
|
116
123
|
import { knownParams, strategyNames } from "./orchestration/strategy.ts";
|
|
117
124
|
import { compactMemberResult } from "./orchestration/render.ts";
|
|
118
125
|
import { sumUsage } from "./orchestration/reducers.ts";
|
|
119
126
|
import type { AgentResult, FailureKind } from "./orchestration/types.ts";
|
|
120
|
-
import { isThinkingLevel
|
|
127
|
+
import { isThinkingLevel } from "./core/types.ts";
|
|
121
128
|
import { snapshotOrchestrationParticipants, type OrchestrationLaunchSnapshot } from "./ui/orchestration-launch.ts";
|
|
122
129
|
import { SessionResults } from "./extension/results.ts";
|
|
123
130
|
import { readArchivedBody, resultReceiptHint, type ResultReceipt } from "./results/retrieval.ts";
|
|
@@ -144,7 +151,7 @@ import { shortModel } from "./tools/delegate.ts";
|
|
|
144
151
|
import { formatInbox } from "./tools/intercom.ts";
|
|
145
152
|
import { renderTimerFire, TimerScheduler, type TimerEntry } from "./core/timer.ts";
|
|
146
153
|
import { AgentOverlay } from "./ui/agent-overlay.ts";
|
|
147
|
-
import { type AddNodeInput, type AgentNode, type AgentNodePatch, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker,
|
|
154
|
+
import { type AddNodeInput, type AgentNode, type AgentNodePatch, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker, isNonAgentNode, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
|
|
148
155
|
import { WakeRows, type WakeRowChange } from "./ui/wake-rows.ts";
|
|
149
156
|
import { LiveClock } from "./ui/live-clock.ts";
|
|
150
157
|
import { filterModels, ModelPicker, orderModelRefs, type ModelPickerMetadata } from "./ui/model-picker.ts";
|
|
@@ -263,7 +270,7 @@ export function inFlightAgentCount(nodes: ReadonlyArray<{ id: string; parentId?:
|
|
|
263
270
|
}
|
|
264
271
|
let count = 0;
|
|
265
272
|
for (const node of nodes) {
|
|
266
|
-
if (node.status === "running" && node.kind !== "delegate" && !
|
|
273
|
+
if (node.status === "running" && node.kind !== "delegate" && !isNonAgentNode(node) && !parents.has(node.id)) count++;
|
|
267
274
|
}
|
|
268
275
|
return count;
|
|
269
276
|
}
|
|
@@ -310,15 +317,28 @@ export interface PiPersonaOptions {
|
|
|
310
317
|
exocomArgv?: readonly string[];
|
|
311
318
|
}
|
|
312
319
|
|
|
313
|
-
export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions = {}): void {
|
|
320
|
+
export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions = {}): void {
|
|
321
|
+
// Loaded-extension capabilities must be discoverable even in a real delegated leg. This is a
|
|
322
|
+
// local host contract, not settings/file inference; disabled supervisors explicitly deny use.
|
|
323
|
+
const bridgeMode = isBridgeMode();
|
|
324
|
+
const role = bridgeMode || process.env.PI_PERSONA_LEG === "1" ? "leg" : resolveConfig(process.env).disabled ? "disabled" : "supervisor";
|
|
325
|
+
installCompanionPresence(pi, role);
|
|
326
|
+
if (role === "leg") {
|
|
327
|
+
const inherited = inheritedMemoryPersona(process.env[MEMORY_PERSONA_ENV]);
|
|
328
|
+
if (inherited !== undefined) {
|
|
329
|
+
const owner = installMemoryOwner(pi, () => inherited.persona);
|
|
330
|
+
pi.on("session_start", (_event, ctx) => owner.start(ctx.sessionManager)());
|
|
331
|
+
pi.on("session_shutdown", () => owner.stop());
|
|
332
|
+
}
|
|
333
|
+
}
|
|
314
334
|
// Cross-process broker (v0.5, spec B3): a child spawned with `PI_PERSONA_BUS` set is a
|
|
315
335
|
// broker-connected sub-agent, not a supervisor — load ONLY the bridge (comm-plane tools +
|
|
316
|
-
// inbound follow-ups) and skip the entire persona/delegate/orchestration surface. Checked
|
|
317
|
-
//
|
|
336
|
+
// inbound follow-ups) and skip the entire persona/delegate/orchestration surface. Checked
|
|
337
|
+
// after the lean companion responders and before the main disabled guard, so it cannot be shadowed by
|
|
318
338
|
// the fork-bomb guard's `PI_PERSONA_DISABLE=1` short-circuit below, or by any other early
|
|
319
339
|
// return. `PI_PERSONA_BUS` unset (the default, and every existing test) ⇒ this branch never
|
|
320
340
|
// runs — zero behavior change.
|
|
321
|
-
if (
|
|
341
|
+
if (bridgeMode) {
|
|
322
342
|
pi.on("session_start", (_event, ctx) => installBridge(pi, ctx));
|
|
323
343
|
return;
|
|
324
344
|
}
|
|
@@ -568,7 +588,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
568
588
|
|
|
569
589
|
// The unified live tree of every in-flight agent — strategy cores, delegate
|
|
570
590
|
// sub-agents, dynamic specialists — rendered as one sticky widget above the input.
|
|
571
|
-
const agentTree = new AgentTree();
|
|
591
|
+
const agentTree = new AgentTree();
|
|
592
|
+
const companionActivities = installActivityIngress(pi, agentTree);
|
|
593
|
+
const memoryOwner = installMemoryOwner(pi, () => controller.activePersona?.name ?? null);
|
|
572
594
|
const wakeRows = new WakeRows();
|
|
573
595
|
// Explicit telemetry is the public observability seam consumed by pi-persona-flow. It is
|
|
574
596
|
// session-scoped and starts only after persona + exocom reconciliation in session_start.
|
|
@@ -645,7 +667,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
645
667
|
const projection = JSON.stringify(agent);
|
|
646
668
|
if (lastAgentProjection.get(node.id) === projection) return;
|
|
647
669
|
lastAgentProjection.set(node.id, projection);
|
|
648
|
-
|
|
670
|
+
// Updates are patches: an omitted field would retain the previous attempt's model.
|
|
671
|
+
// The existing string wire contract uses an empty model to mean explicitly unknown.
|
|
672
|
+
producer.publishAgentUpdated(node.id, node.model === undefined ? { ...agent, model: "" } : agent);
|
|
649
673
|
}
|
|
650
674
|
function refreshSteeredAgentTelemetry(): void {
|
|
651
675
|
if (!telemetry || steerRegistry.size === 0) return;
|
|
@@ -659,7 +683,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
659
683
|
if (!producer) return;
|
|
660
684
|
// Wake rows (timers, monitors) are not agents, and their labels are free text the telemetry
|
|
661
685
|
// contract never persists: they stay on the local widget and never reach the bus.
|
|
662
|
-
if ((change.type === "added" || change.type === "updated") &&
|
|
686
|
+
if ((change.type === "added" || change.type === "updated") && isNonAgentNode(change.node)) return;
|
|
663
687
|
if (change.type === "added") {
|
|
664
688
|
lastAgentProjection.set(change.node.id, JSON.stringify(telemetryAgent(change.node)));
|
|
665
689
|
producer.publishAgentAdded(telemetryAgent(change.node));
|
|
@@ -669,15 +693,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
669
693
|
publishAgentProjection(change.node);
|
|
670
694
|
return;
|
|
671
695
|
}
|
|
672
|
-
if (change.type === "removed") {
|
|
673
|
-
for (const node of change.nodes.filter((n) => !
|
|
696
|
+
if (change.type === "removed") {
|
|
697
|
+
for (const node of change.nodes.filter((n) => !isNonAgentNode(n))) {
|
|
674
698
|
lastAgentProjection.delete(node.id);
|
|
675
699
|
const status = node.status === "running" ? "stopped" : telemetryStatus(node);
|
|
676
700
|
producer.publish("agent.removed", { id: node.id, status });
|
|
677
701
|
}
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (change.nodes.every(isNonAgentNode)) return;
|
|
705
|
+
lastAgentProjection.clear();
|
|
681
706
|
producer.publish("agent.cleared", {});
|
|
682
707
|
}
|
|
683
708
|
agentTree.onChange(publishAgentTreeChange);
|
|
@@ -766,7 +791,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
766
791
|
/** The "N agents" status answers how many agents are working, so a tree holding only wake rows
|
|
767
792
|
* (timers, monitors) publishes none, while the widget lines still show them. */
|
|
768
793
|
function agentCountStatus(nodes: readonly AgentNode[]): string | undefined {
|
|
769
|
-
const agents = nodes.filter((node) => !
|
|
794
|
+
const agents = nodes.filter((node) => !isNonAgentNode(node));
|
|
770
795
|
return agents.length === 0 ? undefined : String(inFlightAgentCount(agents));
|
|
771
796
|
}
|
|
772
797
|
function renderAgentWidget(): void {
|
|
@@ -920,8 +945,11 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
920
945
|
return undefined;
|
|
921
946
|
}
|
|
922
947
|
};
|
|
923
|
-
const persist = (name: string | undefined): void => {
|
|
924
|
-
|
|
948
|
+
const persist = (name: string | undefined): void => {
|
|
949
|
+
// An explicit selection revokes the old companion scope even when persistence is off.
|
|
950
|
+
// Worker runs keep their frozen owners; only the local activity projection is reset.
|
|
951
|
+
if (!disposed && lastCtx) companionActivities.start(lastCtx);
|
|
952
|
+
if (!config.persist) return;
|
|
925
953
|
try {
|
|
926
954
|
savedSettings = patchPersonaSettings(settingsFile, { lastPersona: name ?? null });
|
|
927
955
|
} catch (error) {
|
|
@@ -1099,7 +1127,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1099
1127
|
return false; // keep current, but let the controller report that the switch failed
|
|
1100
1128
|
}
|
|
1101
1129
|
},
|
|
1102
|
-
setStatus: (text) => {
|
|
1130
|
+
setStatus: (text) => {
|
|
1131
|
+
if (!disposed) memoryOwner.changed();
|
|
1103
1132
|
try {
|
|
1104
1133
|
lastCtx?.ui.setStatus("persona", text === undefined ? undefined : compactInlineText(text, { maxChars: 80 }));
|
|
1105
1134
|
} catch {
|
|
@@ -1308,7 +1337,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1308
1337
|
announceAsyncRunSettlement(
|
|
1309
1338
|
run,
|
|
1310
1339
|
lastCtx ? (message, level) => lastCtx?.ui.notify(message, level) : undefined,
|
|
1311
|
-
(settled) => completionNotifier.notify(settled),
|
|
1340
|
+
(settled) => { if (settled.delivery !== "companion") completionNotifier.notify(settled); },
|
|
1312
1341
|
lastCtx?.hasUI && lastCtx.mode === "tui" ? (failed) => {
|
|
1313
1342
|
const model = failed.result?.modelUsed ?? failed.model;
|
|
1314
1343
|
const durationMs = failed.startedAt !== undefined && failed.settledAt !== undefined
|
|
@@ -1496,7 +1525,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1496
1525
|
/* cosmetic — the status is best-effort */
|
|
1497
1526
|
}
|
|
1498
1527
|
};
|
|
1499
|
-
const buildEngine: typeof innerBuildEngine = (signal, onProgress, engOpts) => {
|
|
1528
|
+
const buildEngine: typeof innerBuildEngine = (signal, onProgress, engOpts) => {
|
|
1500
1529
|
const engine = innerBuildEngine(signal, onProgress, engOpts);
|
|
1501
1530
|
return {
|
|
1502
1531
|
run: async (spec, progress, sig, steer) => {
|
|
@@ -1506,7 +1535,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1506
1535
|
return result;
|
|
1507
1536
|
},
|
|
1508
1537
|
};
|
|
1509
|
-
};
|
|
1538
|
+
};
|
|
1510
1539
|
|
|
1511
1540
|
/**
|
|
1512
1541
|
* Every participant a run really spawns — roster members (inline ones included) plus the
|
|
@@ -1743,77 +1772,25 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1743
1772
|
};
|
|
1744
1773
|
}
|
|
1745
1774
|
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
const
|
|
1754
|
-
const configured = persona ? personaModels(personaConfigs, persona) : {};
|
|
1755
|
-
return configured[assignmentKey] ?? configured[agent] ?? agents.find((a) => a.name === agent)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
|
|
1756
|
-
}
|
|
1757
|
-
function coreTelemetryMeta(ctx: ExtensionContext, agent: string, assignmentKey?: string, participant?: ModelParticipant | null): { agent: string; model?: string; thinking?: ThinkingLevel } {
|
|
1758
|
-
const model = coreModel(ctx, agent, assignmentKey, participant);
|
|
1759
|
-
// The member's explicit spec-level effort rides the SAME participant channel as the model, so
|
|
1760
|
-
// every strategy-seeded row (queued seed, live re-add, flow phase) names its effort from the
|
|
1761
|
-
// first frame — the delegate launch rows' own rule. An ambiguous/null participant identifies
|
|
1762
|
-
// no single lens, so it carries nothing, and absence renders nothing.
|
|
1763
|
-
return { agent, ...(model ? { model } : {}), ...(participant?.thinking ? { thinking: participant.thinking } : {}) };
|
|
1764
|
-
}
|
|
1765
|
-
function coreLabel(ctx: ExtensionContext, agent: string, key: string = agent, assignmentKey?: string, participant?: ModelParticipant | null): string {
|
|
1766
|
-
const short = shortModel(coreModel(ctx, agent, assignmentKey, participant));
|
|
1775
|
+
/** Capture at engine construction, after setup: queued legs retain that run's assignments. */
|
|
1776
|
+
function currentStrategyModels(ctx: ExtensionContext): StrategyModels {
|
|
1777
|
+
const persona = controller.activePersona?.name;
|
|
1778
|
+
return snapshotStrategyModels({ agents, ...(persona ? { assigned: personaModels(personaConfigs, persona) } : {}),
|
|
1779
|
+
...(ctx.model ? { sessionModel: `${ctx.model.provider}/${ctx.model.id}` } : {}) });
|
|
1780
|
+
}
|
|
1781
|
+
function coreLabel(identity: AgentAttemptIdentity, key: string, metadata: StrategyLegMetadata): string {
|
|
1782
|
+
const short = shortModel(metadata.model);
|
|
1767
1783
|
// `key` is the disambiguated node id (`agent` for a solo member, `agent · HINT` for a
|
|
1768
1784
|
// roster-role one) — display that + what the core is FOR + the model, so a watcher reads the
|
|
1769
1785
|
// council as roles rather than names, and three `reviewer` lenses still read distinctly.
|
|
1770
|
-
// A
|
|
1771
|
-
//
|
|
1772
|
-
|
|
1773
|
-
const purpose = key.includes(" · ") ? undefined : corePurpose(agent);
|
|
1786
|
+
// A role member already carries its lens in the display key. The full attempt identity,
|
|
1787
|
+
// rather than that shortened label, decides whether a standing purpose would repeat it.
|
|
1788
|
+
const purpose = identity.role ? undefined : corePurpose(identity.agent);
|
|
1774
1789
|
// `key` embeds the member's role HINT (derived from its role prompt), and `short` embeds a
|
|
1775
1790
|
// model ref — both are dynamic, so the whole row is collapsed to one escape-free line.
|
|
1776
1791
|
return compactInlineText([key, purpose, short].filter(Boolean).join(" · "), { maxChars: 120 });
|
|
1777
1792
|
}
|
|
1778
1793
|
|
|
1779
|
-
/** The assignment key for a tree node key when no participant was seeded under it: strip the lens
|
|
1780
|
-
* hint and the occurrence suffix, leaving the bare agent — the legacy key shape, which always
|
|
1781
|
-
* resolves (a role-less agent is exactly how those assignments were saved). */
|
|
1782
|
-
function bareAssignmentKey(nodeKey: string): string {
|
|
1783
|
-
return (nodeKey.split(" · ")[0] ?? nodeKey).replace(/#\d+$/, "");
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
/** Map every picker participant to the SDK's display base. A short role hint is not unique when
|
|
1787
|
-
* different role prompts share it, so ambiguous nodes omit the model rather than borrowing
|
|
1788
|
-
* another lens' assignment. Repeated SDK keys (`#2`, …) resolve through their base and share the
|
|
1789
|
-
* same participant. */
|
|
1790
|
-
function participantByNode(participants: ModelParticipant[]): Map<string, ModelParticipant | null> {
|
|
1791
|
-
const groups = new Map<string, Map<string, ModelParticipant>>();
|
|
1792
|
-
for (const participant of participants) {
|
|
1793
|
-
const base = participant.role ? `${participant.agent} · ${roleHint(participant.role)}` : participant.agent;
|
|
1794
|
-
const group = groups.get(base) ?? new Map<string, ModelParticipant>();
|
|
1795
|
-
group.set(participant.key, participant);
|
|
1796
|
-
groups.set(base, group);
|
|
1797
|
-
}
|
|
1798
|
-
const out = new Map<string, ModelParticipant | null>();
|
|
1799
|
-
for (const [base, group] of groups) {
|
|
1800
|
-
if (group.size !== 1) {
|
|
1801
|
-
out.set(base, null);
|
|
1802
|
-
continue;
|
|
1803
|
-
}
|
|
1804
|
-
const participant = group.values().next().value as ModelParticipant | undefined;
|
|
1805
|
-
if (participant) out.set(base, participant);
|
|
1806
|
-
}
|
|
1807
|
-
return out;
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
function participantForNode(nodeParticipants: ReadonlyMap<string, ModelParticipant | null>, nodeKey: string): ModelParticipant | null | undefined {
|
|
1811
|
-
const exact = nodeParticipants.get(nodeKey);
|
|
1812
|
-
if (exact !== undefined) return exact;
|
|
1813
|
-
const base = nodeKey.replace(/#\d+$/, "");
|
|
1814
|
-
return base === nodeKey ? undefined : nodeParticipants.get(base);
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
1794
|
/** Surface the SDK's main-model recovery in the UI: a broken member is re-run on the session's own
|
|
1818
1795
|
* model rather than dropped from the council, and the operator deserves to see that switch.
|
|
1819
1796
|
* Strictly cosmetic — a toast that throws must never corrupt a run's result. */
|
|
@@ -1833,31 +1810,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1833
1810
|
// The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
|
|
1834
1811
|
// `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
|
|
1835
1812
|
// cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
|
|
1836
|
-
function strategyTreeDeps(
|
|
1813
|
+
function strategyTreeDeps(rootId: string, models: StrategyModels, tracked?: TrackedRun) {
|
|
1837
1814
|
return {
|
|
1838
|
-
onAgentStart: (
|
|
1839
|
-
stopRegistry.set(`${rootId}/${key
|
|
1815
|
+
onAgentStart: (_agent: string, abort: () => void, key: string) => {
|
|
1816
|
+
stopRegistry.set(`${rootId}/${key}`, abort);
|
|
1817
|
+
},
|
|
1818
|
+
onAgentSteerable: (_agent: string, steer: SteerFn, key: string) => {
|
|
1819
|
+
steerRegistry.set(`${rootId}/${key}`, steer);
|
|
1840
1820
|
},
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
onAgentStatus: (agent: string, st: AgentStatus, result?: AgentResult, key?: string) => {
|
|
1845
|
-
const nodeKey = key ?? agent;
|
|
1846
|
-
const id = `${rootId}/${nodeKey}`;
|
|
1821
|
+
onAgentStatus: (_agent: string, st: AgentStatus, result: AgentResult | undefined, nodeKey: string, identity: AgentAttemptIdentity) => {
|
|
1822
|
+
const id = `${rootId}/${nodeKey}`;
|
|
1823
|
+
const metadata = strategyLegMetadata(identity, models, result);
|
|
1847
1824
|
if (st === "running") {
|
|
1848
1825
|
// detail "" clears the seeded "queued" marker — this core is actually live now.
|
|
1849
1826
|
// Stamp the clock here too: the seed's stamp (from AgentTree.add's upsert) is queue
|
|
1850
1827
|
// time, not run time, so a queued leg would otherwise show elapsed time it never
|
|
1851
1828
|
// spent running and could raise a false stall badge once it goes live.
|
|
1852
1829
|
const now = Date.now();
|
|
1853
|
-
|
|
1854
|
-
const assignmentKey = participant?.key ?? bareAssignmentKey(nodeKey);
|
|
1855
|
-
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 });
|
|
1830
|
+
agentTree.add({ id, label: coreLabel(identity, nodeKey, metadata), parentId: rootId, status: "running", kind: "subagent", ...metadata, detail: "", startedAt: now, lastAdvanceAt: now });
|
|
1856
1831
|
return;
|
|
1857
1832
|
}
|
|
1858
1833
|
stopRegistry.delete(id);
|
|
1859
1834
|
steerRegistry.delete(id);
|
|
1860
|
-
const patch:
|
|
1835
|
+
const patch: AgentNodePatch = { status: st, ...metadata };
|
|
1861
1836
|
if (result) {
|
|
1862
1837
|
const u = formatUsage(result.usage);
|
|
1863
1838
|
if (u) patch.detail = u;
|
|
@@ -1866,12 +1841,11 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1866
1841
|
// stays up (siblings still going) the row honestly reads `xh→h`; once the run ends the
|
|
1867
1842
|
// removal makes it a one-frame flash. effortTag renders the divergence only when the
|
|
1868
1843
|
// requested side exists and differs, so an unstamped request shows nothing extra.
|
|
1869
|
-
|
|
1870
|
-
}
|
|
1871
|
-
agentTree.update(id, patch);
|
|
1844
|
+
}
|
|
1845
|
+
agentTree.add({ id, label: coreLabel(identity, nodeKey, metadata), parentId: rootId, ...patch });
|
|
1872
1846
|
},
|
|
1873
|
-
onAgentProgress: (
|
|
1874
|
-
const id = `${rootId}/${key
|
|
1847
|
+
onAgentProgress: (_agent: string, p: AgentProgress, key: string) => {
|
|
1848
|
+
const id = `${rootId}/${key}`;
|
|
1875
1849
|
if (p.toolEvent) publishAgentTool(id, p.toolEvent);
|
|
1876
1850
|
const now = Date.now();
|
|
1877
1851
|
agentTree.update(id, progressPatch(p, now));
|
|
@@ -1916,7 +1890,6 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1916
1890
|
const label = resolveStrategyName(orch) ?? "strategy";
|
|
1917
1891
|
const roster = resolveOrchestrationMembers(orch, teams);
|
|
1918
1892
|
const participants = runParticipants(ctx, { strategy: label, ...(orch.params ? { params: orch.params } : {}), members: roster });
|
|
1919
|
-
const participantsByNode = participantByNode(participants);
|
|
1920
1893
|
results.activate(ctx);
|
|
1921
1894
|
const { rootId, signal, live } = visibleRoot(`${idPrefix}:${label}`, tracked);
|
|
1922
1895
|
const capture = results.bind(rootId);
|
|
@@ -1928,25 +1901,26 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1928
1901
|
agentTree.add({ id: rootId, label, status: "running", kind: "council", ...live });
|
|
1929
1902
|
try {
|
|
1930
1903
|
await ensurePersonaModels(ctx, participants, signal, rootId, tracked);
|
|
1931
|
-
if (signal.aborted) {
|
|
1904
|
+
if (signal.aborted) {
|
|
1932
1905
|
agentTree.update(rootId, { status: "stopped" });
|
|
1933
1906
|
const cancelled: AgentResult = { agent: label, output: "cancelled before orchestration started", usage: emptyUsage(), ok: false, error: "cancelled", failureKind: "abort" };
|
|
1934
|
-
return finish(cancelled);
|
|
1935
|
-
}
|
|
1907
|
+
return finish(cancelled);
|
|
1908
|
+
}
|
|
1909
|
+
const models = currentStrategyModels(ctx);
|
|
1910
|
+
const engine = buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true });
|
|
1936
1911
|
// Seed the whole roster at once (cores show by name immediately); "queued" until the
|
|
1937
1912
|
// engine actually starts each one — an honest view under the concurrency limit.
|
|
1938
1913
|
const seedKeys = rosterNodeKeys(roster);
|
|
1939
|
-
roster.forEach((m, i) => {
|
|
1940
|
-
const
|
|
1941
|
-
const key = seedKeys[i] ??
|
|
1942
|
-
const
|
|
1943
|
-
|
|
1944
|
-
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" });
|
|
1914
|
+
roster.forEach((m, i) => {
|
|
1915
|
+
const identity = agentAttemptIdentity(rosterSpec(m));
|
|
1916
|
+
const key = seedKeys[i] ?? identity.agent;
|
|
1917
|
+
const metadata = strategyLegMetadata(identity, models);
|
|
1918
|
+
agentTree.add({ id: `${rootId}/${key}`, label: coreLabel(identity, key, metadata), parentId: rootId, status: "running", kind: "subagent", ...metadata, detail: "queued" });
|
|
1945
1919
|
});
|
|
1946
1920
|
// The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
|
|
1947
1921
|
// cooperatively between rounds, so an aborted run stops convening instead of running every
|
|
1948
1922
|
// remaining round against an already-cancelled engine (docs/STRATEGIES.md).
|
|
1949
|
-
const result = await runPersonaStrategy(orch, task, { engine
|
|
1923
|
+
const result = await runPersonaStrategy(orch, task, { engine, teams, limits: RUN_LIMITS, ...sessionModelDep(ctx), signal, ...strategyTreeDeps(rootId, models, tracked), onAgentResult, ...strategySpawnGate(), ...modelFallbackDep(ctx) });
|
|
1950
1924
|
agentTree.update(rootId, { status: signal.aborted ? "stopped" : result?.ok === false ? "failed" : "done" });
|
|
1951
1925
|
return finish(result ?? { agent: label, output: "", ok: false, usage: emptyUsage(), error: "No strategy result", failureKind: "contract" });
|
|
1952
1926
|
} catch (error) {
|
|
@@ -2113,34 +2087,32 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2113
2087
|
if (result?.error) patch.detail = result.error;
|
|
2114
2088
|
agentTree.update(`${flowRoot}/${id}`, patch);
|
|
2115
2089
|
},
|
|
2116
|
-
runPhase: async ({ phase, task, resultRefs }) => {
|
|
2090
|
+
runPhase: async ({ phase, task, resultRefs }) => {
|
|
2117
2091
|
const pid = `${flowRoot}/${phase.id}`;
|
|
2118
2092
|
const plan = phasePlans.get(phase.id) ?? { members: [], participants: [] };
|
|
2119
2093
|
const roster = plan.members;
|
|
2120
2094
|
const seedKeys = rosterNodeKeys(roster);
|
|
2121
|
-
const
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2095
|
+
const models = currentStrategyModels(ctx);
|
|
2096
|
+
// Pin reference grants at the engine seam, beside the same frozen model selection
|
|
2097
|
+
// used for phase seeds and live attempts. Strategies cannot enlarge these grants.
|
|
2098
|
+
const phaseEngine = spec.handoff === "reference"
|
|
2099
|
+
? withResultRefs(buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }), resultRefs)
|
|
2100
|
+
: buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true });
|
|
2101
|
+
roster.forEach((m, i) => {
|
|
2102
|
+
const identity = agentAttemptIdentity(rosterSpec(m));
|
|
2103
|
+
const key = seedKeys[i] ?? identity.agent;
|
|
2104
|
+
const metadata = strategyLegMetadata(identity, models);
|
|
2105
|
+
agentTree.add({ id: `${pid}/${key}`, label: coreLabel(identity, key, metadata), parentId: pid, status: "running", kind: "subagent", ...metadata, detail: "queued" });
|
|
2128
2106
|
});
|
|
2129
2107
|
const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
|
|
2130
2108
|
if (phase.roster) orch.roster = phase.roster;
|
|
2131
|
-
// Reference handoff (trusted grant wiring): the phase's legs get EXACTLY the direct
|
|
2132
|
-
// upstream refs flow-run threaded through `runPhase` - pinned at the engine seam, so
|
|
2133
|
-
// a strategy can neither enlarge nor smuggle refs (the SDK strips its own attempts).
|
|
2134
|
-
const phaseEngine = spec.handoff === "reference"
|
|
2135
|
-
? withResultRefs(buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }), resultRefs)
|
|
2136
|
-
: buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true });
|
|
2137
2109
|
const r = await runPersonaStrategy(orch, task, {
|
|
2138
2110
|
engine: phaseEngine,
|
|
2139
2111
|
teams,
|
|
2140
2112
|
limits: RUN_LIMITS,
|
|
2141
2113
|
...sessionModelDep(ctx),
|
|
2142
2114
|
signal, // cooperative per-round abort inside the phase's strategy, not just the engine
|
|
2143
|
-
...strategyTreeDeps(
|
|
2115
|
+
...strategyTreeDeps(pid, models, tracked),
|
|
2144
2116
|
onAgentResult: (result, key) => { result.resultReceipt = capture(`phase:${phase.id}:leg:${key}`, result); },
|
|
2145
2117
|
...strategySpawnGate(),
|
|
2146
2118
|
...modelFallbackDep(ctx),
|
|
@@ -2378,7 +2350,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2378
2350
|
return lines.join("\n");
|
|
2379
2351
|
}
|
|
2380
2352
|
|
|
2381
|
-
const hookHost: HookHost = {
|
|
2353
|
+
const hookHost: HookHost = {
|
|
2354
|
+
companionActivities,
|
|
2355
|
+
memoryOwner,
|
|
2382
2356
|
identity,
|
|
2383
2357
|
results,
|
|
2384
2358
|
get config() { return config; },
|
|
@@ -2463,7 +2437,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2463
2437
|
|
|
2464
2438
|
// ONE launch pool for every background run: a delegate leg, a `council` and a `flow` each take a slot
|
|
2465
2439
|
// (maxConcurrency). The strategy SDK's own per-run semaphore gates the legs INSIDE a council or flow.
|
|
2466
|
-
const launchPool = new Semaphore(RUN_LIMITS.maxConcurrency);
|
|
2440
|
+
const launchPool = new Semaphore(RUN_LIMITS.maxConcurrency);
|
|
2441
|
+
installMindWorker(pi, {
|
|
2442
|
+
context: () => disposed ? undefined : lastCtx,
|
|
2443
|
+
canLaunch: () => {
|
|
2444
|
+
const caps = controller.capabilities;
|
|
2445
|
+
if (caps && (!canFanOut(caps) || !canCallTool(caps, "memory"))) return "persona denies memory or delegation tools";
|
|
2446
|
+
return isAllowed("mind", controller.activePersona?.delegate, config.delegateDefaultAllow) || "persona denies delegation to mind; the delegate target rules must allow mind";
|
|
2447
|
+
},
|
|
2448
|
+
tracker, tree: agentTree, slots: launchPool, stopRegistry, steerRegistry, buildEngine, startPeek,
|
|
2449
|
+
});
|
|
2467
2450
|
const backgroundRuns = createBackgroundRuns({
|
|
2468
2451
|
tracker,
|
|
2469
2452
|
agentTree,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Exact metadata for one engine attempt; display hints are never participant identities. */
|
|
2
|
+
import type { ThinkingLevel } from "../core/types.ts";
|
|
3
|
+
|
|
4
|
+
export interface AgentAttemptIdentity {
|
|
5
|
+
readonly agent: string;
|
|
6
|
+
readonly role?: string;
|
|
7
|
+
readonly model?: string;
|
|
8
|
+
readonly thinking?: ThinkingLevel;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Copy primitives only: no task, tool grants, result references or mutable caller object. */
|
|
12
|
+
export function agentAttemptIdentity(spec: AgentAttemptIdentity): AgentAttemptIdentity {
|
|
13
|
+
return Object.freeze({ agent: spec.agent, ...(spec.role !== undefined ? { role: spec.role } : {}),
|
|
14
|
+
...(spec.model !== undefined ? { model: spec.model } : {}), ...(spec.thinking !== undefined ? { thinking: spec.thinking } : {}) });
|
|
15
|
+
}
|
|
@@ -161,7 +161,10 @@ function buildPhaseTask(baseTask: string, spec: FlowSpec, phase: FlowPhase, upst
|
|
|
161
161
|
parts.push(`\n--- ${id} ---`);
|
|
162
162
|
parts.push(`status: ${r.ok ? "done" : "failed"}`);
|
|
163
163
|
if (ref === undefined) {
|
|
164
|
-
|
|
164
|
+
const preview = r.output.slice(0, 200);
|
|
165
|
+
parts.push(preview
|
|
166
|
+
? `result reference unavailable:\n${fenceUntrusted(preview)}`
|
|
167
|
+
: "result reference unavailable: (no output)");
|
|
165
168
|
continue;
|
|
166
169
|
}
|
|
167
170
|
parts.push(`result ref: ${ref}`);
|
package/src/orchestration/sdk.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { planModelRecovery } from "./model-retry.ts";
|
|
|
13
13
|
import { mapWithConcurrency, Semaphore } from "./parallel.ts";
|
|
14
14
|
import { aggregateResults, sumUsage } from "./reducers.ts";
|
|
15
15
|
import { roleHint, type RosterMember } from "./roster.ts";
|
|
16
|
+
import { agentAttemptIdentity, type AgentAttemptIdentity } from "./agent-attempt.ts";
|
|
16
17
|
import type { AgentResult, ModelRecovery } from "./types.ts";
|
|
17
18
|
import { type ReducerResult, type VoteOpts, voteReduce } from "./voting.ts";
|
|
18
19
|
|
|
@@ -199,17 +200,17 @@ export interface SDKDeps {
|
|
|
199
200
|
/** Per-agent lifecycle, for live UI. The result is passed on done/failed so the
|
|
200
201
|
* UI can capture each agent's output/usage. `key` is a run-unique display id (the
|
|
201
202
|
* `agent` name for a solo member, disambiguated by role/occurrence when the same agent
|
|
202
|
-
* runs several times in one roster)
|
|
203
|
-
*
|
|
204
|
-
onAgentStatus?: (agent: string, status: AgentStatus, result
|
|
203
|
+
* runs several times in one roster). The frozen `attempt` carries exact full-role/model/effort
|
|
204
|
+
* identity: never recover those fields by parsing this shortened display key. */
|
|
205
|
+
onAgentStatus?: (agent: string, status: AgentStatus, result: AgentResult | undefined, key: string, attempt: AgentAttemptIdentity) => void;
|
|
205
206
|
/** One final logical leg after model recovery, never intermediate attempts. Storage is non-fatal. */
|
|
206
|
-
onAgentResult?: (result: AgentResult, key: string) => void;
|
|
207
|
+
onAgentResult?: (result: AgentResult, key: string, attempt: AgentAttemptIdentity) => void;
|
|
207
208
|
/** Per-agent streaming progress (rolling output), for live UI. See `onAgentStatus.key`. */
|
|
208
|
-
onAgentProgress?: (agent: string, progress: AgentProgress, key
|
|
209
|
+
onAgentProgress?: (agent: string, progress: AgentProgress, key: string, attempt: AgentAttemptIdentity) => void;
|
|
209
210
|
/** Called as each agent starts with a handle to abort just that agent (for UI stop). */
|
|
210
|
-
onAgentStart?: (agent: string, abort: () => void, key
|
|
211
|
+
onAgentStart?: (agent: string, abort: () => void, key: string, attempt: AgentAttemptIdentity) => void;
|
|
211
212
|
/** Called once an agent is live with a handle to steer it (in-process engine only). */
|
|
212
|
-
onAgentSteerable?: (agent: string, steer: SteerFn, key
|
|
213
|
+
onAgentSteerable?: (agent: string, steer: SteerFn, key: string, attempt: AgentAttemptIdentity) => void;
|
|
213
214
|
/** Emitted immediately BEFORE a leg is re-run on the main model after a model-caused failure,
|
|
214
215
|
* so live UI can say so while it happens (`key` is the same logical node as the attempt it
|
|
215
216
|
* replaces). Purely cosmetic: a throw here is contained and can never discard the billed
|
|
@@ -303,8 +304,9 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
|
|
|
303
304
|
// seam via `withResultRefs`, which runs BELOW this layer).
|
|
304
305
|
const spec = requested.resultRefs === undefined ? requested : stripResultRefs(requested);
|
|
305
306
|
const key = uiKeyFor(spec);
|
|
307
|
+
let currentAttempt = agentAttemptIdentity(spec);
|
|
306
308
|
const finish = (result: AgentResult): AgentResult => {
|
|
307
|
-
try { deps.onAgentResult?.(result, key); } catch { /* storage must not discard a completed leg */ }
|
|
309
|
+
try { deps.onAgentResult?.(result, key, currentAttempt); } catch { /* storage must not discard a completed leg */ }
|
|
308
310
|
return result;
|
|
309
311
|
};
|
|
310
312
|
if (deps.canSpawn && !deps.canSpawn(spec.agent)) {
|
|
@@ -338,6 +340,8 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
|
|
|
338
340
|
const detachRoot: Array<() => void> = [];
|
|
339
341
|
let announcedStart = false;
|
|
340
342
|
const attempt = async (aSpec: AgentRunSpec): Promise<AgentResult> => {
|
|
343
|
+
const identity = agentAttemptIdentity(aSpec);
|
|
344
|
+
currentAttempt = identity;
|
|
341
345
|
const ac = current ?? new AbortController();
|
|
342
346
|
current = ac;
|
|
343
347
|
if (deps.signal) {
|
|
@@ -348,9 +352,9 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
|
|
|
348
352
|
}
|
|
349
353
|
if (!announcedStart) {
|
|
350
354
|
announcedStart = true;
|
|
351
|
-
deps.onAgentStart?.(aSpec.agent, () => current?.abort(), key);
|
|
355
|
+
deps.onAgentStart?.(aSpec.agent, () => current?.abort(), key, identity);
|
|
352
356
|
}
|
|
353
|
-
deps.onAgentStatus?.(aSpec.agent, "running", undefined, key);
|
|
357
|
+
deps.onAgentStatus?.(aSpec.agent, "running", undefined, key, identity);
|
|
354
358
|
// A slot grant is not an engine start. Root or local cancellation in a lifecycle
|
|
355
359
|
// callback must prevent the engine invocation, not merely hand it an aborted signal.
|
|
356
360
|
if (ac.signal.aborted) {
|
|
@@ -366,9 +370,9 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
|
|
|
366
370
|
try {
|
|
367
371
|
result = await deps.engine.run(
|
|
368
372
|
aSpec,
|
|
369
|
-
onProgress ? (p) => onProgress(aSpec.agent, p, key) : undefined,
|
|
373
|
+
onProgress ? (p) => onProgress(aSpec.agent, p, key, identity) : undefined,
|
|
370
374
|
ac.signal,
|
|
371
|
-
deps.onAgentSteerable ? (steer) => deps.onAgentSteerable?.(aSpec.agent, steer, key) : undefined,
|
|
375
|
+
deps.onAgentSteerable ? (steer) => deps.onAgentSteerable?.(aSpec.agent, steer, key, identity) : undefined,
|
|
372
376
|
);
|
|
373
377
|
} catch (err) {
|
|
374
378
|
// PER-LEG failure — contained, never rethrown: one blown engine call must not discard
|
|
@@ -385,7 +389,7 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
|
|
|
385
389
|
};
|
|
386
390
|
try {
|
|
387
391
|
const first = await attempt(spec);
|
|
388
|
-
deps.onAgentStatus?.(spec.agent, first.ok ? "done" : "failed", first, key);
|
|
392
|
+
deps.onAgentStatus?.(spec.agent, first.ok ? "done" : "failed", first, key, currentAttempt);
|
|
389
393
|
|
|
390
394
|
// ── Main-only model recovery ──────────────────────────────────────────────
|
|
391
395
|
// A model-caused failure (`provider`/`unknown-model`) gets exactly ONE re-run on the
|
|
@@ -427,7 +431,7 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
|
|
|
427
431
|
// a fresh controller or spend another child slot after it has been stopped.
|
|
428
432
|
if (deps.signal?.aborted || current?.signal.aborted) {
|
|
429
433
|
const stopped = { ...abortedLeg(spec.agent), usage: first.usage };
|
|
430
|
-
deps.onAgentStatus?.(spec.agent, "failed", stopped, key);
|
|
434
|
+
deps.onAgentStatus?.(spec.agent, "failed", stopped, key, currentAttempt);
|
|
431
435
|
return stopped;
|
|
432
436
|
}
|
|
433
437
|
// The retry is a REAL attempt: admitted and charged like any other child, but inside
|
|
@@ -444,7 +448,7 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
|
|
|
444
448
|
modelRecovery: recovery,
|
|
445
449
|
...(!second.ok ? { error: `Initial model ${plan.from ?? "(unresolved)"} failed: ${first.error ?? first.failureKind ?? "unknown cause"}; main model ${plan.to} failed: ${second.error ?? second.failureKind ?? "unknown cause"}` } : {}),
|
|
446
450
|
};
|
|
447
|
-
deps.onAgentStatus?.(spec.agent, settled.ok ? "done" : "failed", settled, key);
|
|
451
|
+
deps.onAgentStatus?.(spec.agent, settled.ok ? "done" : "failed", settled, key, currentAttempt);
|
|
448
452
|
return settled;
|
|
449
453
|
} finally {
|
|
450
454
|
for (const detach of detachRoot) detach();
|