@nredd/pi-subagents 0.22.0-nredd.7 → 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 +124 -37
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +131 -28
- 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 +191 -90
- package/src/invocation-config.ts +3 -16
- 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 +22 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +6 -3
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +36 -18
- package/src/settings.ts +42 -12
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +27 -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
package/src/agent-manager.ts
CHANGED
|
@@ -30,7 +30,10 @@ import { getAgentConfig } from "./agent-types.js";
|
|
|
30
30
|
import { type Clock, realClock, type TimerHandle } from "./clock.js";
|
|
31
31
|
import { assignHandle, handleBase } from "./mention.js";
|
|
32
32
|
import type { ModelCallEvent } from "./model-call.js";
|
|
33
|
-
import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel } from "./model-resolver.js";
|
|
33
|
+
import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel, type ModelFallback } from "./model-resolver.js";
|
|
34
|
+
import { admitModelSelection, describeSelection, type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
|
|
35
|
+
import { type TierDisclosure, tierDisclosure } from "./model-tiers.js";
|
|
36
|
+
import { appendModelFallbackEntry, appendModelTierEntry } from "./output-file.js";
|
|
34
37
|
import {
|
|
35
38
|
assertWithinBudgetDepth,
|
|
36
39
|
BUDGET_QUEUE_FULL_ERROR,
|
|
@@ -41,6 +44,8 @@ import {
|
|
|
41
44
|
releaseBudget,
|
|
42
45
|
tryAcquireBudget,
|
|
43
46
|
} from "./process-budget.js";
|
|
47
|
+
import { describeBlock } from "./quota-admission.js";
|
|
48
|
+
import { isStaleCtxError } from "./stale-ctx.js";
|
|
44
49
|
import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
|
|
45
50
|
import { addUsage, type LifetimeUsage } from "./usage.js";
|
|
46
51
|
import type { CompiledSchema } from "./workflow/json-schema.js";
|
|
@@ -59,6 +64,10 @@ function relabelFromResponse(record: AgentRecord, message: AssistantMessageInfo
|
|
|
59
64
|
if (!physical) return;
|
|
60
65
|
record.invocation ??= {};
|
|
61
66
|
Object.assign(record.invocation, physical);
|
|
67
|
+
if ((record.modelTier?.kind === "routed" || record.modelTier?.kind === "static") && record.modelTier.answered !== physical.modelId) {
|
|
68
|
+
record.modelTier = { ...record.modelTier, answered: physical.modelId };
|
|
69
|
+
if (record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
|
|
70
|
+
}
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
@@ -163,12 +172,17 @@ export interface EvictedNote {
|
|
|
163
172
|
evictedAfterMin: number;
|
|
164
173
|
/** The `.output` transcript when one was written, else the session file. */
|
|
165
174
|
transcript?: string;
|
|
175
|
+
/** The run's model fallback, so even an evicted agent's id still discloses it. */
|
|
176
|
+
modelFallback?: ModelFallback;
|
|
177
|
+
modelTier?: TierDisclosure;
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
/** The text a tool returns for an id whose record was evicted. */
|
|
169
181
|
export function describeEvicted(note: EvictedNote): string {
|
|
170
182
|
const where = note.transcript ? `; transcript: ${note.transcript}` : "";
|
|
171
|
-
|
|
183
|
+
const disclosure = modelSelectionNote(note);
|
|
184
|
+
const fallback = disclosure ? `\n${disclosure}` : "";
|
|
185
|
+
return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}${fallback}`;
|
|
172
186
|
}
|
|
173
187
|
|
|
174
188
|
/**
|
|
@@ -282,6 +296,8 @@ interface SpawnArgs {
|
|
|
282
296
|
}
|
|
283
297
|
|
|
284
298
|
interface SpawnOptions {
|
|
299
|
+
/** Home inherited by nested callers; the manager's home is the default. */
|
|
300
|
+
homeDir?: string;
|
|
285
301
|
description: string;
|
|
286
302
|
/**
|
|
287
303
|
* Optional memorable name for this instance, becoming a second handle
|
|
@@ -308,7 +324,14 @@ interface SpawnOptions {
|
|
|
308
324
|
* `spawnTopLevel` strips it from anything a caller sends.
|
|
309
325
|
*/
|
|
310
326
|
reclaim?: { handle: string; alias?: string };
|
|
311
|
-
model?: Model<any
|
|
327
|
+
model?: Model<any> | string;
|
|
328
|
+
/** Internal preflight outcome, stored on the record without resolving again. */
|
|
329
|
+
modelSelection?: ModelSelection;
|
|
330
|
+
/** Historical outcome only, never used to label the continuation. */
|
|
331
|
+
previousModelTier?: TierDisclosure;
|
|
332
|
+
/** Historical fallback only, never used to label the continuation. */
|
|
333
|
+
previousModelFallback?: ModelFallback;
|
|
334
|
+
|
|
312
335
|
maxTurns?: number;
|
|
313
336
|
isolated?: boolean;
|
|
314
337
|
inheritContext?: boolean;
|
|
@@ -472,6 +495,7 @@ const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
|
472
495
|
* `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
|
|
473
496
|
*/
|
|
474
497
|
async function shutdownChildSession(session: AgentSession | undefined): Promise<void> {
|
|
498
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
475
499
|
try {
|
|
476
500
|
const runner = session?.extensionRunner;
|
|
477
501
|
// Optional all the way down: on a pi without the getter, or a stubbed session from a
|
|
@@ -482,14 +506,44 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
|
|
|
482
506
|
// already torn down — one hung handler would leave a dead terminal.
|
|
483
507
|
await Promise.race([
|
|
484
508
|
runner.emit({ type: "session_shutdown", reason: "quit" }),
|
|
485
|
-
new Promise<void>(resolve =>
|
|
509
|
+
new Promise<void>(resolve => {
|
|
510
|
+
deadline = setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS);
|
|
511
|
+
deadline.unref();
|
|
512
|
+
}),
|
|
486
513
|
]);
|
|
487
514
|
}
|
|
488
515
|
} catch { /* a partial session must degrade, not take the teardown down with it */ }
|
|
516
|
+
finally {
|
|
517
|
+
// Promise.race does not cancel its loser, even when a handler rejects.
|
|
518
|
+
if (deadline !== undefined) clearTimeout(deadline);
|
|
519
|
+
}
|
|
489
520
|
// Always, even on timeout: disposal is what this function ultimately exists to do.
|
|
490
521
|
try { session?.dispose?.(); } catch { /* ignore */ }
|
|
491
522
|
}
|
|
492
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Wrap an observer the child's own agent loop calls (usage, compaction) so a
|
|
526
|
+
* throw cannot reach that loop. pi records a throw from a session listener as
|
|
527
|
+
* the turn's failure, so an observer closed over a replaced session's `pi`
|
|
528
|
+
* ("This extension ctx is stale ...") used to end a detached child with an
|
|
529
|
+
* empty error turn and "No output" (#145). That failure is expected after a
|
|
530
|
+
* reload and dropped silently. Any other throw is a real observer defect: it
|
|
531
|
+
* is logged once per observer and also kept from killing the run.
|
|
532
|
+
*/
|
|
533
|
+
function guardObserver<A extends unknown[]>(name: string, observer: ((...args: A) => void) | undefined): ((...args: A) => void) | undefined {
|
|
534
|
+
if (!observer) return undefined;
|
|
535
|
+
let logged = false;
|
|
536
|
+
return (...args: A) => {
|
|
537
|
+
try {
|
|
538
|
+
observer(...args);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (isStaleCtxError(error) || logged) return;
|
|
541
|
+
logged = true;
|
|
542
|
+
console.error(`pi-subagents: ${name} observer failed; further failures are not logged`, error);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
493
547
|
export class AgentManager {
|
|
494
548
|
private agents = new Map<string, AgentRecord>();
|
|
495
549
|
private cleanupInterval: TimerHandle;
|
|
@@ -561,11 +615,12 @@ export class AgentManager {
|
|
|
561
615
|
onUsage?: OnAgentUsage,
|
|
562
616
|
onSteered?: OnAgentSteered,
|
|
563
617
|
private readonly clock: Clock = realClock,
|
|
618
|
+
private readonly homeDir?: string,
|
|
564
619
|
) {
|
|
565
620
|
this.onComplete = onComplete;
|
|
566
621
|
this.onStart = onStart;
|
|
567
|
-
this.onCompact = onCompact;
|
|
568
|
-
this.onUsage = onUsage;
|
|
622
|
+
this.onCompact = guardObserver("compaction", onCompact);
|
|
623
|
+
this.onUsage = guardObserver("usage", onUsage);
|
|
569
624
|
this.onSteered = onSteered;
|
|
570
625
|
this.maxConcurrent = maxConcurrent;
|
|
571
626
|
// Sweep finished records past their retention window (see cleanup()).
|
|
@@ -645,6 +700,19 @@ export class AgentManager {
|
|
|
645
700
|
// call, not minutes later at drain. Throw (not warn): programmatic callers
|
|
646
701
|
// can fix and retry; the RPC layer converts throws into error envelopes.
|
|
647
702
|
assertValidSpawnCwd(options.cwd);
|
|
703
|
+
const selection = options.modelSelection ?? selectModel({
|
|
704
|
+
homeDir: options.homeDir ?? this.homeDir,
|
|
705
|
+
agentType: type,
|
|
706
|
+
definitionModel: getAgentConfig(type)?.model,
|
|
707
|
+
paramModel: options.model,
|
|
708
|
+
parentModel: ctx.model,
|
|
709
|
+
registry: ctx.modelRegistry,
|
|
710
|
+
cwd: options.configCwd ?? ctx.cwd,
|
|
711
|
+
agentLabel: getAgentConfig(type)?.displayName,
|
|
712
|
+
});
|
|
713
|
+
if (typeof selection === "string") throw new Error(selection);
|
|
714
|
+
if (selection.scopeWarning && !options.modelSelection) ctx.ui.notify(selection.scopeWarning, "warning");
|
|
715
|
+
options = { ...options, model: selection.model, modelSelection: selection };
|
|
648
716
|
|
|
649
717
|
const id = randomUUID().slice(0, 17);
|
|
650
718
|
const abortController = new AbortController();
|
|
@@ -684,6 +752,11 @@ export class AgentManager {
|
|
|
684
752
|
// `options` has stopped being the interesting object.
|
|
685
753
|
blocking: options.blocking,
|
|
686
754
|
invocation: options.invocation,
|
|
755
|
+
modelFallback: selection.fallback,
|
|
756
|
+
modelSelection: selection,
|
|
757
|
+
modelTier: selection.tier ? tierDisclosure(selection.tier) : undefined,
|
|
758
|
+
previousModelTier: options.previousModelTier,
|
|
759
|
+
previousModelFallback: options.previousModelFallback,
|
|
687
760
|
depth: options.depth ?? 1,
|
|
688
761
|
parentAgentId: options.parentAgentId,
|
|
689
762
|
workflowId: options.workflowId,
|
|
@@ -826,28 +899,29 @@ export class AgentManager {
|
|
|
826
899
|
release();
|
|
827
900
|
markLeft();
|
|
828
901
|
},
|
|
829
|
-
|
|
902
|
+
// enqueueBudget only registers; grant cannot run until the next pump,
|
|
903
|
+
// after this entry has been initialized and published in the queue.
|
|
904
|
+
cancel: enqueueBudget({
|
|
905
|
+
depth: record.depth ?? 1,
|
|
906
|
+
ready: () => pool === undefined || this.poolHasRoom(pool),
|
|
907
|
+
grant: (lease) => {
|
|
908
|
+
this.queue = this.queue.filter(e => e !== entry);
|
|
909
|
+
if (this.agents.get(id) !== record || record.status !== "queued") {
|
|
910
|
+
releaseBudget(lease);
|
|
911
|
+
entry.release();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
record.budgetLease = lease;
|
|
915
|
+
// Detached, and never rejects: a late failure (e.g. strict worktree
|
|
916
|
+
// isolation) lands on the record inside `launch`. The release waits
|
|
917
|
+
// for that startup to SETTLE: a release at grant time would wake a
|
|
918
|
+
// blocked `spawnAndWait` while `record.promise` was still undefined,
|
|
919
|
+
// and it would read a healthy agent as one that never ran.
|
|
920
|
+
void entry.start().then(() => entry.release(), () => entry.release());
|
|
921
|
+
},
|
|
922
|
+
}),
|
|
830
923
|
left,
|
|
831
924
|
};
|
|
832
|
-
entry.cancel = enqueueBudget({
|
|
833
|
-
depth: record.depth ?? 1,
|
|
834
|
-
ready: () => pool === undefined || this.poolHasRoom(pool),
|
|
835
|
-
grant: (lease) => {
|
|
836
|
-
this.queue = this.queue.filter(e => e !== entry);
|
|
837
|
-
if (this.agents.get(id) !== record || record.status !== "queued") {
|
|
838
|
-
releaseBudget(lease);
|
|
839
|
-
entry.release();
|
|
840
|
-
return;
|
|
841
|
-
}
|
|
842
|
-
record.budgetLease = lease;
|
|
843
|
-
// Detached, and never rejects: a late failure (e.g. strict worktree
|
|
844
|
-
// isolation) lands on the record inside `launch`. The release waits
|
|
845
|
-
// for that startup to SETTLE: a release at grant time would wake a
|
|
846
|
-
// blocked `spawnAndWait` while `record.promise` was still undefined,
|
|
847
|
-
// and it would read a healthy agent as one that never ran.
|
|
848
|
-
void entry.start().then(() => entry.release(), () => entry.release());
|
|
849
|
-
},
|
|
850
|
-
});
|
|
851
925
|
this.queue.push(entry);
|
|
852
926
|
}
|
|
853
927
|
|
|
@@ -954,6 +1028,24 @@ export class AgentManager {
|
|
|
954
1028
|
record.startGate = undefined;
|
|
955
1029
|
this.chargePool(record.chargedPool, 1);
|
|
956
1030
|
|
|
1031
|
+
// Hold the pool slot BEFORE awaiting admission, just as for worktree creation.
|
|
1032
|
+
try {
|
|
1033
|
+
if (options.modelSelection?.tierBinding && !options.modelSelection.tierAdmitted) {
|
|
1034
|
+
const block = await admitModelSelection(options.modelSelection, ctx.modelRegistry, pi.events);
|
|
1035
|
+
record.modelTier = options.modelSelection.tier ? tierDisclosure(options.modelSelection.tier) : undefined;
|
|
1036
|
+
record.modelFallback = options.modelSelection.fallback;
|
|
1037
|
+
if (block) throw new Error(`${modelSelectionNote(record)} ${describeBlock(block)} The agent was not started.`);
|
|
1038
|
+
if (record.abortController?.signal.aborted || options.signal?.aborted) throw new Error("Agent aborted during model admission.");
|
|
1039
|
+
}
|
|
1040
|
+
if (record.modelTier) {
|
|
1041
|
+
record.invocation ??= {};
|
|
1042
|
+
Object.assign(record.invocation, describeSelection(options.modelSelection, ctx.modelRegistry));
|
|
1043
|
+
}
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
this.chargePool(record.chargedPool, -1);
|
|
1046
|
+
throw error;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
957
1049
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
958
1050
|
// fail loud if not possible (no silent fallback to main tree). Done BEFORE
|
|
959
1051
|
// the run is kicked off so a failure doesn't leave a half-running agent.
|
|
@@ -1035,8 +1127,10 @@ export class AgentManager {
|
|
|
1035
1127
|
|
|
1036
1128
|
const promise = runAgent(ctx, type, prompt, {
|
|
1037
1129
|
pi,
|
|
1130
|
+
homeDir: options.homeDir ?? this.homeDir,
|
|
1038
1131
|
agentId: id,
|
|
1039
|
-
model: options.model,
|
|
1132
|
+
model: options.modelSelection?.model,
|
|
1133
|
+
modelSelection: record.modelSelection!,
|
|
1040
1134
|
maxTurns: options.maxTurns,
|
|
1041
1135
|
isolated: options.isolated,
|
|
1042
1136
|
inheritContext: options.inheritContext,
|
|
@@ -1104,7 +1198,9 @@ export class AgentManager {
|
|
|
1104
1198
|
const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
|
|
1105
1199
|
Object.assign(
|
|
1106
1200
|
record.invocation,
|
|
1107
|
-
|
|
1201
|
+
record.modelTier?.kind === "routed" && record.modelTier.admitted
|
|
1202
|
+
? describeSelection(options.modelSelection, ctx.modelRegistry)
|
|
1203
|
+
: isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
|
|
1108
1204
|
);
|
|
1109
1205
|
// Guarded for the reason above: a session that reports no level keeps
|
|
1110
1206
|
// the request rather than losing it. Overwriting unconditionally would
|
|
@@ -1117,6 +1213,9 @@ export class AgentManager {
|
|
|
1117
1213
|
}
|
|
1118
1214
|
}
|
|
1119
1215
|
}
|
|
1216
|
+
// The transcript says it too: it is what someone opens to find out what ran.
|
|
1217
|
+
if (record.modelFallback && record.outputFile) appendModelFallbackEntry(record.outputFile, record.id, record.modelFallback);
|
|
1218
|
+
if (record.modelTier && record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
|
|
1120
1219
|
// Flush any steers that arrived before the session was ready
|
|
1121
1220
|
if (record.pendingSteers?.length) {
|
|
1122
1221
|
for (const msg of record.pendingSteers) {
|
|
@@ -1862,6 +1961,8 @@ export class AgentManager {
|
|
|
1862
1961
|
description: record.description,
|
|
1863
1962
|
sessionFile: record.sessionFile,
|
|
1864
1963
|
completedAt: record.completedAt ?? this.clock.now(),
|
|
1964
|
+
...(record.modelTier ? { modelTier: record.modelTier } : {}),
|
|
1965
|
+
...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
|
|
1865
1966
|
});
|
|
1866
1967
|
// Bound the memory a long session can accumulate. Oldest first, since the
|
|
1867
1968
|
// agent someone still wants to reach is the one they used most recently.
|
|
@@ -1882,6 +1983,8 @@ export class AgentManager {
|
|
|
1882
1983
|
status: record.status,
|
|
1883
1984
|
evictedAfterMin: Math.max(0, Math.round((this.clock.now() - completedAt) / 60_000)),
|
|
1884
1985
|
transcript: record.outputFile ?? record.sessionFile,
|
|
1986
|
+
...(record.modelTier ? { modelTier: record.modelTier } : {}),
|
|
1987
|
+
...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
|
|
1885
1988
|
});
|
|
1886
1989
|
while (this.evictedNotes.size > MAX_EVICTED_NOTES) {
|
|
1887
1990
|
const oldest = this.evictedNotes.keys().next().value;
|