@signalridge/pi-subagents 1.8.0 → 1.9.0
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/CHANGELOG.md +170 -0
- package/README.md +85 -55
- package/package.json +2 -2
- package/src/agent-manager.ts +235 -120
- package/src/agent-runner.ts +90 -72
- package/src/agent-tiers.ts +236 -70
- package/src/cross-extension-rpc.ts +6 -0
- package/src/default-agents.ts +3 -3
- package/src/index.ts +149 -139
- package/src/internal-run.ts +14 -0
- package/src/invocation-config.ts +24 -13
- package/src/model-resolver.ts +12 -0
- package/src/model-scope.ts +5 -2
- package/src/nested-tools.ts +35 -22
- package/src/schedule.ts +3 -0
- package/src/settings.ts +68 -247
- package/src/types.ts +10 -13
- package/src/ui/agent-display.ts +1 -1
- package/src/workflow-tiers.ts +0 -202
package/src/agent-manager.ts
CHANGED
|
@@ -12,20 +12,21 @@ import { realpathSync, statSync } from "node:fs";
|
|
|
12
12
|
import { isAbsolute, resolve } from "node:path";
|
|
13
13
|
import type { Model } from "@earendil-works/pi-ai";
|
|
14
14
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import type { ManagedSpawnRequest as ProtocolManagedSpawnRequest
|
|
16
|
-
import {
|
|
15
|
+
import type { ManagedSpawnRequest as ProtocolManagedSpawnRequest } from "@signalridge/pi-subagents-protocol";
|
|
16
|
+
import { isManagedAgentTier, parseManagedSpawnRequest } from "@signalridge/pi-subagents-protocol";
|
|
17
17
|
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
18
|
-
import type
|
|
18
|
+
import { type AgentTierResolutionSnapshot, getAgentTiersSettings } from "./agent-tiers.js";
|
|
19
19
|
import {
|
|
20
20
|
INTERNAL_AGENT_CONFIG_OVERRIDE,
|
|
21
|
+
INTERNAL_PARENT_POLICY_SNAPSHOT,
|
|
21
22
|
type InternalAgentConfigOverride,
|
|
23
|
+
type ParentPolicySnapshot,
|
|
22
24
|
} from "./internal-run.js";
|
|
23
25
|
import { assignHandle, handleBase } from "./mention.js";
|
|
24
26
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
25
|
-
import type {
|
|
27
|
+
import type { TierThinking } from "./settings.js";
|
|
26
28
|
import type { AgentInvocation, AgentOwner, AgentRecord, AgentRecordSnapshot, IsolationMode, ResumableAgentEntry, SubagentType, ThinkingLevel } from "./types.js";
|
|
27
29
|
import { addUsage } from "./usage.js";
|
|
28
|
-
import type { WorkflowTierResolutionSnapshot } from "./workflow-tiers.js";
|
|
29
30
|
import {
|
|
30
31
|
cleanupWorktree,
|
|
31
32
|
cleanupWorktreeAsync,
|
|
@@ -241,7 +242,14 @@ interface SpawnArgs {
|
|
|
241
242
|
export type ManagedSpawnRequest = ProtocolManagedSpawnRequest;
|
|
242
243
|
|
|
243
244
|
export const MANAGED_SPAWN_ENTRY_TYPE = "subagents:managed-spawn";
|
|
244
|
-
|
|
245
|
+
/** Managed tombstone schema v2; schema-v1 facts are quarantined rather than reused. */
|
|
246
|
+
export const MANAGED_SPAWN_SCHEMA_VERSION = 2;
|
|
247
|
+
/**
|
|
248
|
+
* How many retired spawn keys to remember. Generous — a run rotates its attempt
|
|
249
|
+
* id on resume, so collisions need a key that was live across a branch
|
|
250
|
+
* replacement and is then re-requested verbatim.
|
|
251
|
+
*/
|
|
252
|
+
const MANAGED_RETIRED_KEY_LIMIT = 4_096;
|
|
245
253
|
const MANAGED_TEXT_LIMIT = 8_000;
|
|
246
254
|
const MANAGED_ERROR_LIMIT = 2_000;
|
|
247
255
|
const MANAGED_PATH_LIMIT = 2_000;
|
|
@@ -276,14 +284,14 @@ export interface ManagedSpawnTombstone {
|
|
|
276
284
|
type: string;
|
|
277
285
|
description: string;
|
|
278
286
|
owner: AgentOwner;
|
|
279
|
-
/**
|
|
280
|
-
tier?:
|
|
287
|
+
/** Agent tier this spawn resolved to, including one the host defaulted to. */
|
|
288
|
+
tier?: string;
|
|
281
289
|
/** Named managed thread, when this spawn re-enters a sequential session. */
|
|
282
290
|
thread?: string;
|
|
283
291
|
/** Effective policy fingerprint that makes thread reuse safe across calls/reloads. */
|
|
284
292
|
threadPolicyFingerprint?: string;
|
|
285
|
-
/** Internal audit snapshot of the resolved
|
|
286
|
-
tierSnapshot?:
|
|
293
|
+
/** Internal audit snapshot of the resolved tier policy. */
|
|
294
|
+
tierSnapshot?: AgentTierResolutionSnapshot;
|
|
287
295
|
state: ManagedSpawnState;
|
|
288
296
|
createdAt: number;
|
|
289
297
|
updatedAt: number;
|
|
@@ -294,6 +302,8 @@ export interface ManagedSpawnTombstone {
|
|
|
294
302
|
/** Response returned by managed spawn, including an already-settled outcome. */
|
|
295
303
|
export interface ManagedSpawnResult {
|
|
296
304
|
id: string;
|
|
305
|
+
/** Agent tier the manager selected, echoed so the caller can journal it. */
|
|
306
|
+
tier?: string;
|
|
297
307
|
state: ManagedSpawnState;
|
|
298
308
|
/** True only when this call allocated a new tombstone/Agent record. */
|
|
299
309
|
created: boolean;
|
|
@@ -352,19 +362,13 @@ function normalizeManagedOwner(raw: unknown, requireAttempt = false): AgentOwner
|
|
|
352
362
|
};
|
|
353
363
|
}
|
|
354
364
|
|
|
355
|
-
function normalizeManagedSpawnRequest(raw: unknown): ManagedSpawnRequest {
|
|
356
|
-
return parseManagedSpawnRequest(raw);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
365
|
function managedFingerprint(request: ManagedSpawnRequest, policyFingerprint?: string): string {
|
|
360
366
|
return createHash("sha256")
|
|
361
367
|
.update(JSON.stringify([
|
|
362
368
|
request.type,
|
|
363
369
|
request.prompt,
|
|
364
370
|
request.description,
|
|
365
|
-
|
|
366
|
-
request.model,
|
|
367
|
-
request.thinking,
|
|
371
|
+
request.tier,
|
|
368
372
|
request.toolset,
|
|
369
373
|
request.excludeTools,
|
|
370
374
|
request.isolation,
|
|
@@ -378,50 +382,73 @@ function managedFingerprint(request: ManagedSpawnRequest, policyFingerprint?: st
|
|
|
378
382
|
.digest("hex");
|
|
379
383
|
}
|
|
380
384
|
|
|
381
|
-
|
|
382
|
-
function
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
request.description,
|
|
388
|
-
...(request.tier === undefined ? [] : [request.tier]),
|
|
389
|
-
request.owner.extension,
|
|
390
|
-
request.owner.runId,
|
|
391
|
-
request.owner.nodeId,
|
|
392
|
-
request.owner.attemptId,
|
|
393
|
-
]))
|
|
394
|
-
.digest("hex");
|
|
385
|
+
|
|
386
|
+
function currentParentThinkingLevel(pi: ExtensionAPI): ThinkingLevel | undefined {
|
|
387
|
+
const level = pi.getThinkingLevel?.();
|
|
388
|
+
return level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max"
|
|
389
|
+
? level
|
|
390
|
+
: undefined;
|
|
395
391
|
}
|
|
396
392
|
|
|
397
393
|
/**
|
|
398
394
|
* Policy identity for a managed spawn. Prompts and owner attempt identities
|
|
399
395
|
* intentionally do not participate: a thread is allowed to receive new work,
|
|
400
396
|
* but its session cannot safely change model/tool/isolation policy.
|
|
397
|
+
*
|
|
398
|
+
* The tier's *resolved* model and thinking participate, not just its key: two
|
|
399
|
+
* calls naming the same tier are only the same policy while that tier still
|
|
400
|
+
* means the same thing. A profile that inherits folds in the parent identity
|
|
401
|
+
* for the same reason — it is the value that will actually run.
|
|
401
402
|
*/
|
|
402
|
-
function managedThreadPolicyFingerprint(
|
|
403
|
-
|
|
403
|
+
function managedThreadPolicyFingerprint(
|
|
404
|
+
request: ManagedSpawnRequest,
|
|
405
|
+
policy: ManagedSpawnPolicy,
|
|
406
|
+
parentModel: { provider?: unknown; id?: unknown } | undefined,
|
|
407
|
+
parentThinking: ThinkingLevel | undefined,
|
|
408
|
+
): string {
|
|
409
|
+
const effectiveTier = request.tier ?? policy.invocation?.agentTier;
|
|
410
|
+
const profile = effectiveTier ? getAgentTiersSettings().profiles?.[effectiveTier] : undefined;
|
|
411
|
+
const inheritedModelIdentity =
|
|
412
|
+
profile?.model === "inherit" ? { provider: parentModel?.provider, id: parentModel?.id } : undefined;
|
|
413
|
+
const inheritedThinkingIdentity = profile?.thinking === "inherit" ? parentThinking : undefined;
|
|
404
414
|
const excludeTools = [...new Set([...(request.excludeTools ?? []), ...(policy.excludeTools ?? [])])].sort();
|
|
405
415
|
return createHash("sha256")
|
|
406
416
|
.update(JSON.stringify([
|
|
407
417
|
request.type,
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
418
|
+
effectiveTier,
|
|
419
|
+
profile?.model,
|
|
420
|
+
profile?.thinking,
|
|
421
|
+
inheritedModelIdentity,
|
|
422
|
+
inheritedThinkingIdentity,
|
|
411
423
|
request.toolset ?? policy.toolset,
|
|
412
424
|
excludeTools,
|
|
413
425
|
request.isolation ?? policy.isolation,
|
|
414
426
|
policy.maxTurns,
|
|
415
427
|
policy.isolated,
|
|
416
428
|
policy.inheritContext,
|
|
417
|
-
policy.thinkingLevel,
|
|
418
429
|
policy.policyFingerprint,
|
|
419
|
-
model?.provider,
|
|
420
|
-
model?.id,
|
|
421
430
|
]))
|
|
422
431
|
.digest("hex");
|
|
423
432
|
}
|
|
424
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Name what a caller can actually act on.
|
|
436
|
+
*
|
|
437
|
+
* The common cause is not a changed script: every shipped tier profile inherits
|
|
438
|
+
* its model, so switching the session's model changes what the thread's tier
|
|
439
|
+
* resolves to and invalidates the session it was reusing. A message that only
|
|
440
|
+
* says "policy changed" sends the reader looking in the wrong file.
|
|
441
|
+
*/
|
|
442
|
+
function threadPolicyConflictMessage(thread: string, tier: string | undefined): string {
|
|
443
|
+
const cause = tier
|
|
444
|
+
? `Its tier "${tier}", the session model or thinking level that tier inherits, or its tool/isolation policy changed since the thread's last call.`
|
|
445
|
+
: "Its model, thinking, tool, or isolation policy changed since the thread's last call.";
|
|
446
|
+
return (
|
|
447
|
+
`Managed workflow thread policy conflict: "${thread}". ${cause} ` +
|
|
448
|
+
"A thread reuses one session, which cannot safely change policy mid-conversation; use a new thread name."
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
425
452
|
function isManagedState(value: unknown): value is ManagedSpawnState {
|
|
426
453
|
return value === "queued" || value === "running" || value === "completed" || value === "failed" || value === "stopped" || value === "interrupted";
|
|
427
454
|
}
|
|
@@ -450,62 +477,50 @@ function cloneManagedTombstone(tombstone: ManagedSpawnTombstone): ManagedSpawnTo
|
|
|
450
477
|
};
|
|
451
478
|
}
|
|
452
479
|
|
|
453
|
-
const
|
|
454
|
-
const WORKFLOW_CONFIGURED_THINKING = new Set<WorkflowThinking>([...WORKFLOW_THINKING_LEVELS, "inherit"]);
|
|
480
|
+
const THINKING_LEVELS = new Set<ThinkingLevel>(["minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
455
481
|
const TIER_SNAPSHOT_KEYS = new Set([
|
|
456
482
|
"tier",
|
|
483
|
+
"source",
|
|
457
484
|
"model",
|
|
458
485
|
"thinking",
|
|
459
486
|
"configuredModel",
|
|
460
487
|
"configuredThinking",
|
|
461
488
|
"requestedThinking",
|
|
462
|
-
"modelSource",
|
|
463
|
-
"thinkingSource",
|
|
464
489
|
"clamped",
|
|
465
490
|
"diagnostic",
|
|
466
491
|
]);
|
|
467
492
|
|
|
468
|
-
function parseTierSnapshot(raw: unknown, tier:
|
|
469
|
-
if (!isRecord(raw) || raw.tier !== tier) return undefined;
|
|
493
|
+
function parseTierSnapshot(raw: unknown, tier: string): AgentTierResolutionSnapshot | undefined {
|
|
494
|
+
if (!isRecord(raw) || raw.tier !== tier || !isManagedAgentTier(tier)) return undefined;
|
|
470
495
|
try {
|
|
471
496
|
rejectManagedKeys(raw, TIER_SNAPSHOT_KEYS, "tierSnapshot");
|
|
472
|
-
const source = raw.
|
|
473
|
-
|
|
474
|
-
if ((source !== "frontmatter" && source !== "tier" && source !== "parent") ||
|
|
475
|
-
(thinkingSource !== "frontmatter" && thinkingSource !== "tier" && thinkingSource !== "parent")) return undefined;
|
|
497
|
+
const source = raw.source;
|
|
498
|
+
if (source !== "call" && source !== "frontmatter" && source !== "default") return undefined;
|
|
476
499
|
const optionalThinking = (value: unknown): ThinkingLevel | undefined => {
|
|
477
500
|
if (value === undefined) return undefined;
|
|
478
|
-
return typeof value === "string" &&
|
|
501
|
+
return typeof value === "string" && THINKING_LEVELS.has(value as ThinkingLevel)
|
|
479
502
|
? value as ThinkingLevel
|
|
480
503
|
: undefined;
|
|
481
504
|
};
|
|
482
505
|
const thinking = optionalThinking(raw.thinking);
|
|
483
|
-
const configuredThinking = raw.configuredThinking === undefined
|
|
484
|
-
? undefined
|
|
485
|
-
: typeof raw.configuredThinking === "string" && WORKFLOW_CONFIGURED_THINKING.has(raw.configuredThinking as WorkflowThinking)
|
|
486
|
-
? raw.configuredThinking as WorkflowThinking
|
|
487
|
-
: undefined;
|
|
488
506
|
const requestedThinking = optionalThinking(raw.requestedThinking);
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}
|
|
507
|
+
const configuredThinking = raw.configuredThinking;
|
|
508
|
+
if (typeof raw.configuredModel !== "string" || !raw.configuredModel.trim() || raw.configuredModel.length > MANAGED_PATH_LIMIT) return undefined;
|
|
509
|
+
if (typeof configuredThinking !== "string" || ![...THINKING_LEVELS, "inherit"].includes(configuredThinking)) return undefined;
|
|
510
|
+
if (raw.model !== undefined &&
|
|
511
|
+
(typeof raw.model !== "string" || !raw.model.trim() || raw.model.length > MANAGED_PATH_LIMIT || !raw.model.includes("/"))) return undefined;
|
|
495
512
|
if (raw.thinking !== undefined && thinking === undefined) return undefined;
|
|
496
|
-
if (raw.configuredThinking !== undefined && configuredThinking === undefined) return undefined;
|
|
497
513
|
if (raw.requestedThinking !== undefined && requestedThinking === undefined) return undefined;
|
|
498
514
|
if (raw.clamped !== undefined && typeof raw.clamped !== "boolean") return undefined;
|
|
499
515
|
if (raw.diagnostic !== undefined && (typeof raw.diagnostic !== "string" || raw.diagnostic.length > MANAGED_ERROR_LIMIT)) return undefined;
|
|
500
516
|
return {
|
|
501
517
|
tier,
|
|
518
|
+
source,
|
|
502
519
|
...(typeof raw.model === "string" ? { model: raw.model } : {}),
|
|
503
520
|
...(thinking ? { thinking } : {}),
|
|
504
|
-
|
|
505
|
-
|
|
521
|
+
configuredModel: raw.configuredModel,
|
|
522
|
+
configuredThinking: configuredThinking as TierThinking,
|
|
506
523
|
...(requestedThinking ? { requestedThinking } : {}),
|
|
507
|
-
modelSource: source,
|
|
508
|
-
thinkingSource,
|
|
509
524
|
...(raw.clamped === true ? { clamped: true } : {}),
|
|
510
525
|
...(typeof raw.diagnostic === "string" ? { diagnostic: raw.diagnostic } : {}),
|
|
511
526
|
};
|
|
@@ -521,6 +536,13 @@ function terminalStatusForRecord(status: ManagedRecordStatus): ManagedTerminalSn
|
|
|
521
536
|
return undefined;
|
|
522
537
|
}
|
|
523
538
|
|
|
539
|
+
/** Recover only the key needed to prevent an unsupported tombstone from being replayed. */
|
|
540
|
+
function managedSpawnKeyForQuarantine(raw: unknown): string | undefined {
|
|
541
|
+
if (!isRecord(raw) || typeof raw.spawnKey !== "string") return undefined;
|
|
542
|
+
const key = raw.spawnKey.trim();
|
|
543
|
+
return key.length > 0 && key.length <= 256 ? key : undefined;
|
|
544
|
+
}
|
|
545
|
+
|
|
524
546
|
function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined {
|
|
525
547
|
if (!isRecord(raw) || raw.schemaVersion !== MANAGED_SPAWN_SCHEMA_VERSION) return undefined;
|
|
526
548
|
try {
|
|
@@ -533,10 +555,11 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
|
|
|
533
555
|
? undefined
|
|
534
556
|
: boundedManagedString(raw.threadPolicyFingerprint, "threadPolicyFingerprint", 128);
|
|
535
557
|
if ((thread === undefined) !== (threadPolicyFingerprint === undefined)) return undefined;
|
|
536
|
-
const
|
|
537
|
-
if (
|
|
558
|
+
const rawTier = raw.tier;
|
|
559
|
+
if (rawTier !== undefined && !isManagedAgentTier(rawTier)) return undefined;
|
|
560
|
+
const tier = rawTier as string | undefined;
|
|
538
561
|
const tierSnapshotValue = raw.tierSnapshot;
|
|
539
|
-
let tierSnapshot:
|
|
562
|
+
let tierSnapshot: AgentTierResolutionSnapshot | undefined;
|
|
540
563
|
if (tierSnapshotValue !== undefined) {
|
|
541
564
|
if (tier === undefined) return undefined;
|
|
542
565
|
tierSnapshot = parseTierSnapshot(tierSnapshotValue, tier);
|
|
@@ -608,10 +631,10 @@ export interface SpawnOptions {
|
|
|
608
631
|
isolated?: boolean;
|
|
609
632
|
inheritContext?: boolean;
|
|
610
633
|
thinkingLevel?: ThinkingLevel;
|
|
611
|
-
/** Semantic workflow tier resolved by pi-subagents at session start. */
|
|
612
|
-
tier?: WorkflowTier;
|
|
613
634
|
/** User-named model tier; resolved by pi-subagents at session start. */
|
|
614
635
|
agentTier?: string;
|
|
636
|
+
/** Managed workflow runs must select a tier instead of inheriting the parent. */
|
|
637
|
+
requireAgentTier?: boolean;
|
|
615
638
|
/** Optional toolset hint; concrete tool availability remains agent-configured. */
|
|
616
639
|
toolset?: string;
|
|
617
640
|
/** Additional tool names denied for this invocation. */
|
|
@@ -638,6 +661,8 @@ export interface SpawnOptions {
|
|
|
638
661
|
cwd?: string;
|
|
639
662
|
/** Resolved invocation snapshot captured for UI display. */
|
|
640
663
|
invocation?: AgentInvocation;
|
|
664
|
+
/** Parent model/thinking captured for a managed spawn before it enters the queue. */
|
|
665
|
+
readonly [INTERNAL_PARENT_POLICY_SNAPSHOT]?: ParentPolicySnapshot;
|
|
641
666
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
642
667
|
signal?: AbortSignal;
|
|
643
668
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
@@ -646,10 +671,8 @@ export interface SpawnOptions {
|
|
|
646
671
|
onTextDelta?: (delta: string, fullText: string) => void;
|
|
647
672
|
/** Called when the agent session is created (for accessing session stats). */
|
|
648
673
|
onSessionCreated?: (session: AgentSession) => void;
|
|
649
|
-
/** Called after pi-subagents resolves
|
|
650
|
-
|
|
651
|
-
/** Called after pi-subagents resolves a user-named agent tier. */
|
|
652
|
-
onAgentTierResolved?: (snapshot: AgentTierResolutionSnapshot) => void;
|
|
674
|
+
/** Called after pi-subagents resolves the tier for this spawn; see RunOptions. */
|
|
675
|
+
onAgentTierResolved?: (snapshot: AgentTierResolutionSnapshot, modelLabel?: string) => void;
|
|
653
676
|
/** Called synchronously after a new record is allocated, before session creation. */
|
|
654
677
|
onSpawned?: (id: string) => void;
|
|
655
678
|
/** Called at the end of each agentic turn with the cumulative count. */
|
|
@@ -684,10 +707,10 @@ export interface SpawnOptions {
|
|
|
684
707
|
resumeSessionFile?: string;
|
|
685
708
|
}
|
|
686
709
|
|
|
687
|
-
/** Internal managed-spawn policy; model
|
|
710
|
+
/** Internal managed-spawn policy; model/thinking resolution is finalized by runAgent. */
|
|
688
711
|
export type ManagedSpawnPolicy = Pick<
|
|
689
712
|
SpawnOptions,
|
|
690
|
-
"
|
|
713
|
+
"maxTurns" | "isolated" | "inheritContext" | "isolation" | "invocation" | "rootSessionId" | "toolset" | "excludeTools"
|
|
691
714
|
> & {
|
|
692
715
|
/** In-process identity of the resolved agent definition/tool allowlist for thread reuse. */
|
|
693
716
|
policyFingerprint?: string;
|
|
@@ -699,6 +722,8 @@ interface ResumeTerminalSnapshot {
|
|
|
699
722
|
completedAt?: number;
|
|
700
723
|
result?: string;
|
|
701
724
|
error?: string;
|
|
725
|
+
/** Policy metadata must survive a detached/resumed attempt unchanged. */
|
|
726
|
+
invocation?: AgentInvocation;
|
|
702
727
|
}
|
|
703
728
|
|
|
704
729
|
interface ResumeControl {
|
|
@@ -720,6 +745,16 @@ export class AgentManager {
|
|
|
720
745
|
private onCompact?: OnAgentCompact;
|
|
721
746
|
private managedSpawns = new Map<string, ManagedSpawnTombstone>();
|
|
722
747
|
private managedKeysById = new Map<string, string>();
|
|
748
|
+
/** Spawn keys found in unsupported/corrupt persisted tombstones. */
|
|
749
|
+
private managedQuarantinedKeys = new Set<string>();
|
|
750
|
+
/**
|
|
751
|
+
* Spawn keys retired during a branch/session replacement before their
|
|
752
|
+
* provider work settled. A retired key must never be reallocated, but the set
|
|
753
|
+
* is bounded: a long session that replaces its branch repeatedly would
|
|
754
|
+
* otherwise grow it without limit. Oldest entries are evicted first — those
|
|
755
|
+
* are the ones whose sessions are least likely to still be alive.
|
|
756
|
+
*/
|
|
757
|
+
private managedRetiredKeys = new Set<string>();
|
|
723
758
|
/** Active workflow-thread name to the managed AgentSession it re-enters. */
|
|
724
759
|
private managedThreads = new Map<string, string>();
|
|
725
760
|
/** Policy identity captured with each thread; policy changes cannot bypass a denylist. */
|
|
@@ -1049,7 +1084,8 @@ export class AgentManager {
|
|
|
1049
1084
|
: undefined;
|
|
1050
1085
|
this.replaceManagedTombstone(key, {
|
|
1051
1086
|
...tombstone,
|
|
1052
|
-
...(record.invocation?.
|
|
1087
|
+
...(record.invocation?.agentTierSnapshot ? { tierSnapshot: { ...record.invocation.agentTierSnapshot } } : {}),
|
|
1088
|
+
...(record.invocation?.agentTier !== undefined ? { tier: record.invocation.agentTier } : {}),
|
|
1053
1089
|
state,
|
|
1054
1090
|
updatedAt: Date.now(),
|
|
1055
1091
|
compactionCount,
|
|
@@ -1091,12 +1127,30 @@ export class AgentManager {
|
|
|
1091
1127
|
}
|
|
1092
1128
|
return {
|
|
1093
1129
|
id: tombstone.id,
|
|
1130
|
+
...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
|
|
1094
1131
|
state: tombstone.state,
|
|
1095
1132
|
created,
|
|
1096
1133
|
...(tombstone.terminal ? { terminal: cloneManagedTerminal(tombstone.terminal) } : {}),
|
|
1097
1134
|
};
|
|
1098
1135
|
}
|
|
1099
1136
|
|
|
1137
|
+
private retireLiveManagedKeys(): void {
|
|
1138
|
+
for (const [key, tombstone] of this.managedSpawns) {
|
|
1139
|
+
const record = this.agents.get(tombstone.id);
|
|
1140
|
+
if (!isManagedTerminalState(tombstone.state) || (record !== undefined && !this.isFullyCleaned(record))) {
|
|
1141
|
+
// Re-inserting moves an existing key to the end of iteration order, so
|
|
1142
|
+
// the eviction below always drops the least recently retired key.
|
|
1143
|
+
this.managedRetiredKeys.delete(key);
|
|
1144
|
+
this.managedRetiredKeys.add(key);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
while (this.managedRetiredKeys.size > MANAGED_RETIRED_KEY_LIMIT) {
|
|
1148
|
+
const oldest = this.managedRetiredKeys.values().next();
|
|
1149
|
+
if (oldest.done) break;
|
|
1150
|
+
this.managedRetiredKeys.delete(oldest.value);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1100
1154
|
/** Restore session-scoped managed idempotency entries and settle orphaned work. */
|
|
1101
1155
|
restoreManagedSpawns(
|
|
1102
1156
|
entries: readonly ManagedSpawnEntryLike[],
|
|
@@ -1104,15 +1158,38 @@ export class AgentManager {
|
|
|
1104
1158
|
): ManagedSpawnTombstone[] {
|
|
1105
1159
|
this.clearManagedPersistenceRetries();
|
|
1106
1160
|
if (this.disposed) return [];
|
|
1161
|
+
this.retireLiveManagedKeys();
|
|
1107
1162
|
this.managedSpawns.clear();
|
|
1108
1163
|
this.managedKeysById.clear();
|
|
1164
|
+
this.managedQuarantinedKeys.clear();
|
|
1109
1165
|
this.managedThreads.clear();
|
|
1110
1166
|
this.managedThreadPolicies.clear();
|
|
1111
1167
|
this.managedThreadReservations.clear();
|
|
1112
1168
|
for (const entry of entries) {
|
|
1113
1169
|
if (entry.type !== "custom" || entry.customType !== MANAGED_SPAWN_ENTRY_TYPE) continue;
|
|
1114
1170
|
const tombstone = parseManagedTombstone(entry.data);
|
|
1115
|
-
if (!tombstone)
|
|
1171
|
+
if (!tombstone) {
|
|
1172
|
+
const key = managedSpawnKeyForQuarantine(entry.data);
|
|
1173
|
+
if (key) {
|
|
1174
|
+
this.managedQuarantinedKeys.add(key);
|
|
1175
|
+
const previous = this.managedSpawns.get(key);
|
|
1176
|
+
if (previous && this.managedKeysById.get(previous.id) === key) {
|
|
1177
|
+
this.managedKeysById.delete(previous.id);
|
|
1178
|
+
for (const [threadKey, threadId] of this.managedThreads) {
|
|
1179
|
+
if (threadId !== previous.id) continue;
|
|
1180
|
+
this.managedThreads.delete(threadKey);
|
|
1181
|
+
this.managedThreadPolicies.delete(threadKey);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
// A later incompatible fact must invalidate an earlier current-schema
|
|
1185
|
+
// fact for the same key. Otherwise restore order could resurrect a
|
|
1186
|
+
// spawn that the clean-break quarantine is meant to reject.
|
|
1187
|
+
this.managedSpawns.delete(key);
|
|
1188
|
+
console.warn(`[pi-subagents] quarantined unsupported managed spawn tombstone for ${key}`);
|
|
1189
|
+
}
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
if (this.managedQuarantinedKeys.has(tombstone.spawnKey) || this.managedRetiredKeys.has(tombstone.spawnKey)) continue;
|
|
1116
1193
|
if (options.dropActive && !isManagedTerminalState(tombstone.state)) continue;
|
|
1117
1194
|
this.managedSpawns.set(tombstone.spawnKey, tombstone);
|
|
1118
1195
|
this.managedKeysById.set(tombstone.id, tombstone.spawnKey);
|
|
@@ -1387,42 +1464,68 @@ export class AgentManager {
|
|
|
1387
1464
|
callbacks?: Pick<SpawnOptions, "onToolActivity" | "onTextDelta" | "onSessionCreated" | "onTurnEnd" | "onAssistantUsage" | "onCompaction" | "onSpawned">,
|
|
1388
1465
|
): ManagedSpawnResult {
|
|
1389
1466
|
if (this.disposed) throw new Error("AgentManager is disposed");
|
|
1390
|
-
const normalized =
|
|
1391
|
-
|
|
1467
|
+
const normalized = parseManagedSpawnRequest(request);
|
|
1468
|
+
// The policy may already know the agent's own tier or the configured
|
|
1469
|
+
// default before runAgent emits its authoritative resolution callback.
|
|
1470
|
+
// Carry that key in managed identity, but keep it out of the runner options
|
|
1471
|
+
// so source and snapshot semantics still resolve centrally in runAgent.
|
|
1472
|
+
const identityTier = normalized.tier ?? policy.invocation?.agentTier;
|
|
1473
|
+
// Narrowed once so every later use is a plain string rather than a
|
|
1474
|
+
// non-null assertion on a field TypeScript cannot re-narrow across the
|
|
1475
|
+
// intervening lookups.
|
|
1476
|
+
const thread = normalized.thread;
|
|
1477
|
+
if (thread && (normalized.isolation === "worktree" || policy.isolation === "worktree")) {
|
|
1392
1478
|
throw new Error("Managed workflow threads cannot use worktree isolation; use separate calls instead.");
|
|
1393
1479
|
}
|
|
1394
1480
|
const scope = normalized.spawnKey;
|
|
1395
|
-
|
|
1481
|
+
if (this.managedQuarantinedKeys.has(scope) || this.managedRetiredKeys.has(scope)) {
|
|
1482
|
+
throw new Error(`Managed spawn key is quarantined and cannot be reused: "${scope}"`);
|
|
1483
|
+
}
|
|
1484
|
+
const parentThinking = currentParentThinkingLevel(pi);
|
|
1485
|
+
const parentPolicySnapshot: ParentPolicySnapshot = Object.freeze({
|
|
1486
|
+
model: ctx.model,
|
|
1487
|
+
thinking: parentThinking,
|
|
1488
|
+
});
|
|
1489
|
+
const policyFingerprint = managedThreadPolicyFingerprint(
|
|
1490
|
+
normalized,
|
|
1491
|
+
policy,
|
|
1492
|
+
parentPolicySnapshot.model,
|
|
1493
|
+
parentPolicySnapshot.thinking,
|
|
1494
|
+
);
|
|
1396
1495
|
const fingerprint = managedFingerprint(normalized, policyFingerprint);
|
|
1397
|
-
const
|
|
1398
|
-
const threadPolicyFingerprint = normalized.thread ? policyFingerprint : undefined;
|
|
1496
|
+
const threadPolicyFingerprint = thread ? policyFingerprint : undefined;
|
|
1399
1497
|
const previous = this.managedSpawns.get(scope);
|
|
1400
1498
|
if (previous) {
|
|
1401
|
-
if (previous.fingerprint !== fingerprint
|
|
1499
|
+
if (previous.fingerprint !== fingerprint) {
|
|
1402
1500
|
throw new Error(`Managed spawn key conflict: "${normalized.spawnKey}"`);
|
|
1403
1501
|
}
|
|
1404
|
-
if (
|
|
1405
|
-
throw new Error(
|
|
1502
|
+
if (thread && previous.threadPolicyFingerprint !== threadPolicyFingerprint) {
|
|
1503
|
+
throw new Error(threadPolicyConflictMessage(thread, identityTier));
|
|
1406
1504
|
}
|
|
1407
1505
|
return this.managedResult(scope);
|
|
1408
1506
|
}
|
|
1409
1507
|
|
|
1410
|
-
const threadKey =
|
|
1508
|
+
const threadKey = thread ? `${normalized.owner.runId}\u0000${thread}` : undefined;
|
|
1411
1509
|
if (threadKey && this.managedThreadReservations.has(threadKey)) {
|
|
1412
|
-
throw new Error(`Managed workflow thread "${
|
|
1510
|
+
throw new Error(`Managed workflow thread "${thread}" is already running; calls must be sequential.`);
|
|
1413
1511
|
}
|
|
1414
1512
|
const threadedId = threadKey ? this.managedThreads.get(threadKey) : undefined;
|
|
1415
|
-
if (threadedId) {
|
|
1416
|
-
if (threadPolicyFingerprint !== undefined && this.managedThreadPolicies.get(threadKey
|
|
1417
|
-
throw new Error(
|
|
1513
|
+
if (threadedId && threadKey && thread) {
|
|
1514
|
+
if (threadPolicyFingerprint !== undefined && this.managedThreadPolicies.get(threadKey) !== threadPolicyFingerprint) {
|
|
1515
|
+
throw new Error(threadPolicyConflictMessage(thread, identityTier));
|
|
1418
1516
|
}
|
|
1419
1517
|
const record = this.agents.get(threadedId);
|
|
1420
1518
|
if (record?.session && record.status !== "running" && record.status !== "queued" && !record.detached) {
|
|
1421
1519
|
const previousOwner = record.owner;
|
|
1422
1520
|
const previousInvocation = record.invocation;
|
|
1423
1521
|
const previousManagedKey = this.managedKeysById.get(threadedId);
|
|
1522
|
+
const resumedTier = identityTier ?? previousInvocation?.agentTier;
|
|
1424
1523
|
record.owner = Object.freeze({ ...normalized.owner });
|
|
1425
|
-
record.invocation =
|
|
1524
|
+
record.invocation = {
|
|
1525
|
+
...(policy.invocation ?? {}),
|
|
1526
|
+
...(resumedTier === undefined ? {} : { agentTier: resumedTier }),
|
|
1527
|
+
...(previousInvocation?.agentTierSnapshot ? { agentTierSnapshot: previousInvocation.agentTierSnapshot } : {}),
|
|
1528
|
+
};
|
|
1426
1529
|
const now = Date.now();
|
|
1427
1530
|
const tombstone: ManagedSpawnTombstone = {
|
|
1428
1531
|
schemaVersion: MANAGED_SPAWN_SCHEMA_VERSION,
|
|
@@ -1433,12 +1536,13 @@ export class AgentManager {
|
|
|
1433
1536
|
type: normalized.type,
|
|
1434
1537
|
description: normalized.description,
|
|
1435
1538
|
owner: { ...normalized.owner },
|
|
1436
|
-
...(
|
|
1437
|
-
...(
|
|
1539
|
+
...(resumedTier === undefined ? {} : { tier: resumedTier }),
|
|
1540
|
+
...(thread === undefined ? {} : { thread, threadPolicyFingerprint }),
|
|
1438
1541
|
state: "queued",
|
|
1439
1542
|
createdAt: now,
|
|
1440
1543
|
updatedAt: now,
|
|
1441
1544
|
compactionCount: record.compactionCount,
|
|
1545
|
+
...(record.invocation?.agentTierSnapshot ? { tierSnapshot: { ...record.invocation.agentTierSnapshot } } : {}),
|
|
1442
1546
|
};
|
|
1443
1547
|
this.clearManagedPersistenceRetry(scope);
|
|
1444
1548
|
this.managedSpawns.set(scope, tombstone);
|
|
@@ -1461,10 +1565,15 @@ export class AgentManager {
|
|
|
1461
1565
|
}).catch(() => {});
|
|
1462
1566
|
this.syncManagedRecord(record, true);
|
|
1463
1567
|
const resumedState = String(record.status);
|
|
1464
|
-
return {
|
|
1568
|
+
return {
|
|
1569
|
+
id: threadedId,
|
|
1570
|
+
...(resumedTier === undefined ? {} : { tier: resumedTier }),
|
|
1571
|
+
state: resumedState === "queued" ? "queued" : "running",
|
|
1572
|
+
created: true,
|
|
1573
|
+
};
|
|
1465
1574
|
}
|
|
1466
1575
|
if (record && !record.detached && (record.status === "running" || record.status === "queued")) {
|
|
1467
|
-
throw new Error(`Managed workflow thread "${
|
|
1576
|
+
throw new Error(`Managed workflow thread "${thread}" is already running; calls must be sequential.`);
|
|
1468
1577
|
}
|
|
1469
1578
|
if (!record || record.detached || !record.session) {
|
|
1470
1579
|
this.managedThreads.delete(threadKey!);
|
|
@@ -1485,8 +1594,8 @@ export class AgentManager {
|
|
|
1485
1594
|
type: normalized.type,
|
|
1486
1595
|
description: normalized.description,
|
|
1487
1596
|
owner: { ...normalized.owner },
|
|
1488
|
-
...(
|
|
1489
|
-
...(
|
|
1597
|
+
...(identityTier === undefined ? {} : { tier: identityTier }),
|
|
1598
|
+
...(thread === undefined ? {} : { thread, threadPolicyFingerprint }),
|
|
1490
1599
|
state: "queued",
|
|
1491
1600
|
createdAt: now,
|
|
1492
1601
|
updatedAt: now,
|
|
@@ -1518,8 +1627,12 @@ export class AgentManager {
|
|
|
1518
1627
|
try {
|
|
1519
1628
|
this.spawnInternal(pi, ctx, normalized.type, normalized.prompt, {
|
|
1520
1629
|
...policy,
|
|
1521
|
-
|
|
1522
|
-
|
|
1630
|
+
[INTERNAL_PARENT_POLICY_SNAPSHOT]: parentPolicySnapshot,
|
|
1631
|
+
// A managed workflow must resolve a tier or fail closed; it may not
|
|
1632
|
+
// silently inherit the parent session's model when nobody named one.
|
|
1633
|
+
requireAgentTier: true,
|
|
1634
|
+
...(normalized.tier === undefined ? {} : { agentTier: normalized.tier }),
|
|
1635
|
+
...(thread === undefined ? {} : { thread }),
|
|
1523
1636
|
description: normalized.description,
|
|
1524
1637
|
isBackground: true,
|
|
1525
1638
|
...callbacks,
|
|
@@ -1553,11 +1666,16 @@ export class AgentManager {
|
|
|
1553
1666
|
return this.managedResult(scope, true);
|
|
1554
1667
|
}
|
|
1555
1668
|
|
|
1556
|
-
/** Forget managed idempotency
|
|
1669
|
+
/** Forget settled managed idempotency state when a logical session is replaced; retire live keys. */
|
|
1557
1670
|
resetManagedSpawns(): void {
|
|
1558
1671
|
this.clearManagedPersistenceRetries();
|
|
1672
|
+
// Keep identities whose provider/session may still be alive after a branch
|
|
1673
|
+
// replacement. Clearing the in-memory tombstone without retaining its key
|
|
1674
|
+
// would let a resumed run allocate the same spawn identity again.
|
|
1675
|
+
this.retireLiveManagedKeys();
|
|
1559
1676
|
this.managedSpawns.clear();
|
|
1560
1677
|
this.managedKeysById.clear();
|
|
1678
|
+
this.managedQuarantinedKeys.clear();
|
|
1561
1679
|
this.managedThreads.clear();
|
|
1562
1680
|
this.managedThreadPolicies.clear();
|
|
1563
1681
|
this.managedThreadReservations.clear();
|
|
@@ -1639,9 +1757,10 @@ export class AgentManager {
|
|
|
1639
1757
|
isolated: internalOverride?.isolated ?? options.isolated,
|
|
1640
1758
|
inheritContext: internalOverride?.inheritContext ?? options.inheritContext,
|
|
1641
1759
|
...(internalOverride ? { [INTERNAL_AGENT_CONFIG_OVERRIDE]: internalOverride } : {}),
|
|
1760
|
+
[INTERNAL_PARENT_POLICY_SNAPSHOT]: options[INTERNAL_PARENT_POLICY_SNAPSHOT],
|
|
1642
1761
|
thinkingLevel: options.thinkingLevel,
|
|
1643
|
-
tier: options.tier,
|
|
1644
1762
|
agentTier: options.agentTier,
|
|
1763
|
+
requireAgentTier: options.requireAgentTier,
|
|
1645
1764
|
// Worktree wins for the working dir (the agent must run in the copy —
|
|
1646
1765
|
// which, with a custom cwd, was created from that target). Config stays
|
|
1647
1766
|
// with the parent project when a caller-supplied cwd is in play; it must
|
|
@@ -1691,31 +1810,21 @@ export class AgentManager {
|
|
|
1691
1810
|
depth: record.depth ?? 1,
|
|
1692
1811
|
maxSubagentDepth: record.maxSubagentDepth,
|
|
1693
1812
|
},
|
|
1694
|
-
|
|
1695
|
-
if (!record.detached) {
|
|
1696
|
-
record.invocation = {
|
|
1697
|
-
...(record.invocation ?? {}),
|
|
1698
|
-
...(options.tier === undefined ? {} : { tier: options.tier }),
|
|
1699
|
-
thinking: snapshot.thinking,
|
|
1700
|
-
tierSnapshot: { ...snapshot },
|
|
1701
|
-
};
|
|
1702
|
-
this.syncManagedRecord(record, true);
|
|
1703
|
-
options.onTierResolved?.(snapshot);
|
|
1704
|
-
}
|
|
1705
|
-
},
|
|
1706
|
-
// Recorded on the same record as the workflow snapshot but under its own
|
|
1707
|
-
// field, so a run can carry both without either overwriting the other's
|
|
1708
|
-
// account of how its model was chosen.
|
|
1709
|
-
onAgentTierResolved: (snapshot) => {
|
|
1813
|
+
onAgentTierResolved: (snapshot, modelLabel) => {
|
|
1710
1814
|
if (!record.detached) {
|
|
1711
1815
|
record.invocation = {
|
|
1712
1816
|
...(record.invocation ?? {}),
|
|
1817
|
+
// The tier owns model and thinking, so it owns their display too —
|
|
1818
|
+
// including clearing a label a caller pre-computed before the tier
|
|
1819
|
+
// resolved. Without this a tiered spawn shows its tier name and no
|
|
1820
|
+
// model at all, even when the profile pins one.
|
|
1821
|
+
modelName: modelLabel,
|
|
1713
1822
|
agentTier: snapshot.tier,
|
|
1714
1823
|
thinking: snapshot.thinking,
|
|
1715
1824
|
agentTierSnapshot: { ...snapshot },
|
|
1716
1825
|
};
|
|
1717
1826
|
this.syncManagedRecord(record, true);
|
|
1718
|
-
options.onAgentTierResolved?.(snapshot);
|
|
1827
|
+
options.onAgentTierResolved?.(snapshot, modelLabel);
|
|
1719
1828
|
}
|
|
1720
1829
|
},
|
|
1721
1830
|
onSessionCreated: (session) => {
|
|
@@ -2134,6 +2243,7 @@ export class AgentManager {
|
|
|
2134
2243
|
...(record.completedAt === undefined ? {} : { completedAt: record.completedAt }),
|
|
2135
2244
|
...(record.result === undefined ? {} : { result: record.result }),
|
|
2136
2245
|
...(record.error === undefined ? {} : { error: record.error }),
|
|
2246
|
+
...(record.invocation ? { invocation: cloneFrozenData(record.invocation) as AgentInvocation } : {}),
|
|
2137
2247
|
};
|
|
2138
2248
|
}
|
|
2139
2249
|
|
|
@@ -2143,6 +2253,9 @@ export class AgentManager {
|
|
|
2143
2253
|
record.completedAt = snapshot.completedAt;
|
|
2144
2254
|
record.result = snapshot.result;
|
|
2145
2255
|
record.error = snapshot.error;
|
|
2256
|
+
record.invocation = snapshot.invocation
|
|
2257
|
+
? cloneFrozenData(snapshot.invocation) as AgentInvocation
|
|
2258
|
+
: undefined;
|
|
2146
2259
|
}
|
|
2147
2260
|
|
|
2148
2261
|
private beginResumeControl(
|
|
@@ -3358,6 +3471,8 @@ export class AgentManager {
|
|
|
3358
3471
|
this.agents.clear();
|
|
3359
3472
|
this.managedSpawns.clear();
|
|
3360
3473
|
this.managedKeysById.clear();
|
|
3474
|
+
this.managedQuarantinedKeys.clear();
|
|
3475
|
+
this.managedRetiredKeys.clear();
|
|
3361
3476
|
this.managedThreads.clear();
|
|
3362
3477
|
this.managedThreadPolicies.clear();
|
|
3363
3478
|
this.managedThreadReservations.clear();
|