@nredd/pi-subagents 0.22.0-nredd.6 → 0.22.0-nredd.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -43
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +418 -118
- package/src/agent-runner.ts +155 -62
- package/src/cross-extension-rpc.ts +11 -36
- package/src/custom-agents.ts +9 -5
- package/src/default-agents.ts +2 -4
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +5 -5
- package/src/home-paths.ts +27 -0
- package/src/index.ts +231 -107
- package/src/invocation-config.ts +8 -35
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +9 -5
- package/src/model-resolver.ts +41 -11
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +159 -0
- package/src/model-tiers.ts +171 -0
- package/src/nested-tools.ts +31 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +346 -0
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +41 -19
- package/src/settings.ts +111 -8
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +33 -0
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +15 -4
- package/src/ui/conversation-viewer.ts +6 -3
- package/src/ui/fallback-flag.ts +90 -0
- package/src/ui/fleet-list.ts +363 -53
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +18 -2
- package/src/ui/workflow-card.ts +5 -1
- package/src/ui/workflow-dialog.ts +10 -0
- package/src/workflow/host.ts +43 -45
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/progress.ts +4 -0
- package/src/workflow/runtime.ts +25 -8
- package/src/workflow/saved.ts +19 -17
- package/src/workflow/task.ts +10 -0
- package/src/workflow/worker-source.ts +156 -38
package/src/nested-tools.ts
CHANGED
|
@@ -17,8 +17,7 @@ import {
|
|
|
17
17
|
} from "./agent-types.js";
|
|
18
18
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
19
19
|
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
|
|
20
|
-
import {
|
|
21
|
-
import { checkModelScope } from "./model-scope.js";
|
|
20
|
+
import { type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
|
|
22
21
|
import {
|
|
23
22
|
createOutputFilePath,
|
|
24
23
|
getOutputTranscriptDefault,
|
|
@@ -75,8 +74,10 @@ export const NESTED_WORKFLOW_NO_AGENT_ERROR =
|
|
|
75
74
|
+ "Do the work directly.";
|
|
76
75
|
|
|
77
76
|
interface NestedSpawnOptions {
|
|
77
|
+
homeDir?: string;
|
|
78
78
|
description: string;
|
|
79
79
|
model?: Model<any>;
|
|
80
|
+
modelSelection?: ModelSelection;
|
|
80
81
|
maxTurns?: number;
|
|
81
82
|
isolated?: boolean;
|
|
82
83
|
inheritContext?: boolean;
|
|
@@ -120,6 +121,8 @@ export interface NestedAgentManager {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
export interface NestedToolContext {
|
|
124
|
+
/** Home shared by this branch's user-resource discovery. */
|
|
125
|
+
homeDir?: string;
|
|
123
126
|
manager: NestedAgentManager;
|
|
124
127
|
pi: ExtensionAPI;
|
|
125
128
|
parentAgentId: string;
|
|
@@ -160,6 +163,13 @@ function ownsRecord(record: AgentRecord | undefined, parentAgentId: string): rec
|
|
|
160
163
|
type ResultPosition = "inline" | "fetched";
|
|
161
164
|
|
|
162
165
|
function formatRecord(record: AgentRecord, position: ResultPosition): string {
|
|
166
|
+
// Read off the record, so a fetched or resumed child reports what ran, not
|
|
167
|
+
// what the current call would have configured.
|
|
168
|
+
const note = modelSelectionNote(record);
|
|
169
|
+
return note ? `${note}\n\n${formatOutcome(record, position)}` : formatOutcome(record, position);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function formatOutcome(record: AgentRecord, position: ResultPosition): string {
|
|
163
173
|
if (record.status === "error") {
|
|
164
174
|
return `Agent failed: ${record.error ?? "unknown error"}${partialOutputSuffix(record)}`;
|
|
165
175
|
}
|
|
@@ -181,7 +191,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
181
191
|
// Agents resolve from a registry built for THIS branch's config root (under
|
|
182
192
|
// worktree isolation, the copy). Never via registerAgents — that is
|
|
183
193
|
// process-global state shared with the main session and every other agent.
|
|
184
|
-
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd, false, { projectTrusted: context.projectTrusted }));
|
|
194
|
+
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd, false, { projectTrusted: context.projectTrusted, homeDir: context.homeDir }));
|
|
185
195
|
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
|
|
186
196
|
context.allowedSubagents === "all"
|
|
187
197
|
? undefined
|
|
@@ -201,7 +211,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
201
211
|
prompt: Type.String({ description: "Self-contained task for the nested agent." }),
|
|
202
212
|
description: Type.String({ description: "Short 3-5 word task description." }),
|
|
203
213
|
subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.` }),
|
|
204
|
-
model: Type.Optional(Type.String({ description: "Optional provider/model override." })),
|
|
214
|
+
model: Type.Optional(Type.String({ description: "Optional configured @tier or provider/model override; explicit values win over agentModels and the definition." })),
|
|
205
215
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
|
|
206
216
|
max_turns: Type.Optional(Type.Number({ minimum: 1 })),
|
|
207
217
|
run_in_background: Type.Optional(
|
|
@@ -221,6 +231,15 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
221
231
|
if (!ownsRecord(existing, context.parentAgentId)) {
|
|
222
232
|
return textResult(`Nested agent not found or not owned by this parent: "${params.resume}".`, true);
|
|
223
233
|
}
|
|
234
|
+
// Two resumes of one child in the same turn (parallel tool calls) would
|
|
235
|
+
// run two prompts on one session; the manager refuses the second, and
|
|
236
|
+
// this says why instead of a generic failure.
|
|
237
|
+
if (existing.status === "running" || existing.status === "queued") {
|
|
238
|
+
return textResult(
|
|
239
|
+
`Nested agent "${params.resume}" is still ${existing.status}; it can only be resumed once its current run finishes.`,
|
|
240
|
+
true,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
224
243
|
// Frontmatter authoritative, as on the spawn path; the manager falls
|
|
225
244
|
// back to the limit the child was spawned with, then the default.
|
|
226
245
|
const resumed = await context.manager.resume(params.resume, params.prompt, signal, {
|
|
@@ -264,40 +283,26 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
264
283
|
// Foreground regardless of `backgroundByDefault` — see the reasoning on
|
|
265
284
|
// ResolveOptions. An explicit `true` here still opts in.
|
|
266
285
|
const invocation = resolveAgentInvocationConfig(config, params, {
|
|
267
|
-
agentType: resolvedType,
|
|
268
286
|
worktreeAllowed: isWorktreeIsolationEnabled(),
|
|
269
287
|
defaultRunInBackground: false,
|
|
270
288
|
});
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (invocation.modelFromParams) return textResult(resolvedModel, true);
|
|
276
|
-
} else {
|
|
277
|
-
model = resolvedModel;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Same scopeModels policy as the top-level Agent tool — a nested spawn
|
|
282
|
-
// must not escape the allowlist. A "warn" verdict proceeds silently:
|
|
283
|
-
// child sessions have no UI surface to toast to.
|
|
284
|
-
const scopeVerdict = checkModelScope({
|
|
285
|
-
model,
|
|
286
|
-
cwd: context.configCwd,
|
|
287
|
-
modelRegistry: ctx.modelRegistry,
|
|
288
|
-
callerSupplied: invocation.modelFromParams,
|
|
289
|
-
agentLabel: config?.displayName ?? resolvedType,
|
|
290
|
-
modelInput: invocation.modelInput,
|
|
289
|
+
const selection = selectModel({
|
|
290
|
+
homeDir: context.homeDir,
|
|
291
|
+
agentType: resolvedType, definitionModel: config?.model, paramModel: params.model,
|
|
292
|
+
parentModel: ctx.model, registry: ctx.modelRegistry, cwd: context.configCwd, agentLabel: config?.displayName,
|
|
291
293
|
});
|
|
292
|
-
if (
|
|
294
|
+
if (typeof selection === "string") return textResult(selection, true);
|
|
295
|
+
const model = selection.model;
|
|
293
296
|
|
|
294
297
|
// The whole branch shares the root session's transcript directory; read it
|
|
295
298
|
// off the owning parent rather than this child session's own id.
|
|
296
299
|
const rootSessionId = context.manager.getRecord(context.parentAgentId)?.rootSessionId;
|
|
297
300
|
const childDepth = context.depth + 1;
|
|
298
301
|
const options: NestedSpawnOptions = {
|
|
302
|
+
homeDir: context.homeDir,
|
|
299
303
|
description: params.description,
|
|
300
304
|
model,
|
|
305
|
+
modelSelection: selection,
|
|
301
306
|
maxTurns: invocation.maxTurns,
|
|
302
307
|
isolated: invocation.isolated,
|
|
303
308
|
inheritContext: invocation.inheritContext,
|
package/src/output-file.ts
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* matching Claude Code's task output file format.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { type ModelFallback, modelFallbackNote } from "./model-resolver.js";
|
|
13
|
+
import { type TierDisclosure, tierDisclosure, tierNote } from "./model-tiers.js";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Project/global default for writing a subagent's `.output` transcript; a custom
|
|
@@ -80,6 +82,26 @@ export function ensureOutputFile(path: string): void {
|
|
|
80
82
|
} catch { /* ignore — streaming writes are best-effort too */ }
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/** Append the model-fallback disclosure as a transcript line (best effort, like streaming writes). */
|
|
86
|
+
export function appendModelFallbackEntry(path: string, agentId: string, fallback: ModelFallback): void {
|
|
87
|
+
try {
|
|
88
|
+
// A resumed agent reuses its transcript, which may already say it.
|
|
89
|
+
if (existsSync(path) && readFileSync(path, "utf-8").includes('"type":"model-fallback"')) return;
|
|
90
|
+
appendFileSync(
|
|
91
|
+
path,
|
|
92
|
+
`${JSON.stringify({ isSidechain: true, agentId, type: "model-fallback", modelFallback: fallback, note: modelFallbackNote(fallback), timestamp: new Date().toISOString() })}\n`,
|
|
93
|
+
"utf-8",
|
|
94
|
+
);
|
|
95
|
+
} catch { /* ignore */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Append tier provenance separately from assistant messages for durable replay. */
|
|
99
|
+
export function appendModelTierEntry(path: string, agentId: string, modelTier: TierDisclosure): void {
|
|
100
|
+
try {
|
|
101
|
+
appendFileSync(path, `${JSON.stringify({ isSidechain: true, agentId, type: "model-tier", modelTier: tierDisclosure(modelTier), note: tierNote(modelTier), timestamp: new Date().toISOString() })}\n`, "utf-8");
|
|
102
|
+
} catch { /* transcript writes are best effort */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
/** Write the initial user prompt entry. */
|
|
84
106
|
export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void {
|
|
85
107
|
const entry = {
|
package/src/package-resources.ts
CHANGED
|
@@ -55,7 +55,8 @@
|
|
|
55
55
|
|
|
56
56
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
57
57
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
58
|
-
import { DefaultPackageManager,
|
|
58
|
+
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
59
|
+
import { normalizeHomePath, resolveAgentDir } from "./home-paths.js";
|
|
59
60
|
|
|
60
61
|
/** Resource kinds a package may declare. Agents are `.md`, workflows are `.js`. */
|
|
61
62
|
export type PackageResourceKind = "agents" | "workflows";
|
|
@@ -103,6 +104,8 @@ export interface PiPackage {
|
|
|
103
104
|
export type PackageGate = boolean | string[] | undefined;
|
|
104
105
|
|
|
105
106
|
export interface PackageDiscoveryOptions {
|
|
107
|
+
/** Home directory for user settings and installed resources, resolved at call time. */
|
|
108
|
+
homeDir?: string;
|
|
106
109
|
/**
|
|
107
110
|
* Pi's project-trust state for `cwd`, from `ctx.isProjectTrusted()`. When
|
|
108
111
|
* false, `.pi/settings.json` is not read — so a package configured only by an
|
|
@@ -376,14 +379,26 @@ interface ConfiguredPackageRow {
|
|
|
376
379
|
* so a later pi that drops or renames this API contributes no packages instead
|
|
377
380
|
* of failing activation.
|
|
378
381
|
*/
|
|
379
|
-
function configuredPackages(cwd: string, projectTrusted: boolean): ConfiguredPackageRow[] {
|
|
382
|
+
function configuredPackages(cwd: string, projectTrusted: boolean, homeDir?: string): ConfiguredPackageRow[] {
|
|
380
383
|
try {
|
|
381
384
|
if (typeof DefaultPackageManager !== "function") return [];
|
|
382
|
-
const agentDir =
|
|
385
|
+
const agentDir = resolveAgentDir(homeDir);
|
|
383
386
|
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
|
384
387
|
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
385
388
|
if (typeof manager.listConfiguredPackages !== "function") return [];
|
|
386
|
-
return manager.listConfiguredPackages();
|
|
389
|
+
if (homeDir === undefined) return manager.listConfiguredPackages();
|
|
390
|
+
// Pi's enumerator expands local package sources through its own ambient
|
|
391
|
+
// home. Reuse its public installed-path resolver with normalized inputs,
|
|
392
|
+
// preserving the original source for package-gate matching and diagnostics.
|
|
393
|
+
const rows: ConfiguredPackageRow[] = [];
|
|
394
|
+
for (const scope of ["user", "project"] as const) {
|
|
395
|
+
const settings = scope === "user" ? settingsManager.getGlobalSettings() : settingsManager.getProjectSettings();
|
|
396
|
+
for (const pkg of settings.packages ?? []) {
|
|
397
|
+
const source = typeof pkg === "string" ? pkg : pkg.source;
|
|
398
|
+
rows.push({ source, scope, installedPath: manager.getInstalledPath(normalizeHomePath(source, homeDir), scope) });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return rows;
|
|
387
402
|
} catch {
|
|
388
403
|
return [];
|
|
389
404
|
}
|
|
@@ -394,10 +409,10 @@ function configuredPackages(cwd: string, projectTrusted: boolean): ConfiguredPac
|
|
|
394
409
|
* order (global entries first, then project). Duplicates by root are collapsed —
|
|
395
410
|
* a package listed in both scopes is one package on disk.
|
|
396
411
|
*/
|
|
397
|
-
export function listPiPackages(cwd: string, projectTrusted = false): PiPackage[] {
|
|
412
|
+
export function listPiPackages(cwd: string, projectTrusted = false, homeDir?: string): PiPackage[] {
|
|
398
413
|
const seen = new Set<string>();
|
|
399
414
|
const out: PiPackage[] = [];
|
|
400
|
-
for (const entry of configuredPackages(cwd, projectTrusted)) {
|
|
415
|
+
for (const entry of configuredPackages(cwd, projectTrusted, homeDir)) {
|
|
401
416
|
const root = entry.installedPath;
|
|
402
417
|
if (!root || seen.has(root)) continue;
|
|
403
418
|
const manifest = readPackageJson(root);
|
|
@@ -484,9 +499,9 @@ export function invalidatePackageCache(): void {
|
|
|
484
499
|
cache.clear();
|
|
485
500
|
}
|
|
486
501
|
|
|
487
|
-
function scan(cwd: string, projectTrusted: boolean, gate: PackageGate): CacheEntry {
|
|
502
|
+
function scan(cwd: string, projectTrusted: boolean, gate: PackageGate, homeDir?: string): CacheEntry {
|
|
488
503
|
const entry = emptyEntry();
|
|
489
|
-
for (const pkg of listPiPackages(cwd, projectTrusted)) {
|
|
504
|
+
for (const pkg of listPiPackages(cwd, projectTrusted, homeDir)) {
|
|
490
505
|
if (!packageAllowed(pkg, gate)) continue;
|
|
491
506
|
// The root is normalized here, not at lookup time: the declared paths below
|
|
492
507
|
// go through `resolve(root, entry)`, so a root that arrived with a trailing
|
|
@@ -520,10 +535,10 @@ function cacheEntryFor(cwd: string, kind: PackageResourceKind, opts: PackageDisc
|
|
|
520
535
|
// `\u0000` written as an escape, not a raw byte: a literal NUL makes this
|
|
521
536
|
// whole file read as binary to grep, ripgrep and git, which silently drops
|
|
522
537
|
// it from search results.
|
|
523
|
-
const key = `${cwd}\u0000${projectTrusted ? 1 : 0}\u0000${JSON.stringify(gate ?? true)}`;
|
|
538
|
+
const key = `${cwd}\u0000${opts.homeDir ?? ""}\u0000${resolveAgentDir(opts.homeDir)}\u0000${projectTrusted ? 1 : 0}\u0000${JSON.stringify(gate ?? true)}`;
|
|
524
539
|
let hit = cache.get(key);
|
|
525
540
|
if (!hit) {
|
|
526
|
-
hit = scan(cwd, projectTrusted, gate);
|
|
541
|
+
hit = scan(cwd, projectTrusted, gate, opts.homeDir);
|
|
527
542
|
cache.set(key, hit);
|
|
528
543
|
}
|
|
529
544
|
return hit;
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* process-budget.ts -- one ceiling on running subagents for the whole pi process.
|
|
3
|
+
*
|
|
4
|
+
* `maxConcurrent` and `maxConcurrentForeground` are per `AgentManager`, and
|
|
5
|
+
* there can be more than one manager per process: `/new`, `/resume`, `/fork`
|
|
6
|
+
* and `/reload` activate a fresh one and leave the previous manager alive until
|
|
7
|
+
* its background agents finish. (Subagent sessions do not activate this
|
|
8
|
+
* extension; nested children run in the parent's manager.) Those pools also
|
|
9
|
+
* exempt nested children, workflow children, scheduled jobs and resumes. The budget is the one count all of them share, kept on `globalThis`
|
|
10
|
+
* under `Symbol.for` so every manager and every module instance (a `/reload`
|
|
11
|
+
* loads a fresh copy of this file) sees the same state.
|
|
12
|
+
*
|
|
13
|
+
* Scope is the PROCESS, not the machine: two pi processes each get their own
|
|
14
|
+
* budget. It bounds running agents, not CPU, memory or commands.
|
|
15
|
+
*
|
|
16
|
+
* Every run holds one lease from the moment it is admitted until it actually
|
|
17
|
+
* settles. A parent keeps its lease while it waits on a child: releasing it
|
|
18
|
+
* around a nested call would be wrong, because pi runs a turn's tool calls in
|
|
19
|
+
* parallel and the parent's other tools are still executing.
|
|
20
|
+
*
|
|
21
|
+
* Holding parent leases is what makes a plain cap deadlock: twelve parents
|
|
22
|
+
* each waiting on a child that can never start. The reserve prevents it. With
|
|
23
|
+
* `L` the policy's `maxDepth` (one value for the whole process, NOT a branch's
|
|
24
|
+
* own cap) and `limit(j) = maxActive - nestedReserve * (L - j)`, a run at depth
|
|
25
|
+
* `d` is admitted only while, for EVERY level `j` from `d` to `L`,
|
|
26
|
+
*
|
|
27
|
+
* active at depth <= j < limit(j)
|
|
28
|
+
*
|
|
29
|
+
* (`j = L` is the plain `active < maxActive`). So with `maxActive: 12,
|
|
30
|
+
* nestedReserve: 2, L: 2`, at most ten top-level runs hold slots at once, and
|
|
31
|
+
* the last two only ever go to depth-2 children. Every admission keeps
|
|
32
|
+
* `count(<= j) <= limit(j)` for all `j`, releases only lower counts, and
|
|
33
|
+
* policy changes that could break it wait for an idle process (see
|
|
34
|
+
* `setBudgetPolicy`). Suppose every holder is a parent blocked on a child and
|
|
35
|
+
* `k < L` is the deepest level holding any: then `active = count(<= k) <=
|
|
36
|
+
* limit(k) < limit(k + 1) <= maxActive`, so a depth-`k+1` child fits at every
|
|
37
|
+
* level. Leaves (depth `L`) never wait on anyone, so they finish. That needs
|
|
38
|
+
* `limit` strictly increasing and `limit(1) >= 1` -- `isCoherent` -- and a run
|
|
39
|
+
* deeper than `L` refused outright (`assertWithinBudgetDepth`), since nothing
|
|
40
|
+
* reserves a slot for it. Top-level, workflow and scheduled runs all count as
|
|
41
|
+
* depth 1.
|
|
42
|
+
*
|
|
43
|
+
* Waiters are served first-come-first-served among the ELIGIBLE: a deeper
|
|
44
|
+
* waiter may pass a shallower one whose threshold is not met, which is what the
|
|
45
|
+
* reserve is for. Without that, a root at the head of the line would block the
|
|
46
|
+
* child that its own siblings are waiting on.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const BUDGET_KEY = Symbol.for("pi-subagents:process-budget");
|
|
50
|
+
|
|
51
|
+
/** Bumped on any incompatible change to `BudgetState`'s shape. */
|
|
52
|
+
const BUDGET_VERSION = 2;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Queued starts the budget will hold while it is enabled. Only a bound on
|
|
56
|
+
* memory and on runaway fan-out -- a full queue fails the spawn with
|
|
57
|
+
* `BUDGET_QUEUE_FULL_ERROR` instead of parking work nobody will see start.
|
|
58
|
+
*/
|
|
59
|
+
export const MAX_PENDING_SUBAGENTS = 128;
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_BUDGET_POLICY: Readonly<BudgetPolicy> = {
|
|
62
|
+
maxActive: 0,
|
|
63
|
+
nestedReserve: 2,
|
|
64
|
+
maxDepth: 2,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface BudgetPolicy {
|
|
68
|
+
/** Process-wide ceiling on running subagents. `0` = off (counted, never enforced). */
|
|
69
|
+
maxActive: number;
|
|
70
|
+
/** Slots held back per nesting level below a run, so children can always start. */
|
|
71
|
+
nestedReserve: number;
|
|
72
|
+
/**
|
|
73
|
+
* The deepest level the reserve covers: the GLOBAL `maxSubagentDepth`, so a
|
|
74
|
+
* project file cannot reshape a process-wide policy. A deeper run is refused.
|
|
75
|
+
*/
|
|
76
|
+
maxDepth: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One admitted run. Released exactly once; later releases are no-ops. */
|
|
80
|
+
export interface BudgetLease {
|
|
81
|
+
readonly depth: number;
|
|
82
|
+
released: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A start waiting for admission. */
|
|
86
|
+
export interface BudgetWaiter {
|
|
87
|
+
/** Nesting depth of the run: top-level = 1. */
|
|
88
|
+
depth: number;
|
|
89
|
+
/** Local conditions the owner also needs (its own pool limits). */
|
|
90
|
+
ready: () => boolean;
|
|
91
|
+
/** Takes ownership of the lease. Must not throw; a throw is swallowed and the lease released. */
|
|
92
|
+
grant: (lease: BudgetLease) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface BudgetState {
|
|
96
|
+
version: number;
|
|
97
|
+
policy: BudgetPolicy;
|
|
98
|
+
/** A tightening change, applied once no lease is active (`setBudgetPolicy`). */
|
|
99
|
+
pendingPolicy: BudgetPolicy | undefined;
|
|
100
|
+
active: Set<BudgetLease>;
|
|
101
|
+
waiters: BudgetWaiter[];
|
|
102
|
+
pumping: boolean;
|
|
103
|
+
repump: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const BUDGET_QUEUE_FULL_ERROR =
|
|
107
|
+
`Subagent queue is full (${MAX_PENDING_SUBAGENTS} waiting for the process-wide maxActiveSubagents limit). `
|
|
108
|
+
+ "Wait for running agents to finish, or do the work directly.";
|
|
109
|
+
|
|
110
|
+
function freshState(): BudgetState {
|
|
111
|
+
return {
|
|
112
|
+
version: BUDGET_VERSION,
|
|
113
|
+
policy: { ...DEFAULT_BUDGET_POLICY },
|
|
114
|
+
pendingPolicy: undefined,
|
|
115
|
+
active: new Set(),
|
|
116
|
+
waiters: [],
|
|
117
|
+
pumping: false,
|
|
118
|
+
repump: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The process's budget state. An incompatible version (a `/reload` onto a
|
|
124
|
+
* build that changed the shape) refuses budget operations until pi restarts.
|
|
125
|
+
* Never fork the state: two private counts could each admit the whole cap.
|
|
126
|
+
*/
|
|
127
|
+
function state(): BudgetState {
|
|
128
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
129
|
+
const existing = g[BUDGET_KEY] as BudgetState | undefined;
|
|
130
|
+
if (existing === undefined) {
|
|
131
|
+
const created = freshState();
|
|
132
|
+
g[BUDGET_KEY] = created;
|
|
133
|
+
return created;
|
|
134
|
+
}
|
|
135
|
+
if (existing.version === BUDGET_VERSION) return existing;
|
|
136
|
+
throw new Error(
|
|
137
|
+
`[pi-subagents] Incompatible process budget v${String(existing.version)} already loaded (this build uses v${String(BUDGET_VERSION)}). `
|
|
138
|
+
+ "Restart pi before starting more subagents; no private budget will be created.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The deepest level `policy` reserves for; nesting off still means one level. */
|
|
143
|
+
const topLevel = (policy: BudgetPolicy): number => Math.max(1, policy.maxDepth);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* How many runs at depth <= `level` may hold leases before a run at `level` or
|
|
147
|
+
* shallower has to wait. No floor: `isCoherent` keeps it at least one.
|
|
148
|
+
*/
|
|
149
|
+
export function admissionLimit(policy: BudgetPolicy, level: number): number {
|
|
150
|
+
if (policy.maxActive <= 0) return Number.POSITIVE_INFINITY;
|
|
151
|
+
return policy.maxActive - policy.nestedReserve * (topLevel(policy) - level);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether `policy` is deadlock-free: off, or `limit` strictly increasing from
|
|
156
|
+
* at least one at depth 1 -- a reserve of one or more, and room for one
|
|
157
|
+
* top-level run after reserving for every level below it.
|
|
158
|
+
*/
|
|
159
|
+
export function isCoherent(policy: BudgetPolicy): boolean {
|
|
160
|
+
if (policy.maxActive <= 0 || topLevel(policy) === 1) return true;
|
|
161
|
+
return policy.nestedReserve >= 1 && admissionLimit(policy, 1) >= 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Every level's limit is at least what it was, so no held lease can block a child that fit before. */
|
|
165
|
+
function relaxes(from: BudgetPolicy, to: BudgetPolicy): boolean {
|
|
166
|
+
if (to.maxActive <= 0) return true;
|
|
167
|
+
if (from.maxActive <= 0 || topLevel(from) !== topLevel(to)) return false;
|
|
168
|
+
for (let level = 1; level <= topLevel(to); level++) {
|
|
169
|
+
if (admissionLimit(to, level) < admissionLimit(from, level)) return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function getBudgetPolicy(): BudgetPolicy {
|
|
175
|
+
return { ...state().policy };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Replace the policy. Last writer wins: every activation in the process
|
|
180
|
+
* shares it. An incoherent policy is refused with a warning. A change that
|
|
181
|
+
* could strand a waiting child -- turning the budget on, lowering a limit,
|
|
182
|
+
* changing `maxDepth` -- is held until no lease is active, since running
|
|
183
|
+
* parents were admitted under the old limits; turning it off or raising
|
|
184
|
+
* limits applies at once.
|
|
185
|
+
*/
|
|
186
|
+
export function setBudgetPolicy(next: BudgetPolicy): void {
|
|
187
|
+
const policy: BudgetPolicy = {
|
|
188
|
+
maxActive: Math.max(0, Math.floor(next.maxActive)),
|
|
189
|
+
nestedReserve: Math.max(0, Math.floor(next.nestedReserve)),
|
|
190
|
+
maxDepth: Math.max(0, Math.floor(next.maxDepth)),
|
|
191
|
+
};
|
|
192
|
+
if (!isCoherent(policy)) {
|
|
193
|
+
console.warn(
|
|
194
|
+
`[pi-subagents] Ignoring maxActiveSubagents ${String(policy.maxActive)} with nestedSubagentReserve ${String(policy.nestedReserve)} `
|
|
195
|
+
+ `at maxSubagentDepth ${String(policy.maxDepth)}: it could deadlock. Nesting needs a reserve of at least 1 and `
|
|
196
|
+
+ "maxActiveSubagents >= nestedSubagentReserve * (maxSubagentDepth - 1) + 1.",
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const s = state();
|
|
201
|
+
if (s.active.size === 0 || relaxes(s.policy, policy)) {
|
|
202
|
+
s.policy = policy;
|
|
203
|
+
s.pendingPolicy = undefined;
|
|
204
|
+
pumpBudget();
|
|
205
|
+
} else {
|
|
206
|
+
s.pendingPolicy = policy;
|
|
207
|
+
console.warn(
|
|
208
|
+
`[pi-subagents] Process budget change deferred until all running subagents settle: `
|
|
209
|
+
+ `maxActiveSubagents=${String(policy.maxActive)}, nestedSubagentReserve=${String(policy.nestedReserve)}, `
|
|
210
|
+
+ `maxSubagentDepth=${String(policy.maxDepth)}. The previous policy remains in force; continuous work can delay this indefinitely.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The policy waiting for the process to go idle, if any. For diagnostics and tests. */
|
|
216
|
+
export function getPendingBudgetPolicy(): BudgetPolicy | undefined {
|
|
217
|
+
const pending = state().pendingPolicy;
|
|
218
|
+
return pending === undefined ? undefined : { ...pending };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Throw when the budget is on and a run at `depth` is deeper than its policy
|
|
223
|
+
* reserves for: it could wait forever behind its own ancestors. Pass/fail is
|
|
224
|
+
* fixed for the run's life, since `maxDepth` changes only while idle.
|
|
225
|
+
*/
|
|
226
|
+
export function assertWithinBudgetDepth(depth: number): void {
|
|
227
|
+
const { policy } = state();
|
|
228
|
+
if (policy.maxActive > 0 && depth > topLevel(policy)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Nested subagent at depth ${String(depth)} exceeds the process budget's depth ${String(topLevel(policy))} `
|
|
231
|
+
+ "(global maxSubagentDepth). Complete the task directly.",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Running leases and waiting starts, for diagnostics and tests. */
|
|
237
|
+
export function budgetUsage(): { active: number; pending: number; maxActive: number } {
|
|
238
|
+
const s = state();
|
|
239
|
+
return { active: s.active.size, pending: s.waiters.length, maxActive: s.policy.maxActive };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function admits(s: BudgetState, depth: number): boolean {
|
|
243
|
+
const { policy } = s;
|
|
244
|
+
if (policy.maxActive <= 0) return true;
|
|
245
|
+
const top = topLevel(policy);
|
|
246
|
+
const level = Math.max(1, depth);
|
|
247
|
+
if (level > top) return false;
|
|
248
|
+
// Leases deeper than `top` cannot exist while it is on; clamp so they still count in the total.
|
|
249
|
+
const atDepth = new Array<number>(top + 1).fill(0);
|
|
250
|
+
for (const lease of s.active) {
|
|
251
|
+
const index = Math.min(top, Math.max(1, lease.depth));
|
|
252
|
+
atDepth[index] = atDepth[index]! + 1;
|
|
253
|
+
}
|
|
254
|
+
let atOrAbove = 0;
|
|
255
|
+
for (let j = 1; j <= top; j++) {
|
|
256
|
+
atOrAbove += atDepth[j]!;
|
|
257
|
+
if (j >= level && atOrAbove >= admissionLimit(policy, j)) return false;
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function newLease(s: BudgetState, depth: number): BudgetLease {
|
|
263
|
+
const lease: BudgetLease = { depth, released: false };
|
|
264
|
+
s.active.add(lease);
|
|
265
|
+
return lease;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Take a lease now, or undefined when this depth is at its limit or an earlier
|
|
270
|
+
* eligible waiter has first claim. Completion listeners can spawn synchronously
|
|
271
|
+
* between release and drain; re-entrant grant callbacks can do the same.
|
|
272
|
+
*/
|
|
273
|
+
export function tryAcquireBudget(depth: number): BudgetLease | undefined {
|
|
274
|
+
const s = state();
|
|
275
|
+
if (!admits(s, depth) || s.waiters.some(waiter => admits(s, waiter.depth) && waiter.ready())) return undefined;
|
|
276
|
+
return newLease(s, depth);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Whether another waiter fits. Always true while the budget is off. */
|
|
280
|
+
export function budgetHasPendingRoom(): boolean {
|
|
281
|
+
const s = state();
|
|
282
|
+
return s.policy.maxActive <= 0 || s.waiters.length < MAX_PENDING_SUBAGENTS;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Join the queue. Returns a cancel function; cancelling a granted waiter is a no-op. */
|
|
286
|
+
export function enqueueBudget(waiter: BudgetWaiter): () => void {
|
|
287
|
+
state().waiters.push(waiter);
|
|
288
|
+
return () => {
|
|
289
|
+
const s = state();
|
|
290
|
+
const i = s.waiters.indexOf(waiter);
|
|
291
|
+
if (i !== -1) s.waiters.splice(i, 1);
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Give a lease back. Idempotent; does not pump, so callers keep their own
|
|
297
|
+
* ordering. The last release applies a held policy change.
|
|
298
|
+
*/
|
|
299
|
+
export function releaseBudget(lease: BudgetLease | undefined): void {
|
|
300
|
+
if (lease === undefined || lease.released) return;
|
|
301
|
+
lease.released = true;
|
|
302
|
+
const s = state();
|
|
303
|
+
s.active.delete(lease);
|
|
304
|
+
if (s.active.size === 0 && s.pendingPolicy !== undefined) {
|
|
305
|
+
s.policy = s.pendingPolicy;
|
|
306
|
+
s.pendingPolicy = undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Admit every waiter that fits, earliest first, re-scanning after each grant
|
|
312
|
+
* because a grant changes the counts (`ready()` reads the owner's pools).
|
|
313
|
+
* Re-entrant calls from inside a grant are folded into the running loop.
|
|
314
|
+
*/
|
|
315
|
+
export function pumpBudget(): void {
|
|
316
|
+
const s = state();
|
|
317
|
+
if (s.pumping) {
|
|
318
|
+
s.repump = true;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
s.pumping = true;
|
|
322
|
+
try {
|
|
323
|
+
do {
|
|
324
|
+
s.repump = false;
|
|
325
|
+
for (;;) {
|
|
326
|
+
const i = s.waiters.findIndex(w => admits(s, w.depth) && w.ready());
|
|
327
|
+
if (i === -1) break;
|
|
328
|
+
const waiter = s.waiters.splice(i, 1)[0]!;
|
|
329
|
+
const lease = newLease(s, waiter.depth);
|
|
330
|
+
try {
|
|
331
|
+
waiter.grant(lease);
|
|
332
|
+
} catch {
|
|
333
|
+
releaseBudget(lease);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} while (s.repump);
|
|
337
|
+
} finally {
|
|
338
|
+
s.pumping = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Test-only: forget all leases and waiters and restore the default policy. */
|
|
343
|
+
export function resetBudgetForTests(): void {
|
|
344
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
345
|
+
g[BUDGET_KEY] = freshState();
|
|
346
|
+
}
|
package/src/quota-admission.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Quota tracking lives in a separate extension (nredd/pi-subscription-router),
|
|
5
5
|
* reached over `pi.events` with the same envelope as `cross-extension-rpc.ts`:
|
|
6
6
|
* emit `router:rpc:admit` `{ requestId, model }`, reply on
|
|
7
|
-
* `router:rpc:admit:reply:<requestId>` as `{ success, data?: { ok, resolvedModel?, resetAt?, wait? } }` (`resolvedModel`
|
|
7
|
+
* `router:rpc:admit:reply:<requestId>` as `{ success, data?: { ok, resolvedModel?, resetAt?, wait? } }` (`resolvedModel` labels the admitted physical model).
|
|
8
8
|
*
|
|
9
9
|
* Admission fails open. No router installed, a slow router, a malformed reply
|
|
10
10
|
* or a router error all admit the dispatch: quota is advisory here, and the
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { nanoid } from "nanoid";
|
|
15
15
|
import { type Clock, realClock } from "./clock.js";
|
|
16
|
+
import type { TierAdmission } from "./model-tiers.js";
|
|
16
17
|
|
|
17
18
|
export const ADMIT_CHANNEL = "router:rpc:admit";
|
|
18
19
|
|
|
@@ -50,34 +51,45 @@ export function modelRef(model: { provider: string; id: string }): string {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/** Resolve to the block when the router refuses `model`, else undefined. */
|
|
53
|
-
export function checkAdmission(
|
|
54
|
+
export async function checkAdmission(
|
|
54
55
|
events: AdmissionEvents,
|
|
55
56
|
model: string,
|
|
56
57
|
timeoutMs = ADMIT_TIMEOUT_MS,
|
|
57
58
|
clock: Clock = realClock,
|
|
58
59
|
): Promise<QuotaBlock | undefined> {
|
|
60
|
+
return (await admissionOutcome(events, model, timeoutMs, clock)).block;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Retain the physical admission label and whether the router supplied a signal. */
|
|
64
|
+
export function admissionOutcome(
|
|
65
|
+
events: AdmissionEvents,
|
|
66
|
+
model: string,
|
|
67
|
+
timeoutMs = ADMIT_TIMEOUT_MS,
|
|
68
|
+
clock: Clock = realClock,
|
|
69
|
+
): Promise<TierAdmission> {
|
|
59
70
|
const requestId = nanoid();
|
|
60
71
|
return new Promise(resolve => {
|
|
61
72
|
let done = false;
|
|
62
|
-
const finish = (
|
|
73
|
+
const finish = (outcome: TierAdmission) => {
|
|
63
74
|
if (done) return;
|
|
64
75
|
done = true;
|
|
65
76
|
timer.cancel();
|
|
66
77
|
unsubscribe();
|
|
67
|
-
resolve(
|
|
78
|
+
resolve(outcome);
|
|
68
79
|
};
|
|
69
80
|
const unsubscribe = events.on(`${ADMIT_CHANNEL}:reply:${requestId}`, raw => {
|
|
70
|
-
const reply = raw as { success?: unknown; data?: { ok?: unknown; resetAt?: unknown; wait?: unknown } } | undefined;
|
|
71
|
-
if (reply?.success !== true || reply.data?.ok !==
|
|
81
|
+
const reply = raw as { success?: unknown; data?: { ok?: unknown; resolvedModel?: unknown; resetAt?: unknown; wait?: unknown } } | undefined;
|
|
82
|
+
if (reply?.success !== true || typeof reply.data?.ok !== "boolean") return finish({ quotaSignal: false });
|
|
83
|
+
if (reply.data.ok) return finish({ quotaSignal: true, resolvedModel: typeof reply.data.resolvedModel === "string" ? reply.data.resolvedModel : undefined });
|
|
72
84
|
const resetAt = reply.data.resetAt;
|
|
73
85
|
const wait = reply.data.wait;
|
|
74
|
-
finish({
|
|
86
|
+
finish({ quotaSignal: true, block: {
|
|
75
87
|
model,
|
|
76
88
|
resetAt: typeof resetAt === "number" && Number.isFinite(resetAt) ? resetAt : undefined,
|
|
77
89
|
...(typeof wait === "boolean" ? { wait } : {}),
|
|
78
|
-
});
|
|
90
|
+
} });
|
|
79
91
|
});
|
|
80
|
-
const timer = clock.setTimeout(() => finish(
|
|
92
|
+
const timer = clock.setTimeout(() => finish({ quotaSignal: false }), timeoutMs);
|
|
81
93
|
events.emit(ADMIT_CHANNEL, { requestId, model });
|
|
82
94
|
});
|
|
83
95
|
}
|