@signalridge/pi-subagents 1.14.0 → 1.15.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 +13 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +102 -51
- package/src/agent-runner.ts +130 -111
- package/src/index.ts +195 -39
- package/src/internal-run.ts +5 -1
- package/src/invocation-config.ts +6 -1
- package/src/nested-tools.ts +7 -2
- package/src/run-budget-policy.ts +22 -0
- package/src/ui/fleet-list.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.15.0
|
|
4
|
+
### Minor Changes
|
|
5
|
+
|
|
6
|
+
- a4dc1de: Enforce missing per-invocation safety budgets on resumed agents, including foreground/background calls, nested resumes, mentions, and reused managed threads. Share fresh/resumed turn, token, and tool-call enforcement and terminal outcome mapping; preserve configured grace and tool timeouts. Resume inherits the original invocation's turn budget when not overridden, respects authoritative frontmatter/default policy, validates explicit limits like spawn, and does not let zero erase a configured cap. Nested resumes resolve only their branch-local frontmatter through an in-process budget-policy capability; internal callers without it retain the admitted invocation budget rather than consulting the root registry. Authoritative frontmatter zero remains unlimited after a finite earlier invocation. A retained session's finite or unlimited policy survives later default changes; one manager-resolved cap drives enforcement and queued/running UI counters. Activity updates are invocation-owned and lifetime usage is counted only once.
|
|
7
|
+
|
|
8
|
+
Dispose grouped notifications and pending batch/nudge timers synchronously at confirmed shutdown, retire invocation-owned foreground progress/spinner callbacks before any teardown await, fence captured delivery callbacks, and preserve managed lifecycle/usage receipts while cooperative child teardown completes. Refused/failed resume admissions retire their own UI callbacks, while admitted lifetimes own final cleanup; mention notifications are fenced after asynchronous admission. Vetoable navigation remains unchanged.
|
|
9
|
+
|
|
10
|
+
Selective upstream parity references: tintinweb/pi-subagents `d7c9ec0b027b91c7ab9da0fa5f4ae203474a5bdf`, `ca93a5247e72e7053fcd10bea7125d6c4205f949` (unreleased after the 0.22 tag), and `83be963a969eb359af29cbbb4d4af2d01748bfb5` (released in 0.22). The integrated engine is not imported; managed protocol v4 and payloads are unchanged. Event-based budgets cannot forcibly terminate a provider or tool that ignores abort.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- a4dc1de: Distinguish expandable Workflow groups from individual agents with a dedicated `▸` type mark across all run outcomes. Remove the duplicate trailing blank row before the footer while preserving Fleet navigation, exact-owner conversation opening, and the existing Workflow detail UI.
|
|
15
|
+
|
|
3
16
|
## 1.14.0
|
|
4
17
|
### Minor Changes
|
|
5
18
|
|
package/README.md
CHANGED
|
@@ -55,6 +55,8 @@ Derived from [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents)
|
|
|
55
55
|
|
|
56
56
|
The container-first FleetView and bounded drill-down patterns are independently adapted from tintinweb/pi-subagents at [`221df02323c6b6350ea8474579e0ee0f6cc84cea`](https://github.com/tintinweb/pi-subagents/tree/221df02323c6b6350ea8474579e0ee0f6cc84cea/src/ui) and [`fd8d81618d80ae83a3faf40cb87dae99cca33264`](https://github.com/tintinweb/pi-subagents/tree/fd8d81618d80ae83a3faf40cb87dae99cca33264/src/ui). Collision-only child-label discriminators are inspired by [nicobailon/pi-subagents `69673c7fbf3351e54a5b0786a7e9d9fff625a749`](https://github.com/nicobailon/pi-subagents/blob/69673c7fbf3351e54a5b0786a7e9d9fff625a749/src/tui/render-helpers.ts). The workflow engine and phase inspector remain owned by `pi-workflows`, not copied into this package. A separate optional, validated UI protocol supplies authoritative run names, statuses and observed phases and opens that owner's inspector. Without that peer, groups retain the observation-only child list. No workflow source imports, inferred DAG state or second queue/model policy are introduced. The managed protocol remains v4.
|
|
57
57
|
|
|
58
|
+
Resume-budget and shutdown parity fixes selectively adapt upstream work at [`d7c9ec0`](https://github.com/tintinweb/pi-subagents/commit/d7c9ec0b027b91c7ab9da0fa5f4ae203474a5bdf), [`ca93a52`](https://github.com/tintinweb/pi-subagents/commit/ca93a5247e72e7053fcd10bea7125d6c4205f949) (unreleased after the 0.22 tag), and [`83be963`](https://github.com/tintinweb/pi-subagents/commit/83be963a969eb359af29cbbb4d4af2d01748bfb5) (released in 0.22). They retain this fork's manager-owned sessions, resource budgets, activity accounting, and protocol v4; the integrated upstream engine is not imported.
|
|
59
|
+
|
|
58
60
|
The opt-in parent clarification broker is locally implemented. It uses the existing main session, not an upstream LLM arbitrator, cloned parent history, private UI automation, or an additional Agent per question. Permission gates remain human-owned.
|
|
59
61
|
|
|
60
62
|
## Lint policy
|
|
@@ -469,6 +471,10 @@ Instead of hard-aborting at the turn limit, agents get a graceful shutdown:
|
|
|
469
471
|
2. Up to 5 grace turns to finish cleanly
|
|
470
472
|
3. Hard abort only after the grace period
|
|
471
473
|
|
|
474
|
+
Each resumed prompt gets its own counters, not an unlimited continuation or a deduction from prior turns. This applies to foreground/background resumes, nested children, mentions, and reused managed threads. Agent frontmatter remains authoritative; without a per-resume override, the original invocation's turn limit is inherited, falling back to configured defaults. Resume overrides use the same non-negative safe-integer validation as spawning; caller zero cannot disable an inherited/configured cap. An authoritative frontmatter zero remains unlimited, even after an earlier finite invocation. Nested resumes reload the same branch-local definitions as nested spawns, never the root registry; an internal resume without scoped policy retains its admitted invocation budget. A retained session's admitted turn policy, including unlimited, survives later project-default changes; the manager supplies that same effective cap to queued and running progress trackers. The existing token/tool-call caps and tool timeout also apply to resumed prompts. Budgets are event-based: an already-running provider/tool can overshoot within one event or ignore abort; these are not process-isolation guarantees.
|
|
475
|
+
|
|
476
|
+
Confirmed shutdown retires grouped completion, batch-finalization, held-nudge timers, and invocation-owned foreground spinners before awaiting child teardown. Captured delivery callbacks cannot wake the main model or publish foreground progress afterward, even while a provider ignores abort. Refused or failed resume admissions retire their own UI callbacks; an admitted run owns cleanup until settlement. Mention continuations cannot publish notifications after their root/runtime retires. Managed lifecycle/usage receipts remain available during cooperative shutdown; vetoable navigation attempts do not dispose these resources.
|
|
477
|
+
|
|
472
478
|
| Status | Meaning | UI treatment |
|
|
473
479
|
|--------|---------|-------------|
|
|
474
480
|
| `completed` | Finished naturally | `completed` |
|
package/package.json
CHANGED
package/src/agent-manager.ts
CHANGED
|
@@ -35,15 +35,19 @@ import {
|
|
|
35
35
|
import {
|
|
36
36
|
INTERNAL_AGENT_CONFIG_OVERRIDE,
|
|
37
37
|
INTERNAL_PARENT_POLICY_SNAPSHOT,
|
|
38
|
+
INTERNAL_RESUME_BUDGET_POLICY,
|
|
38
39
|
type InternalAgentConfigOverride,
|
|
39
40
|
type ParentPolicySnapshot,
|
|
41
|
+
type ResumeBudgetPolicy,
|
|
40
42
|
} from "./internal-run.js";
|
|
43
|
+
import { resolveMaxTurns } from "./invocation-config.js";
|
|
41
44
|
import { assignHandle, handleBase } from "./mention.js";
|
|
42
45
|
import {
|
|
43
46
|
assertParentModelRuntimeAvailable,
|
|
44
47
|
parentModelSessionOptions,
|
|
45
48
|
} from "./model-runtime-bridge.js";
|
|
46
49
|
import type { ParentQuestionHandler } from "./parent-questions.js";
|
|
50
|
+
import { selectResumeTurnPolicy } from "./run-budget-policy.js";
|
|
47
51
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
48
52
|
import type { TierThinking } from "./settings.js";
|
|
49
53
|
import type {
|
|
@@ -315,6 +319,21 @@ function snapshotCleanupFailure(
|
|
|
315
319
|
});
|
|
316
320
|
}
|
|
317
321
|
|
|
322
|
+
/** Hard budgets take precedence over provider failure, then soft wrap-up. */
|
|
323
|
+
function applyRunOutcome(
|
|
324
|
+
record: AgentRecord,
|
|
325
|
+
outcome: { aborted?: boolean; steered?: boolean; failure?: string },
|
|
326
|
+
): void {
|
|
327
|
+
if (outcome.aborted) {
|
|
328
|
+
record.status = "aborted";
|
|
329
|
+
} else if (outcome.failure) {
|
|
330
|
+
record.status = "error";
|
|
331
|
+
record.error = outcome.failure;
|
|
332
|
+
} else {
|
|
333
|
+
record.status = outcome.steered ? "steered" : "completed";
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
318
337
|
interface SpawnArgs {
|
|
319
338
|
pi: ExtensionAPI;
|
|
320
339
|
ctx: ExtensionContext;
|
|
@@ -1021,10 +1040,27 @@ export type ManagedSpawnCallbacks = Pick<
|
|
|
1021
1040
|
| "onCompaction"
|
|
1022
1041
|
| "onSpawned"
|
|
1023
1042
|
> & {
|
|
1043
|
+
/** Reused thread's effective display budget, before queue admission. */
|
|
1044
|
+
onResumeBudgetResolved?: (maxTurns: number | undefined) => void;
|
|
1024
1045
|
/** Reused thread's actual run boundary, before native steering or prompt. */
|
|
1025
1046
|
onResumeStarting?: (record: AgentRecord, startMessageIndex: number) => void;
|
|
1026
1047
|
};
|
|
1027
1048
|
|
|
1049
|
+
export interface ResumeOptions {
|
|
1050
|
+
readonly [INTERNAL_RESUME_BUDGET_POLICY]?: ResumeBudgetPolicy;
|
|
1051
|
+
isBackground?: boolean;
|
|
1052
|
+
maxTurns?: number;
|
|
1053
|
+
onToolActivity?: (activity: ToolActivity) => void;
|
|
1054
|
+
onTurnEnd?: (count: number) => void;
|
|
1055
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
1056
|
+
onAssistantUsage?: SpawnOptions["onAssistantUsage"];
|
|
1057
|
+
onCompaction?: (info: CompactionInfo) => void;
|
|
1058
|
+
/** Effective display cap, published synchronously before queue admission. */
|
|
1059
|
+
onBudgetResolved?: (maxTurns: number | undefined) => void;
|
|
1060
|
+
/** Called at actual admission, including a queued background resume. */
|
|
1061
|
+
onStarted?: () => void;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1028
1064
|
interface ResumeTerminalSnapshot {
|
|
1029
1065
|
status: AgentRecord["status"];
|
|
1030
1066
|
startedAt: number;
|
|
@@ -1863,9 +1899,13 @@ export class AgentManager {
|
|
|
1863
1899
|
// only filter excludes only explicit `false`, so undefined agents — which
|
|
1864
1900
|
// have no inline surface — stay visible instead of vanishing.
|
|
1865
1901
|
isBackground: options.isBackground,
|
|
1866
|
-
invocation:
|
|
1867
|
-
|
|
1868
|
-
|
|
1902
|
+
invocation:
|
|
1903
|
+
options.invocation || options.maxTurns !== undefined
|
|
1904
|
+
? cloneFrozenData({
|
|
1905
|
+
...options.invocation,
|
|
1906
|
+
maxTurns: options.maxTurns ?? options.invocation?.maxTurns,
|
|
1907
|
+
})
|
|
1908
|
+
: undefined,
|
|
1869
1909
|
depth: options.depth ?? 1,
|
|
1870
1910
|
parentAgentId: options.parentAgentId,
|
|
1871
1911
|
...(ancestorAgentIds ? { ancestorAgentIds } : {}),
|
|
@@ -2176,9 +2216,13 @@ export class AgentManager {
|
|
|
2176
2216
|
}
|
|
2177
2217
|
void this.resume(threadedId, normalized.prompt, undefined, {
|
|
2178
2218
|
isBackground: true,
|
|
2219
|
+
maxTurns: policy.maxTurns,
|
|
2220
|
+
onBudgetResolved: callbacks?.onResumeBudgetResolved,
|
|
2179
2221
|
onStarted: () => {
|
|
2180
2222
|
void Promise.resolve(callbacks?.onResumeStarting?.(record, record.session!.messages.length)).catch(() => {});
|
|
2181
2223
|
},
|
|
2224
|
+
onTurnEnd: callbacks?.onTurnEnd,
|
|
2225
|
+
onTextDelta: callbacks?.onTextDelta,
|
|
2182
2226
|
onToolActivity: callbacks?.onToolActivity,
|
|
2183
2227
|
onAssistantUsage: callbacks?.onAssistantUsage,
|
|
2184
2228
|
onCompaction: callbacks?.onCompaction
|
|
@@ -2560,14 +2604,7 @@ export class AgentManager {
|
|
|
2560
2604
|
// Precedence: a hard abort keeps "aborted"; then a failed final turn
|
|
2561
2605
|
// (provider error that pi resolved instead of rejecting, #144) is an
|
|
2562
2606
|
// honest "error" — not a completion with an empty or stale result.
|
|
2563
|
-
|
|
2564
|
-
record.status = "aborted";
|
|
2565
|
-
} else if (failure) {
|
|
2566
|
-
record.status = "error";
|
|
2567
|
-
record.error = failure;
|
|
2568
|
-
} else {
|
|
2569
|
-
record.status = steered ? "steered" : "completed";
|
|
2570
|
-
}
|
|
2607
|
+
applyRunOutcome(record, { aborted, steered, failure });
|
|
2571
2608
|
}
|
|
2572
2609
|
record.completedAt ??= Date.now();
|
|
2573
2610
|
detach();
|
|
@@ -3094,21 +3131,7 @@ export class AgentManager {
|
|
|
3094
3131
|
id: string,
|
|
3095
3132
|
prompt: string,
|
|
3096
3133
|
signal?: AbortSignal,
|
|
3097
|
-
options?:
|
|
3098
|
-
isBackground?: boolean;
|
|
3099
|
-
onToolActivity?: (activity: {
|
|
3100
|
-
type: "start" | "end";
|
|
3101
|
-
toolName: string;
|
|
3102
|
-
}) => void;
|
|
3103
|
-
onAssistantUsage?: (usage: {
|
|
3104
|
-
input: number;
|
|
3105
|
-
output: number;
|
|
3106
|
-
cacheWrite: number;
|
|
3107
|
-
}) => void;
|
|
3108
|
-
onCompaction?: (info: unknown) => void;
|
|
3109
|
-
/** Called once a queued background resume has acquired its run slot. */
|
|
3110
|
-
onStarted?: () => void;
|
|
3111
|
-
},
|
|
3134
|
+
options?: ResumeOptions,
|
|
3112
3135
|
): Promise<AgentRecord | undefined> {
|
|
3113
3136
|
if (this.disposed) return undefined;
|
|
3114
3137
|
const record = this.agents.get(id);
|
|
@@ -3124,6 +3147,28 @@ export class AgentManager {
|
|
|
3124
3147
|
record.invocation?.inheritContext === true,
|
|
3125
3148
|
);
|
|
3126
3149
|
|
|
3150
|
+
if (
|
|
3151
|
+
options?.maxTurns !== undefined &&
|
|
3152
|
+
(!Number.isSafeInteger(options.maxTurns) || options.maxTurns < 0)
|
|
3153
|
+
) {
|
|
3154
|
+
throw new Error("maxTurns must be a non-negative safe integer");
|
|
3155
|
+
}
|
|
3156
|
+
// Nested definitions belong to their branch, never the global root registry.
|
|
3157
|
+
// Without the scoped tool's capability, retain the admitted invocation policy.
|
|
3158
|
+
const policy = record.parentAgentId === undefined
|
|
3159
|
+
? getAgentConfig(record.type)
|
|
3160
|
+
: options?.[INTERNAL_RESUME_BUDGET_POLICY] ?? record.invocation;
|
|
3161
|
+
const maxTurns = selectResumeTurnPolicy(record.session, resolveMaxTurns(
|
|
3162
|
+
policy,
|
|
3163
|
+
// A caller's zero cannot erase the original/configured safety policy.
|
|
3164
|
+
options?.maxTurns || record.invocation?.maxTurns,
|
|
3165
|
+
));
|
|
3166
|
+
try {
|
|
3167
|
+
options?.onBudgetResolved?.(maxTurns === 0 ? undefined : maxTurns);
|
|
3168
|
+
} catch {
|
|
3169
|
+
// UI observers cannot alter the authoritative policy or admission.
|
|
3170
|
+
}
|
|
3171
|
+
options = { ...options, maxTurns };
|
|
3127
3172
|
const snapshot = this.resumeSnapshot(record);
|
|
3128
3173
|
if (options?.isBackground) {
|
|
3129
3174
|
// A background resume is one lifecycle, including time spent queued. Keep
|
|
@@ -3177,18 +3222,31 @@ export class AgentManager {
|
|
|
3177
3222
|
record.result = undefined;
|
|
3178
3223
|
record.error = undefined;
|
|
3179
3224
|
try {
|
|
3180
|
-
|
|
3225
|
+
options.onStarted?.();
|
|
3226
|
+
const { text, failure, aborted, steered } = await resumeAgent(record.session, prompt, {
|
|
3227
|
+
maxTurns,
|
|
3228
|
+
onTurnEnd: (count) => {
|
|
3229
|
+
if (!record.detached) options.onTurnEnd?.(count);
|
|
3230
|
+
},
|
|
3231
|
+
onTextDelta: (delta, text) => {
|
|
3232
|
+
if (!record.detached) options.onTextDelta?.(delta, text);
|
|
3233
|
+
},
|
|
3181
3234
|
onToolActivity: (activity) => {
|
|
3182
|
-
if (
|
|
3235
|
+
if (record.detached) return;
|
|
3236
|
+
if (activity.type === "end") record.toolUses++;
|
|
3237
|
+
options.onToolActivity?.(activity);
|
|
3183
3238
|
},
|
|
3184
3239
|
onAssistantUsage: (usage) => {
|
|
3185
|
-
if (
|
|
3240
|
+
if (record.detached) return;
|
|
3241
|
+
addUsage(record.lifetimeUsage, usage);
|
|
3242
|
+
options.onAssistantUsage?.(usage);
|
|
3186
3243
|
},
|
|
3187
3244
|
onCompaction: (info) => {
|
|
3188
3245
|
if (record.detached) return;
|
|
3189
3246
|
record.compactionCount++;
|
|
3190
3247
|
this.syncManagedRecord(record);
|
|
3191
3248
|
this.onCompact?.(record, info);
|
|
3249
|
+
options.onCompaction?.(info);
|
|
3192
3250
|
},
|
|
3193
3251
|
signal: control.controller.signal,
|
|
3194
3252
|
});
|
|
@@ -3197,8 +3255,7 @@ export class AgentManager {
|
|
|
3197
3255
|
!control.controller.signal.aborted &&
|
|
3198
3256
|
(record.status as AgentRecord["status"]) !== "stopped"
|
|
3199
3257
|
) {
|
|
3200
|
-
record
|
|
3201
|
-
if (failure) record.error = failure;
|
|
3258
|
+
applyRunOutcome(record, { aborted, steered, failure });
|
|
3202
3259
|
record.result = text;
|
|
3203
3260
|
record.completedAt = Date.now();
|
|
3204
3261
|
}
|
|
@@ -3242,21 +3299,7 @@ export class AgentManager {
|
|
|
3242
3299
|
record: AgentRecord,
|
|
3243
3300
|
prompt: string,
|
|
3244
3301
|
control: ResumeControl,
|
|
3245
|
-
options?:
|
|
3246
|
-
isBackground?: boolean;
|
|
3247
|
-
onToolActivity?: (activity: {
|
|
3248
|
-
type: "start" | "end";
|
|
3249
|
-
toolName: string;
|
|
3250
|
-
}) => void;
|
|
3251
|
-
onAssistantUsage?: (usage: {
|
|
3252
|
-
input: number;
|
|
3253
|
-
output: number;
|
|
3254
|
-
cacheWrite: number;
|
|
3255
|
-
}) => void;
|
|
3256
|
-
onCompaction?: (info: unknown) => void;
|
|
3257
|
-
/** Called once a queued background resume has acquired its run slot. */
|
|
3258
|
-
onStarted?: () => void;
|
|
3259
|
-
},
|
|
3302
|
+
options?: ResumeOptions,
|
|
3260
3303
|
): Promise<string> {
|
|
3261
3304
|
const snapshot = control.snapshot;
|
|
3262
3305
|
let slotHeld = false;
|
|
@@ -3305,13 +3348,22 @@ export class AgentManager {
|
|
|
3305
3348
|
}
|
|
3306
3349
|
|
|
3307
3350
|
try {
|
|
3308
|
-
const { text, failure } = await resumeAgent(record.session!, prompt, {
|
|
3351
|
+
const { text, failure, aborted, steered } = await resumeAgent(record.session!, prompt, {
|
|
3352
|
+
maxTurns: options?.maxTurns,
|
|
3353
|
+
onTurnEnd: (count) => {
|
|
3354
|
+
if (!record.detached) options?.onTurnEnd?.(count);
|
|
3355
|
+
},
|
|
3356
|
+
onTextDelta: (delta, text) => {
|
|
3357
|
+
if (!record.detached) options?.onTextDelta?.(delta, text);
|
|
3358
|
+
},
|
|
3309
3359
|
onToolActivity: (activity) => {
|
|
3310
|
-
if (
|
|
3360
|
+
if (record.detached) return;
|
|
3361
|
+
if (activity.type === "end") record.toolUses++;
|
|
3311
3362
|
options?.onToolActivity?.(activity);
|
|
3312
3363
|
},
|
|
3313
3364
|
onAssistantUsage: (usage) => {
|
|
3314
|
-
if (
|
|
3365
|
+
if (record.detached) return;
|
|
3366
|
+
addUsage(record.lifetimeUsage, usage);
|
|
3315
3367
|
options?.onAssistantUsage?.(usage);
|
|
3316
3368
|
},
|
|
3317
3369
|
onCompaction: (info) => {
|
|
@@ -3328,8 +3380,7 @@ export class AgentManager {
|
|
|
3328
3380
|
!control.controller.signal.aborted &&
|
|
3329
3381
|
(record.status as AgentRecord["status"]) !== "stopped"
|
|
3330
3382
|
) {
|
|
3331
|
-
record
|
|
3332
|
-
if (failure) record.error = failure;
|
|
3383
|
+
applyRunOutcome(record, { aborted, steered, failure });
|
|
3333
3384
|
record.result = text;
|
|
3334
3385
|
record.completedAt = Date.now();
|
|
3335
3386
|
}
|
package/src/agent-runner.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { ANSWER_SUBAGENT_QUESTION, type ParentQuestionHandler } from "./parent-q
|
|
|
48
48
|
import { captureProjectTrust, childProjectTrust, configurationMcpFactory, isProjectResource } from "./project-trust.js";
|
|
49
49
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
50
50
|
import { captureQuestionOrigin } from "./question-origin.js";
|
|
51
|
+
import { getDefaultTurnPolicy, type RunBudgets, selectResumeTurnPolicy, sessionBudgets, setDefaultTurnPolicy } from "./run-budget-policy.js";
|
|
51
52
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
52
53
|
import { preloadSkills } from "./skill-loader.js";
|
|
53
54
|
import { createSupervisorTool } from "./supervisor.js";
|
|
@@ -353,9 +354,6 @@ async function runConfiguredGate(command: string, cwd: string, pi: ExtensionAPI)
|
|
|
353
354
|
}
|
|
354
355
|
}
|
|
355
356
|
|
|
356
|
-
/** Default max turns. undefined = unlimited (no turn limit). */
|
|
357
|
-
let defaultMaxTurns: number | undefined;
|
|
358
|
-
|
|
359
357
|
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
360
358
|
export function normalizeMaxTurns(n: number | undefined): number | undefined {
|
|
361
359
|
if (n == null || n === 0) return undefined;
|
|
@@ -363,9 +361,9 @@ export function normalizeMaxTurns(n: number | undefined): number | undefined {
|
|
|
363
361
|
}
|
|
364
362
|
|
|
365
363
|
/** Get the default max turns value. undefined = unlimited. */
|
|
366
|
-
export function getDefaultMaxTurns(): number | undefined { return
|
|
364
|
+
export function getDefaultMaxTurns(): number | undefined { return getDefaultTurnPolicy(); }
|
|
367
365
|
/** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
368
|
-
export function setDefaultMaxTurns(n: number | undefined): void {
|
|
366
|
+
export function setDefaultMaxTurns(n: number | undefined): void { setDefaultTurnPolicy(normalizeMaxTurns(n)); }
|
|
369
367
|
|
|
370
368
|
/** Additional turns allowed after the soft limit steer message. */
|
|
371
369
|
/**
|
|
@@ -1810,85 +1808,23 @@ export async function runAgent(
|
|
|
1810
1808
|
options.onSessionCreated(session);
|
|
1811
1809
|
}
|
|
1812
1810
|
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
let aborted = false;
|
|
1818
|
-
|
|
1819
|
-
// Resource budgets, in the same shape as max_turns: a wrap-up steer at the
|
|
1820
|
-
// soft threshold, an abort at the hard one. They bound what a single agent
|
|
1821
|
-
// can spend on its own, which turn count does not — one turn can burn an
|
|
1822
|
-
// arbitrary number of tokens or tool calls.
|
|
1823
|
-
//
|
|
1824
|
-
// `0` means unlimited here, matching this package's existing convention for
|
|
1825
|
-
// `maxTurns`. That is the opposite of `maxSubagentSpawnsPerBranch`, and
|
|
1826
|
-
// deliberately: these are opt-in resource caps that ship off, while the
|
|
1827
|
-
// branch spawn budget is a safety valve that ships on.
|
|
1828
|
-
const tokenBudget = normalizeMaxTurns(agentConfig?.maxTokens ?? defaultMaxTokens);
|
|
1829
|
-
const toolCallBudget = normalizeMaxTurns(agentConfig?.maxToolCalls ?? defaultMaxToolCalls);
|
|
1830
|
-
let budgetTokens = 0;
|
|
1831
|
-
let budgetToolCalls = 0;
|
|
1832
|
-
let tokenSoftReached = false;
|
|
1833
|
-
let toolSoftReached = false;
|
|
1834
|
-
|
|
1835
|
-
/**
|
|
1836
|
-
* Apply one budget, returning the next soft-limit state.
|
|
1837
|
-
*
|
|
1838
|
-
* The soft threshold is `SOFT_BUDGET_FRACTION` of the budget so the agent has
|
|
1839
|
-
* room left to actually write its answer after being told to wrap up — a
|
|
1840
|
-
* steer sent at 100% would be asking for a final response with no allowance
|
|
1841
|
-
* to produce it.
|
|
1842
|
-
*/
|
|
1843
|
-
const applyBudget = (used: number, budget: number | undefined, softReached: boolean, label: string): boolean => {
|
|
1844
|
-
if (budget == null) return softReached;
|
|
1845
|
-
// The hard limit is checked FIRST. One message can consume more than the
|
|
1846
|
-
// whole budget, and testing the soft threshold first would answer that with
|
|
1847
|
-
// a wrap-up steer and no abort — leaving an agent already over budget to
|
|
1848
|
-
// run on until its next event.
|
|
1849
|
-
if (used >= budget) {
|
|
1850
|
-
aborted = true;
|
|
1851
|
-
session.abort();
|
|
1852
|
-
return true;
|
|
1853
|
-
}
|
|
1854
|
-
if (!softReached && used >= budget * SOFT_BUDGET_FRACTION) {
|
|
1855
|
-
session.steer(
|
|
1856
|
-
`You are near your ${label} budget for this task. Wrap up immediately — provide your final answer now.`,
|
|
1857
|
-
);
|
|
1858
|
-
return true;
|
|
1859
|
-
}
|
|
1860
|
-
return softReached;
|
|
1811
|
+
const limits = {
|
|
1812
|
+
maxTurns: options.maxTurns ?? agentConfig?.maxTurns ?? getDefaultMaxTurns(),
|
|
1813
|
+
maxTokens: normalizeMaxTurns(agentConfig?.maxTokens ?? defaultMaxTokens),
|
|
1814
|
+
maxToolCalls: normalizeMaxTurns(agentConfig?.maxToolCalls ?? defaultMaxToolCalls),
|
|
1861
1815
|
};
|
|
1816
|
+
sessionBudgets.set(session, limits);
|
|
1817
|
+
const budget = createRunBudget(session, limits, options.onTurnEnd);
|
|
1862
1818
|
|
|
1863
1819
|
let currentMessageText = "";
|
|
1864
|
-
// Per-tool timeout: a hung bash/MCP call must not stall the subagent forever.
|
|
1865
|
-
// Each tool_execution_start arms a timer; the matching end clears it. On
|
|
1866
|
-
// timeout the session is aborted — which propagates via the tool's
|
|
1867
|
-
// AbortSignal into the hanging execute() and surfaces as an error result.
|
|
1868
|
-
// `contact_supervisor` is excluded: it intentionally waits for a human.
|
|
1869
1820
|
const toolTimeouts = createToolTimeouts(session, noExtensions ? 0 : TOOL_CALL_ABORT_GRACE_MS, approvalTimer.isInteractiveAsk);
|
|
1870
1821
|
approvalTimer.armApproved = toolTimeouts.armApproved;
|
|
1871
1822
|
approvalTimer.pauseHuman = toolTimeouts.pauseHuman;
|
|
1872
|
-
// Own finalized output before
|
|
1873
|
-
// failure must remain visible without erasing the answer it interrupted.
|
|
1823
|
+
// Own finalized output before dispatching observers that may throw.
|
|
1874
1824
|
const collector = collectResponseText(session);
|
|
1875
1825
|
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
options.onTurnEnd?.(turnCount);
|
|
1879
|
-
if (maxTurns != null) {
|
|
1880
|
-
if (!softLimitReached && turnCount >= maxTurns) {
|
|
1881
|
-
softLimitReached = true;
|
|
1882
|
-
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
|
|
1883
|
-
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
|
|
1884
|
-
aborted = true;
|
|
1885
|
-
session.abort();
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
}
|
|
1889
|
-
if (event.type === "message_start") {
|
|
1890
|
-
currentMessageText = "";
|
|
1891
|
-
}
|
|
1826
|
+
budget.onEvent(event);
|
|
1827
|
+
if (event.type === "message_start") currentMessageText = "";
|
|
1892
1828
|
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
1893
1829
|
currentMessageText += event.assistantMessageEvent.delta;
|
|
1894
1830
|
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
|
|
@@ -1900,22 +1836,14 @@ export async function runAgent(
|
|
|
1900
1836
|
if (event.type === "tool_execution_end") {
|
|
1901
1837
|
toolTimeouts.end(event);
|
|
1902
1838
|
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
1903
|
-
budgetToolCalls++;
|
|
1904
|
-
toolSoftReached = applyBudget(budgetToolCalls, toolCallBudget, toolSoftReached, "tool call");
|
|
1905
1839
|
}
|
|
1906
1840
|
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
1907
|
-
const u =
|
|
1841
|
+
const u = event.message.usage;
|
|
1908
1842
|
if (u) options.onAssistantUsage?.({
|
|
1909
1843
|
input: u.input ?? 0,
|
|
1910
1844
|
output: u.output ?? 0,
|
|
1911
1845
|
cacheWrite: u.cacheWrite ?? 0,
|
|
1912
1846
|
});
|
|
1913
|
-
if (u) {
|
|
1914
|
-
// Same total as `getLifetimeTotal`: input + output + cacheWrite, with
|
|
1915
|
-
// cacheRead deliberately excluded.
|
|
1916
|
-
budgetTokens += (u.input ?? 0) + (u.output ?? 0) + (u.cacheWrite ?? 0);
|
|
1917
|
-
tokenSoftReached = applyBudget(budgetTokens, tokenBudget, tokenSoftReached, "token");
|
|
1918
|
-
}
|
|
1919
1847
|
}
|
|
1920
1848
|
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
1921
1849
|
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
|
|
@@ -1934,20 +1862,16 @@ export async function runAgent(
|
|
|
1934
1862
|
}
|
|
1935
1863
|
|
|
1936
1864
|
const baseText = collector.getText();
|
|
1937
|
-
//
|
|
1938
|
-
// appended to the result, so the parent reads the check and the claim it is
|
|
1939
|
-
// checking side by side. It deliberately does not steer the agent to fix
|
|
1940
|
-
// what failed: the gate is evidence for the caller, not another turn.
|
|
1865
|
+
// Acceptance gates report evidence after the run, never spend another turn.
|
|
1941
1866
|
const responseText = agentConfig?.gate
|
|
1942
1867
|
? `${baseText}${await runConfiguredGate(agentConfig.gate, effectiveCwd, options.pi)}`
|
|
1943
1868
|
: baseText;
|
|
1944
1869
|
completed = true;
|
|
1945
|
-
return { responseText, session,
|
|
1870
|
+
return { responseText, session, ...budget.outcome(), failure: collector.getFailure() };
|
|
1946
1871
|
} finally {
|
|
1947
1872
|
cleanupAbort();
|
|
1948
|
-
//
|
|
1949
|
-
//
|
|
1950
|
-
// the session and must close it instead of leaving a live session behind.
|
|
1873
|
+
// Until onSessionCreated hands ownership to the manager, the runner must
|
|
1874
|
+
// close a session whose setup/prompt failed.
|
|
1951
1875
|
if (!handedOff && !completed) {
|
|
1952
1876
|
try {
|
|
1953
1877
|
if (!options.signal?.aborted) await session.abort();
|
|
@@ -1958,6 +1882,86 @@ export async function runAgent(
|
|
|
1958
1882
|
}
|
|
1959
1883
|
}
|
|
1960
1884
|
|
|
1885
|
+
/** One invocation's soft/hard budgets, shared by fresh and resumed prompts. */
|
|
1886
|
+
function createRunBudget(session: AgentSession, limits: RunBudgets, onTurnEnd?: (count: number) => void) {
|
|
1887
|
+
let turnCount = 0;
|
|
1888
|
+
const maxTurns = normalizeMaxTurns(limits.maxTurns);
|
|
1889
|
+
let softLimitReached = false;
|
|
1890
|
+
let aborted = false;
|
|
1891
|
+
|
|
1892
|
+
// Resource budgets, in the same shape as max_turns: a wrap-up steer at the
|
|
1893
|
+
// soft threshold, an abort at the hard one. They bound what a single agent
|
|
1894
|
+
// can spend on its own, which turn count does not — one turn can burn an
|
|
1895
|
+
// arbitrary number of tokens or tool calls.
|
|
1896
|
+
//
|
|
1897
|
+
// `0` means unlimited here, matching this package's existing convention for
|
|
1898
|
+
// `maxTurns`. That is the opposite of `maxSubagentSpawnsPerBranch`, and
|
|
1899
|
+
// deliberately: these are opt-in resource caps that ship off, while the
|
|
1900
|
+
// branch spawn budget is a safety valve that ships on.
|
|
1901
|
+
const tokenBudget = limits.maxTokens;
|
|
1902
|
+
const toolCallBudget = limits.maxToolCalls;
|
|
1903
|
+
let budgetTokens = 0;
|
|
1904
|
+
let budgetToolCalls = 0;
|
|
1905
|
+
let tokenSoftReached = false;
|
|
1906
|
+
let toolSoftReached = false;
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* Apply one budget, returning the next soft-limit state.
|
|
1910
|
+
*
|
|
1911
|
+
* The soft threshold is `SOFT_BUDGET_FRACTION` of the budget so the agent has
|
|
1912
|
+
* room left to actually write its answer after being told to wrap up — a
|
|
1913
|
+
* steer sent at 100% would be asking for a final response with no allowance
|
|
1914
|
+
* to produce it.
|
|
1915
|
+
*/
|
|
1916
|
+
const applyBudget = (used: number, budget: number | undefined, softReached: boolean, label: string): boolean => {
|
|
1917
|
+
if (budget == null) return softReached;
|
|
1918
|
+
// The hard limit is checked FIRST. One message can consume more than the
|
|
1919
|
+
// whole budget, and testing the soft threshold first would answer that with
|
|
1920
|
+
// a wrap-up steer and no abort — leaving an agent already over budget to
|
|
1921
|
+
// run on until its next event.
|
|
1922
|
+
if (used >= budget) {
|
|
1923
|
+
aborted = true;
|
|
1924
|
+
session.abort();
|
|
1925
|
+
return true;
|
|
1926
|
+
}
|
|
1927
|
+
if (!softReached && used >= budget * SOFT_BUDGET_FRACTION) {
|
|
1928
|
+
session.steer(
|
|
1929
|
+
`You are near your ${label} budget for this task. Wrap up immediately — provide your final answer now.`,
|
|
1930
|
+
);
|
|
1931
|
+
return true;
|
|
1932
|
+
}
|
|
1933
|
+
return softReached;
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1936
|
+
return {
|
|
1937
|
+
outcome: () => ({ aborted, steered: softLimitReached }),
|
|
1938
|
+
onEvent: (event: AgentSessionEvent): void => {
|
|
1939
|
+
if (event.type === "turn_end") {
|
|
1940
|
+
turnCount++;
|
|
1941
|
+
if (maxTurns != null) {
|
|
1942
|
+
if (!softLimitReached && turnCount >= maxTurns) {
|
|
1943
|
+
softLimitReached = true;
|
|
1944
|
+
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
|
|
1945
|
+
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
|
|
1946
|
+
aborted = true;
|
|
1947
|
+
session.abort();
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
onTurnEnd?.(turnCount);
|
|
1951
|
+
}
|
|
1952
|
+
if (event.type === "tool_execution_end") {
|
|
1953
|
+
budgetToolCalls++;
|
|
1954
|
+
toolSoftReached = applyBudget(budgetToolCalls, toolCallBudget, toolSoftReached, "tool call");
|
|
1955
|
+
}
|
|
1956
|
+
if (event.type === "message_end" && event.message.role === "assistant" && event.message.usage) {
|
|
1957
|
+
const u = event.message.usage;
|
|
1958
|
+
budgetTokens += (u.input ?? 0) + (u.output ?? 0) + (u.cacheWrite ?? 0);
|
|
1959
|
+
tokenSoftReached = applyBudget(budgetTokens, tokenBudget, tokenSoftReached, "token");
|
|
1960
|
+
}
|
|
1961
|
+
},
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1961
1965
|
/**
|
|
1962
1966
|
* Send a new prompt to an existing session (resume).
|
|
1963
1967
|
*/
|
|
@@ -1965,34 +1969,48 @@ export async function resumeAgent(
|
|
|
1965
1969
|
session: AgentSession,
|
|
1966
1970
|
prompt: string,
|
|
1967
1971
|
options: {
|
|
1972
|
+
maxTurns?: number;
|
|
1973
|
+
onTurnEnd?: (count: number) => void;
|
|
1974
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
1968
1975
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
1969
1976
|
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
1970
1977
|
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
|
|
1971
1978
|
signal?: AbortSignal;
|
|
1972
1979
|
} = {},
|
|
1973
|
-
): Promise<{ text: string; failure?: string }> {
|
|
1980
|
+
): Promise<{ text: string; failure?: string; aborted: boolean; steered: boolean }> {
|
|
1974
1981
|
throwIfAborted(options.signal);
|
|
1975
1982
|
assertSafeChildSession(session);
|
|
1976
1983
|
const collector = collectResponseText(session);
|
|
1977
1984
|
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
1978
1985
|
|
|
1979
|
-
const
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1986
|
+
const inherited = sessionBudgets.get(session);
|
|
1987
|
+
const budget = createRunBudget(session, {
|
|
1988
|
+
maxTurns: selectResumeTurnPolicy(session, options.maxTurns),
|
|
1989
|
+
maxTokens: inherited ? inherited.maxTokens : normalizeMaxTurns(defaultMaxTokens),
|
|
1990
|
+
maxToolCalls: inherited ? inherited.maxToolCalls : normalizeMaxTurns(defaultMaxToolCalls),
|
|
1991
|
+
}, options.onTurnEnd);
|
|
1992
|
+
let currentMessageText = "";
|
|
1993
|
+
const unsubEvents = session.subscribe((event: AgentSessionEvent) => {
|
|
1994
|
+
budget.onEvent(event);
|
|
1995
|
+
if (event.type === "message_start") currentMessageText = "";
|
|
1996
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
1997
|
+
currentMessageText += event.assistantMessageEvent.delta;
|
|
1998
|
+
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
|
|
1999
|
+
}
|
|
2000
|
+
if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
|
|
2001
|
+
if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
2002
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
2003
|
+
const u = event.message.usage;
|
|
2004
|
+
if (u) options.onAssistantUsage?.({
|
|
2005
|
+
input: u.input ?? 0,
|
|
2006
|
+
output: u.output ?? 0,
|
|
2007
|
+
cacheWrite: u.cacheWrite ?? 0,
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
2011
|
+
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
|
|
2012
|
+
}
|
|
2013
|
+
});
|
|
1996
2014
|
// The child policy and its consent gate survive resumed turns. Replace the
|
|
1997
2015
|
// original prompt's timer callback so a delayed approval cannot arm a stale
|
|
1998
2016
|
// timer that the first prompt no longer clears.
|
|
@@ -2025,6 +2043,7 @@ export async function resumeAgent(
|
|
|
2025
2043
|
|
|
2026
2044
|
return {
|
|
2027
2045
|
text: collector.getText(),
|
|
2046
|
+
...budget.outcome(),
|
|
2028
2047
|
failure: collector.getFailure(),
|
|
2029
2048
|
};
|
|
2030
2049
|
}
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
type ManagedSpawnRequest,
|
|
65
65
|
type ManagedSpawnResult,
|
|
66
66
|
type ManagedSpawnTombstone,
|
|
67
|
+
type SpawnOptions,
|
|
67
68
|
} from "./agent-manager.js";
|
|
68
69
|
import {
|
|
69
70
|
getAgentConversation,
|
|
@@ -327,7 +328,7 @@ function snapshotOwner(owner: AgentOwner | undefined): AgentOwner | undefined {
|
|
|
327
328
|
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
328
329
|
* Used by both foreground and background paths to avoid duplication.
|
|
329
330
|
*/
|
|
330
|
-
function
|
|
331
|
+
function buildActivityTracker(maxTurns: number | undefined, onStreamUpdate: (() => void) | undefined, isCurrent: () => boolean) {
|
|
331
332
|
const state: AgentActivity = {
|
|
332
333
|
activeTools: new Map(),
|
|
333
334
|
toolUses: 0,
|
|
@@ -340,6 +341,7 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
|
340
341
|
|
|
341
342
|
const callbacks = {
|
|
342
343
|
onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
|
|
344
|
+
if (!isCurrent()) return;
|
|
343
345
|
if (activity.type === "start") {
|
|
344
346
|
state.activeTools.set(
|
|
345
347
|
activity.toolName + "_" + Date.now(),
|
|
@@ -357,14 +359,17 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
|
357
359
|
onStreamUpdate?.();
|
|
358
360
|
},
|
|
359
361
|
onTextDelta: (_delta: string, fullText: string) => {
|
|
362
|
+
if (!isCurrent()) return;
|
|
360
363
|
state.responseText = fullText;
|
|
361
364
|
onStreamUpdate?.();
|
|
362
365
|
},
|
|
363
366
|
onTurnEnd: (turnCount: number) => {
|
|
367
|
+
if (!isCurrent()) return;
|
|
364
368
|
state.turnCount = turnCount;
|
|
365
369
|
onStreamUpdate?.();
|
|
366
370
|
},
|
|
367
371
|
onSessionCreated: (session: any) => {
|
|
372
|
+
if (!isCurrent()) return;
|
|
368
373
|
state.session = session;
|
|
369
374
|
},
|
|
370
375
|
onAssistantUsage: (usage: {
|
|
@@ -372,6 +377,7 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
|
372
377
|
output: number;
|
|
373
378
|
cacheWrite: number;
|
|
374
379
|
}) => {
|
|
380
|
+
if (!isCurrent()) return;
|
|
375
381
|
addUsage(state.lifetimeUsage, usage);
|
|
376
382
|
onStreamUpdate?.();
|
|
377
383
|
},
|
|
@@ -695,17 +701,52 @@ function activateRootRuntime(
|
|
|
695
701
|
// Holds notifications briefly so get_subagent_result can cancel them
|
|
696
702
|
// before they reach pi.sendMessage (fire-and-forget).
|
|
697
703
|
const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
|
|
704
|
+
let notificationsRetired = false;
|
|
705
|
+
const invocationUiRetirements = new Set<() => void>();
|
|
706
|
+
const activityRetirements = new WeakMap<AgentActivity, () => void>();
|
|
707
|
+
|
|
708
|
+
function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
709
|
+
let retired = notificationsRetired;
|
|
710
|
+
const timers = new Set<ReturnType<typeof setInterval>>();
|
|
711
|
+
const isCurrent = () => !retired && !notificationsRetired;
|
|
712
|
+
const retire = () => {
|
|
713
|
+
retired = true;
|
|
714
|
+
for (const timer of timers) clearInterval(timer);
|
|
715
|
+
timers.clear();
|
|
716
|
+
invocationUiRetirements.delete(retire);
|
|
717
|
+
};
|
|
718
|
+
const tracker = buildActivityTracker(maxTurns, () => {
|
|
719
|
+
if (isCurrent()) onStreamUpdate?.();
|
|
720
|
+
}, isCurrent);
|
|
721
|
+
if (!retired) invocationUiRetirements.add(retire);
|
|
722
|
+
activityRetirements.set(tracker.state, retire);
|
|
723
|
+
return {
|
|
724
|
+
...tracker,
|
|
725
|
+
isCurrent,
|
|
726
|
+
retire,
|
|
727
|
+
onBudgetResolved: (maxTurns: number | undefined) => {
|
|
728
|
+
if (isCurrent()) tracker.state.maxTurns = maxTurns;
|
|
729
|
+
},
|
|
730
|
+
ownTimer: (timer: ReturnType<typeof setInterval>) => {
|
|
731
|
+
if (isCurrent()) timers.add(timer);
|
|
732
|
+
else clearInterval(timer);
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
698
737
|
const NUDGE_HOLD_MS = 200;
|
|
699
738
|
// A queued result wait must observe completion before its held notification
|
|
700
739
|
// can fire, so successful waits can still suppress that redundant nudge.
|
|
701
740
|
const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
|
|
702
741
|
|
|
703
742
|
function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
|
|
743
|
+
if (notificationsRetired) return;
|
|
704
744
|
cancelNudge(key);
|
|
705
745
|
pendingNudges.set(
|
|
706
746
|
key,
|
|
707
747
|
setTimeout(() => {
|
|
708
748
|
pendingNudges.delete(key);
|
|
749
|
+
if (notificationsRetired) return;
|
|
709
750
|
try {
|
|
710
751
|
send();
|
|
711
752
|
} catch {
|
|
@@ -725,7 +766,7 @@ function activateRootRuntime(
|
|
|
725
766
|
|
|
726
767
|
// ---- Individual nudge helper (async join mode) ----
|
|
727
768
|
function emitIndividualNudge(record: AgentRecord) {
|
|
728
|
-
if (record.resultConsumed) return; // re-check at send time
|
|
769
|
+
if (notificationsRetired || record.resultConsumed) return; // re-check at send time
|
|
729
770
|
|
|
730
771
|
const notification = formatTaskNotification(record, 500);
|
|
731
772
|
const footer = record.outputFile
|
|
@@ -755,6 +796,7 @@ function activateRootRuntime(
|
|
|
755
796
|
|
|
756
797
|
// ---- Group join manager ----
|
|
757
798
|
const groupJoin = new GroupJoinManager((records, partial) => {
|
|
799
|
+
if (notificationsRetired) return;
|
|
758
800
|
for (const r of records) {
|
|
759
801
|
agentActivity.delete(r.id);
|
|
760
802
|
fleet.onAgentFinished(r.id);
|
|
@@ -951,6 +993,8 @@ function activateRootRuntime(
|
|
|
951
993
|
const manager = new AgentManager(
|
|
952
994
|
(record) => {
|
|
953
995
|
if (!record.detached) recordSessionUsage(record);
|
|
996
|
+
const activity = agentActivity.get(record.id);
|
|
997
|
+
if (activity) activityRetirements.get(activity)?.();
|
|
954
998
|
// Nested children report only through their owning parent's scoped tools.
|
|
955
999
|
// Keep them out of top-level lifecycle, transcript, notification, and UI channels.
|
|
956
1000
|
if (record.parentAgentId || record.detached) return;
|
|
@@ -999,6 +1043,10 @@ function activateRootRuntime(
|
|
|
999
1043
|
...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
|
|
1000
1044
|
});
|
|
1001
1045
|
|
|
1046
|
+
// Shutdown still needs authoritative usage, lifecycle and persistence
|
|
1047
|
+
// receipts above. Only UI/notification delivery is retired.
|
|
1048
|
+
if (notificationsRetired) return;
|
|
1049
|
+
|
|
1002
1050
|
// Managed workflow agents are consumed by their owner through lifecycle
|
|
1003
1051
|
// events. Suppress only their automatic main-session nudge/group message.
|
|
1004
1052
|
if (record.owner) {
|
|
@@ -1489,6 +1537,17 @@ function activateRootRuntime(
|
|
|
1489
1537
|
let runtimeShutdown: Promise<void> | undefined;
|
|
1490
1538
|
const shutdownRuntime = (): Promise<void> => {
|
|
1491
1539
|
runtimeShutdown ??= (async () => {
|
|
1540
|
+
// Retire delivery synchronously, before any provider/workflow await.
|
|
1541
|
+
// Cleared timers may already have a callback captured by the host.
|
|
1542
|
+
notificationsRetired = true;
|
|
1543
|
+
for (const retire of [...invocationUiRetirements]) retire();
|
|
1544
|
+
groupJoin.dispose();
|
|
1545
|
+
if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
|
|
1546
|
+
batchFinalizeTimer = undefined;
|
|
1547
|
+
currentBatchAgents = [];
|
|
1548
|
+
for (const timer of pendingNudges.values()) clearTimeout(timer);
|
|
1549
|
+
pendingNudges.clear();
|
|
1550
|
+
agentActivity.clear();
|
|
1492
1551
|
// session_shutdown is confirmed, unlike either before-event. Start the
|
|
1493
1552
|
// workflow's owned-agent cleanup while its RPC responder and the old leaf
|
|
1494
1553
|
// are still available, regardless of which extension loads first.
|
|
@@ -1771,12 +1830,14 @@ function activateRootRuntime(
|
|
|
1771
1830
|
// Allocate tracking only at a real fresh/reused-thread admission. An
|
|
1772
1831
|
// idempotent request replay must not create another tracker at all.
|
|
1773
1832
|
let tracker: ReturnType<typeof createActivityTracker> | undefined;
|
|
1774
|
-
|
|
1833
|
+
let resolvedResumeMaxTurns: number | undefined;
|
|
1834
|
+
const tracking = () => tracker ??= createActivityTracker(resolvedResumeMaxTurns, () => fleet.update());
|
|
1775
1835
|
const outputTranscript =
|
|
1776
1836
|
getAgentConfig(dispatch.type)?.outputTranscript ??
|
|
1777
1837
|
getOutputTranscriptDefault();
|
|
1778
1838
|
let id = "";
|
|
1779
1839
|
const initializeRecord = (spawnedId: string): void => {
|
|
1840
|
+
if (notificationsRetired) return;
|
|
1780
1841
|
id = spawnedId;
|
|
1781
1842
|
agentActivity.set(id, tracking().state);
|
|
1782
1843
|
const record = manager.getRecordMutable(id);
|
|
@@ -1802,7 +1863,12 @@ function activateRootRuntime(
|
|
|
1802
1863
|
onTurnEnd: count => tracker?.callbacks.onTurnEnd(count),
|
|
1803
1864
|
onAssistantUsage: usage => tracker?.callbacks.onAssistantUsage(usage),
|
|
1804
1865
|
onSpawned: initializeRecord,
|
|
1866
|
+
onResumeBudgetResolved: (maxTurns) => {
|
|
1867
|
+
resolvedResumeMaxTurns = maxTurns;
|
|
1868
|
+
tracker?.onBudgetResolved(maxTurns);
|
|
1869
|
+
},
|
|
1805
1870
|
onResumeStarting: (record: AgentRecord, startMessageIndex: number) => {
|
|
1871
|
+
if (notificationsRetired) return;
|
|
1806
1872
|
id = record.id;
|
|
1807
1873
|
const { state } = tracking();
|
|
1808
1874
|
state.session = record.session;
|
|
@@ -1820,6 +1886,7 @@ function activateRootRuntime(
|
|
|
1820
1886
|
fleet.update();
|
|
1821
1887
|
},
|
|
1822
1888
|
onSessionCreated: (session: AgentSession) => {
|
|
1889
|
+
if (notificationsRetired) return;
|
|
1823
1890
|
tracking().callbacks.onSessionCreated(session);
|
|
1824
1891
|
const record = manager.getRecordMutable(id);
|
|
1825
1892
|
if (record?.outputFile) {
|
|
@@ -1832,6 +1899,7 @@ function activateRootRuntime(
|
|
|
1832
1899
|
}
|
|
1833
1900
|
},
|
|
1834
1901
|
onCompaction: () => {
|
|
1902
|
+
if (notificationsRetired) return;
|
|
1835
1903
|
fleet.update();
|
|
1836
1904
|
},
|
|
1837
1905
|
};
|
|
@@ -1907,16 +1975,17 @@ function activateRootRuntime(
|
|
|
1907
1975
|
): string {
|
|
1908
1976
|
const config = getAgentConfig(type);
|
|
1909
1977
|
const resolved = resolveAgentInvocationConfig(config, {});
|
|
1910
|
-
const
|
|
1978
|
+
const tracker = createActivityTracker(undefined, () => {
|
|
1911
1979
|
fleet.update();
|
|
1912
1980
|
});
|
|
1981
|
+
const { state, callbacks } = tracker;
|
|
1913
1982
|
// Same sole gate as every other spawn path: `record.outputFile` being set is
|
|
1914
1983
|
// what every downstream consumer keys off, so an agent whose frontmatter
|
|
1915
1984
|
// says `output_transcript: false` must not gain one by being mentioned.
|
|
1916
1985
|
const outputTranscript =
|
|
1917
1986
|
config?.outputTranscript ?? getOutputTranscriptDefault();
|
|
1918
1987
|
let id = "";
|
|
1919
|
-
const
|
|
1988
|
+
const spawnOptions: SpawnOptions = {
|
|
1920
1989
|
description: opts.description,
|
|
1921
1990
|
maxTurns: normalizeMaxTurns(resolved.maxTurns ?? getDefaultMaxTurns()),
|
|
1922
1991
|
isolated: resolved.isolated,
|
|
@@ -1939,6 +2008,7 @@ function activateRootRuntime(
|
|
|
1939
2008
|
},
|
|
1940
2009
|
...callbacks,
|
|
1941
2010
|
onSpawned: (newId) => {
|
|
2011
|
+
if (notificationsRetired) return;
|
|
1942
2012
|
id = newId;
|
|
1943
2013
|
agentActivity.set(id, state);
|
|
1944
2014
|
const record = manager.getRecordMutable(id);
|
|
@@ -1958,6 +2028,7 @@ function activateRootRuntime(
|
|
|
1958
2028
|
fleet.update();
|
|
1959
2029
|
},
|
|
1960
2030
|
onSessionCreated: (session) => {
|
|
2031
|
+
if (notificationsRetired) return;
|
|
1961
2032
|
callbacks.onSessionCreated(session);
|
|
1962
2033
|
const record = manager.getRecordMutable(id);
|
|
1963
2034
|
if (record?.outputFile) {
|
|
@@ -1976,8 +2047,13 @@ function activateRootRuntime(
|
|
|
1976
2047
|
);
|
|
1977
2048
|
}
|
|
1978
2049
|
},
|
|
1979
|
-
}
|
|
1980
|
-
|
|
2050
|
+
};
|
|
2051
|
+
try {
|
|
2052
|
+
return manager.spawn(pi, ctxRef, type, prompt, spawnOptions);
|
|
2053
|
+
} catch (error) {
|
|
2054
|
+
tracker.retire();
|
|
2055
|
+
throw error;
|
|
2056
|
+
}
|
|
1981
2057
|
}
|
|
1982
2058
|
|
|
1983
2059
|
// ---- Join mode configuration ----
|
|
@@ -2038,6 +2114,7 @@ function activateRootRuntime(
|
|
|
2038
2114
|
|
|
2039
2115
|
/** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
|
|
2040
2116
|
function finalizeBatch() {
|
|
2117
|
+
if (notificationsRetired) return;
|
|
2041
2118
|
batchFinalizeTimer = undefined;
|
|
2042
2119
|
const batchAgents = [...currentBatchAgents];
|
|
2043
2120
|
currentBatchAgents = [];
|
|
@@ -2240,24 +2317,53 @@ function activateRootRuntime(
|
|
|
2240
2317
|
}
|
|
2241
2318
|
|
|
2242
2319
|
if (record.session) {
|
|
2320
|
+
const tracker = createActivityTracker(undefined, () => fleet.update());
|
|
2321
|
+
const { state, callbacks } = tracker;
|
|
2322
|
+
state.session = record.session;
|
|
2323
|
+
const originCurrent = rootUiFence();
|
|
2324
|
+
const session = record.session;
|
|
2325
|
+
const owner = record.owner;
|
|
2326
|
+
const canNotify = () => !notificationsRetired && originCurrent() &&
|
|
2327
|
+
manager.getRecordMutable(record.id) === record && !record.detached &&
|
|
2328
|
+
record.session === session && record.owner === owner;
|
|
2329
|
+
let admitted = false;
|
|
2243
2330
|
try {
|
|
2244
2331
|
const resumed = await manager.resume(
|
|
2245
2332
|
record.id,
|
|
2246
2333
|
mention.message,
|
|
2247
2334
|
undefined,
|
|
2248
|
-
{
|
|
2335
|
+
{
|
|
2336
|
+
isBackground: true,
|
|
2337
|
+
...callbacks,
|
|
2338
|
+
onBudgetResolved: tracker.onBudgetResolved,
|
|
2339
|
+
onStarted: () => {
|
|
2340
|
+
if (tracker.isCurrent()) agentActivity.set(record.id, state);
|
|
2341
|
+
},
|
|
2342
|
+
},
|
|
2249
2343
|
);
|
|
2250
|
-
|
|
2344
|
+
admitted = resumed?.promise !== undefined;
|
|
2345
|
+
if (resumed && tracker.isCurrent() && (resumed.status === "queued" || resumed.status === "running")) {
|
|
2346
|
+
agentActivity.set(record.id, state);
|
|
2347
|
+
}
|
|
2348
|
+
void resumed?.promise?.finally(() => {
|
|
2349
|
+
tracker.retire();
|
|
2350
|
+
if (agentActivity.get(record.id) !== state) return;
|
|
2351
|
+
agentActivity.delete(record.id);
|
|
2352
|
+
fleet.onAgentFinished(record.id);
|
|
2353
|
+
}).catch(() => {});
|
|
2354
|
+
if (canNotify()) ctx.ui.notify(
|
|
2251
2355
|
resumed
|
|
2252
2356
|
? `Resuming ${target}`
|
|
2253
2357
|
: `Could not resume ${target} — it is still running.`,
|
|
2254
2358
|
resumed ? "info" : "warning",
|
|
2255
2359
|
);
|
|
2256
2360
|
} catch (error) {
|
|
2257
|
-
ctx.ui.notify(
|
|
2361
|
+
if (canNotify()) ctx.ui.notify(
|
|
2258
2362
|
`Could not resume ${target}: ${error instanceof Error ? error.message : String(error)}`,
|
|
2259
2363
|
"warning",
|
|
2260
2364
|
);
|
|
2365
|
+
} finally {
|
|
2366
|
+
if (!admitted) tracker.retire();
|
|
2261
2367
|
}
|
|
2262
2368
|
return { action: "handled" };
|
|
2263
2369
|
}
|
|
@@ -3076,8 +3182,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3076
3182
|
// the flag was accepted then silently dropped — a resumed agent always
|
|
3077
3183
|
// blocked the caller until it finished.
|
|
3078
3184
|
if (runInBackground) {
|
|
3079
|
-
const
|
|
3080
|
-
|
|
3185
|
+
const bgTracker = createActivityTracker(undefined, () => fleet.update());
|
|
3186
|
+
const { state: bgState, callbacks: bgCallbacks } = bgTracker;
|
|
3081
3187
|
// resumeAgent has no onSessionCreated — the session predates this run
|
|
3082
3188
|
// — so seed the activity tracker directly.
|
|
3083
3189
|
bgState.session = existing.session;
|
|
@@ -3097,6 +3203,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3097
3203
|
// this index, so it is written exactly once.
|
|
3098
3204
|
const transcriptAnchor = existing.session.messages.length ?? 0;
|
|
3099
3205
|
const attachTranscript = (): void => {
|
|
3206
|
+
if (!bgTracker.isCurrent()) return;
|
|
3207
|
+
agentActivity.set(existing.id, bgState);
|
|
3100
3208
|
if (!existing.outputFile || existing.outputCleanup) return;
|
|
3101
3209
|
existing.outputCleanup = streamToOutputFile(
|
|
3102
3210
|
existing.session!,
|
|
@@ -3106,25 +3214,38 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3106
3214
|
transcriptAnchor,
|
|
3107
3215
|
);
|
|
3108
3216
|
};
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
undefined,
|
|
3113
|
-
{
|
|
3217
|
+
let record: AgentRecord | undefined;
|
|
3218
|
+
let admitted = false;
|
|
3219
|
+
try {
|
|
3220
|
+
record = await manager.resume(params.resume, params.prompt, undefined, {
|
|
3114
3221
|
isBackground: true,
|
|
3222
|
+
maxTurns: params.max_turns,
|
|
3223
|
+
onBudgetResolved: bgTracker.onBudgetResolved,
|
|
3115
3224
|
onToolActivity: bgCallbacks.onToolActivity,
|
|
3225
|
+
onTurnEnd: bgCallbacks.onTurnEnd,
|
|
3226
|
+
onTextDelta: bgCallbacks.onTextDelta,
|
|
3116
3227
|
onAssistantUsage: bgCallbacks.onAssistantUsage,
|
|
3117
3228
|
onStarted: attachTranscript,
|
|
3118
|
-
}
|
|
3119
|
-
|
|
3229
|
+
});
|
|
3230
|
+
admitted = record?.promise !== undefined;
|
|
3231
|
+
} finally {
|
|
3232
|
+
if (!admitted) bgTracker.retire();
|
|
3233
|
+
}
|
|
3120
3234
|
if (!record) {
|
|
3121
3235
|
return textResult(
|
|
3122
3236
|
`Cannot resume agent "${params.resume}" in background — it is already running. ` +
|
|
3123
3237
|
"Wait for it to settle, or steer it with steer_subagent.",
|
|
3124
3238
|
);
|
|
3125
3239
|
}
|
|
3126
|
-
|
|
3127
|
-
|
|
3240
|
+
if (bgTracker.isCurrent() && (record.status === "queued" || record.status === "running")) {
|
|
3241
|
+
agentActivity.set(params.resume, bgState);
|
|
3242
|
+
}
|
|
3243
|
+
void record.promise?.finally(() => {
|
|
3244
|
+
bgTracker.retire();
|
|
3245
|
+
if (agentActivity.get(record.id) !== bgState) return;
|
|
3246
|
+
agentActivity.delete(record.id);
|
|
3247
|
+
fleet.onAgentFinished(record.id);
|
|
3248
|
+
}).catch(() => {});
|
|
3128
3249
|
return textResult(
|
|
3129
3250
|
record.status === "queued"
|
|
3130
3251
|
? `Agent "${params.resume}" resumed in background (queued at the concurrency limit).`
|
|
@@ -3133,11 +3254,34 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3133
3254
|
);
|
|
3134
3255
|
}
|
|
3135
3256
|
|
|
3136
|
-
const
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3257
|
+
const tracker = createActivityTracker(undefined, () => {
|
|
3258
|
+
if (!tracker.isCurrent()) return;
|
|
3259
|
+
fleet.update();
|
|
3260
|
+
if (!tracker.isCurrent()) return;
|
|
3261
|
+
onUpdate?.(textResult(
|
|
3262
|
+
"Resuming agent...",
|
|
3263
|
+
buildDetails(detailBase, existing, state),
|
|
3264
|
+
));
|
|
3265
|
+
});
|
|
3266
|
+
const { state, callbacks } = tracker;
|
|
3267
|
+
state.session = existing.session;
|
|
3268
|
+
let record: AgentRecord | undefined;
|
|
3269
|
+
try {
|
|
3270
|
+
record = await manager.resume(params.resume, params.prompt, signal, {
|
|
3271
|
+
...callbacks,
|
|
3272
|
+
maxTurns: params.max_turns,
|
|
3273
|
+
onBudgetResolved: tracker.onBudgetResolved,
|
|
3274
|
+
onStarted: () => {
|
|
3275
|
+
if (tracker.isCurrent()) agentActivity.set(existing.id, state);
|
|
3276
|
+
},
|
|
3277
|
+
});
|
|
3278
|
+
} finally {
|
|
3279
|
+
tracker.retire();
|
|
3280
|
+
if (agentActivity.get(existing.id) === state) {
|
|
3281
|
+
agentActivity.delete(existing.id);
|
|
3282
|
+
fleet.onAgentFinished(existing.id);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3141
3285
|
if (!record) {
|
|
3142
3286
|
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
3143
3287
|
}
|
|
@@ -3146,19 +3290,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3146
3290
|
if (record.status === "error") {
|
|
3147
3291
|
return textResult(
|
|
3148
3292
|
`Agent failed: ${record.error}${partialOutputSuffix(record)}`,
|
|
3149
|
-
buildDetails(detailBase, record),
|
|
3293
|
+
buildDetails(detailBase, record, state),
|
|
3150
3294
|
);
|
|
3151
3295
|
}
|
|
3296
|
+
const output = record.result?.trim() || "No output.";
|
|
3297
|
+
const outcome = getForegroundOutcomeNote(record.status);
|
|
3152
3298
|
return textResult(
|
|
3153
|
-
|
|
3154
|
-
buildDetails(detailBase, record),
|
|
3299
|
+
outcome ? `${output}\n\n[pi-subagents outcome]${outcome}` : output,
|
|
3300
|
+
buildDetails(detailBase, record, state),
|
|
3155
3301
|
);
|
|
3156
3302
|
}
|
|
3157
3303
|
|
|
3158
3304
|
// Background execution
|
|
3159
3305
|
if (runInBackground) {
|
|
3160
|
-
const
|
|
3161
|
-
|
|
3306
|
+
const bgTracker = createActivityTracker(effectiveMaxTurns);
|
|
3307
|
+
const { state: bgState, callbacks: bgCallbacks } = bgTracker;
|
|
3162
3308
|
|
|
3163
3309
|
// Wrap onSessionCreated to wire output file streaming.
|
|
3164
3310
|
// The callback lazily reads record.outputFile (set right after spawn)
|
|
@@ -3166,6 +3312,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3166
3312
|
let id: string;
|
|
3167
3313
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
3168
3314
|
bgCallbacks.onSessionCreated = (session: any) => {
|
|
3315
|
+
if (notificationsRetired) return;
|
|
3169
3316
|
origBgOnSession(session);
|
|
3170
3317
|
const rec = manager.getRecordMutable(id);
|
|
3171
3318
|
if (rec?.outputFile) {
|
|
@@ -3180,7 +3327,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3180
3327
|
|
|
3181
3328
|
// A startup throw means the agent never started. Let pi mark the tool
|
|
3182
3329
|
// call as failed instead of returning a successful-looking text result.
|
|
3183
|
-
|
|
3330
|
+
const spawnOptions: SpawnOptions = {
|
|
3184
3331
|
description: params.description,
|
|
3185
3332
|
model: resolvedConfig.agentTierSelected ? undefined : model,
|
|
3186
3333
|
maxTurns: effectiveMaxTurns,
|
|
@@ -3196,7 +3343,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3196
3343
|
invocation: agentInvocation,
|
|
3197
3344
|
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
3198
3345
|
...bgCallbacks,
|
|
3199
|
-
}
|
|
3346
|
+
};
|
|
3347
|
+
try {
|
|
3348
|
+
id = manager.spawn(pi, ctx, subagentType, params.prompt, spawnOptions);
|
|
3349
|
+
} catch (error) {
|
|
3350
|
+
bgTracker.retire();
|
|
3351
|
+
throw error;
|
|
3352
|
+
}
|
|
3200
3353
|
// The clone needs a positive spawn witness before any UI, transcript,
|
|
3201
3354
|
// or event observer can throw. A startup failure inside manager.spawn
|
|
3202
3355
|
// removes its record and does not reach this acknowledgement.
|
|
@@ -3275,8 +3428,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3275
3428
|
let handedOff = false;
|
|
3276
3429
|
|
|
3277
3430
|
const streamUpdate = () => {
|
|
3431
|
+
if (!fgTracker.isCurrent()) return;
|
|
3278
3432
|
fleet.update();
|
|
3279
|
-
if (!foregroundUpdates) return;
|
|
3433
|
+
if (!fgTracker.isCurrent() || !foregroundUpdates) return;
|
|
3280
3434
|
const details: AgentDetails = {
|
|
3281
3435
|
...detailBase,
|
|
3282
3436
|
toolUses: fgState.toolUses,
|
|
@@ -3294,16 +3448,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3294
3448
|
});
|
|
3295
3449
|
};
|
|
3296
3450
|
|
|
3297
|
-
const
|
|
3298
|
-
|
|
3299
|
-
streamUpdate,
|
|
3300
|
-
);
|
|
3451
|
+
const fgTracker = createActivityTracker(effectiveMaxTurns, streamUpdate);
|
|
3452
|
+
const { state: fgState, callbacks: fgCallbacks } = fgTracker;
|
|
3301
3453
|
|
|
3302
3454
|
// Wire session creation: register in the FleetView list + stream to the output file.
|
|
3303
3455
|
// The output file path is set synchronously after spawn (below),
|
|
3304
3456
|
// before onSessionCreated fires — same pattern as background agents.
|
|
3305
3457
|
const origOnSession = fgCallbacks.onSessionCreated;
|
|
3306
3458
|
fgCallbacks.onSessionCreated = (session: any) => {
|
|
3459
|
+
if (!fgTracker.isCurrent()) return;
|
|
3307
3460
|
origOnSession(session);
|
|
3308
3461
|
for (const a of manager.listAgentsMutable()) {
|
|
3309
3462
|
if (a.session === session) {
|
|
@@ -3329,9 +3482,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3329
3482
|
};
|
|
3330
3483
|
|
|
3331
3484
|
const spinnerInterval = setInterval(() => {
|
|
3485
|
+
if (!fgTracker.isCurrent()) return;
|
|
3332
3486
|
spinnerFrame++;
|
|
3333
3487
|
streamUpdate();
|
|
3334
3488
|
}, SPINNER_INTERVAL_MS);
|
|
3489
|
+
fgTracker.ownTimer(spinnerInterval);
|
|
3335
3490
|
|
|
3336
3491
|
streamUpdate();
|
|
3337
3492
|
|
|
@@ -3374,7 +3529,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3374
3529
|
// A transfer ends this tool's spinner, not the child's activity or stream.
|
|
3375
3530
|
foregroundUpdates = false;
|
|
3376
3531
|
clearInterval(spinnerInterval);
|
|
3377
|
-
if (
|
|
3532
|
+
if (!handedOff) fgTracker.retire();
|
|
3533
|
+
if (fgId && !handedOff && !notificationsRetired) {
|
|
3378
3534
|
agentActivity.delete(fgId);
|
|
3379
3535
|
fleet.onAgentFinished(fgId);
|
|
3380
3536
|
}
|
package/src/internal-run.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
8
|
-
import type { ThinkingLevel } from "./types.js";
|
|
8
|
+
import type { AgentConfig, ThinkingLevel } from "./types.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Immutable policy used while asking a model to draft an agent definition.
|
|
@@ -39,6 +39,10 @@ export interface ParentPolicySnapshot {
|
|
|
39
39
|
|
|
40
40
|
export const INTERNAL_PARENT_POLICY_SNAPSHOT = Symbol("pi-subagents.internal-parent-policy-snapshot");
|
|
41
41
|
|
|
42
|
+
/** Branch-local frontmatter only; never accepts another run/config capability. */
|
|
43
|
+
export type ResumeBudgetPolicy = Pick<AgentConfig, "maxTurns">;
|
|
44
|
+
export const INTERNAL_RESUME_BUDGET_POLICY = Symbol("pi-subagents.internal-resume-budget-policy");
|
|
45
|
+
|
|
42
46
|
/** The only no-tool policy currently issued by the package. */
|
|
43
47
|
export const AGENT_DEFINITION_GENERATION_OVERRIDE: InternalAgentConfigOverride = Object.freeze({
|
|
44
48
|
builtinToolNames: Object.freeze([] as string[]),
|
package/src/invocation-config.ts
CHANGED
|
@@ -27,6 +27,11 @@ interface AgentInvocationParams {
|
|
|
27
27
|
isolation?: IsolationMode;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Frontmatter is authoritative, including its explicit unlimited value. */
|
|
31
|
+
export function resolveMaxTurns(agentConfig: Pick<AgentConfig, "maxTurns"> | undefined, requested?: number): number | undefined {
|
|
32
|
+
return agentConfig?.maxTurns ?? requested;
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
export function resolveAgentInvocationConfig(
|
|
31
36
|
agentConfig: AgentConfig | undefined,
|
|
32
37
|
params: AgentInvocationParams,
|
|
@@ -62,7 +67,7 @@ export function resolveAgentInvocationConfig(
|
|
|
62
67
|
thinking: agentTierSelected
|
|
63
68
|
? undefined
|
|
64
69
|
: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
|
|
65
|
-
maxTurns: agentConfig
|
|
70
|
+
maxTurns: resolveMaxTurns(agentConfig, params.max_turns),
|
|
66
71
|
inheritContext:
|
|
67
72
|
agentConfig?.inheritContext ?? params.inherit_context ?? false,
|
|
68
73
|
runInBackground:
|
package/src/nested-tools.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "./agent-types.js";
|
|
19
19
|
import { INHERIT_CONTEXT_UNAVAILABLE } from "./context-boundary.js";
|
|
20
20
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
21
|
+
import { INTERNAL_RESUME_BUDGET_POLICY, type ResumeBudgetPolicy } from "./internal-run.js";
|
|
21
22
|
import { resolveAgentInvocationConfig } from "./invocation-config.js";
|
|
22
23
|
import { resolveModel } from "./model-resolver.js";
|
|
23
24
|
import { checkModelScope } from "./model-scope.js";
|
|
@@ -92,7 +93,7 @@ export interface NestedAgentManager {
|
|
|
92
93
|
): Promise<{ id: string; record: AgentRecord }>;
|
|
93
94
|
/** Internal live lookup used only by the scoped manager runtime. */
|
|
94
95
|
getRecordMutable(id: string): AgentRecord | undefined;
|
|
95
|
-
resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
|
|
96
|
+
resume(id: string, prompt: string, signal?: AbortSignal, options?: { maxTurns?: number; readonly [INTERNAL_RESUME_BUDGET_POLICY]?: ResumeBudgetPolicy }): Promise<AgentRecord | undefined>;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
export interface NestedToolContext {
|
|
@@ -194,7 +195,11 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
194
195
|
if (signal?.aborted) {
|
|
195
196
|
return textResult("Resume aborted.", true);
|
|
196
197
|
}
|
|
197
|
-
const
|
|
198
|
+
const config = getAgentConfigIn(loadRegistry(), existing.type);
|
|
199
|
+
const resumed = await context.manager.resume(params.resume, params.prompt, signal, {
|
|
200
|
+
maxTurns: params.max_turns,
|
|
201
|
+
[INTERNAL_RESUME_BUDGET_POLICY]: { maxTurns: config?.maxTurns },
|
|
202
|
+
});
|
|
198
203
|
return resumed
|
|
199
204
|
? textResult(formatRecord(resumed, "inline"), resumed.status === "error")
|
|
200
205
|
: textResult(`Failed to resume nested agent "${params.resume}".`, true);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface RunBudgets {
|
|
4
|
+
maxTurns?: number;
|
|
5
|
+
maxTokens?: number;
|
|
6
|
+
maxToolCalls?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Session policy survives each prompt; counters and UI observers never do.
|
|
10
|
+
export const sessionBudgets = new WeakMap<AgentSession, RunBudgets>();
|
|
11
|
+
let defaultTurnPolicy: number | undefined;
|
|
12
|
+
|
|
13
|
+
export function getDefaultTurnPolicy(): number | undefined { return defaultTurnPolicy; }
|
|
14
|
+
export function setDefaultTurnPolicy(value: number | undefined): void { defaultTurnPolicy = value; }
|
|
15
|
+
|
|
16
|
+
/** Raw zero preserves authoritative unlimited policy across settings changes. */
|
|
17
|
+
export function selectResumeTurnPolicy(session: AgentSession, resolved?: number): number {
|
|
18
|
+
if (resolved !== undefined) return resolved;
|
|
19
|
+
const inherited = sessionBudgets.get(session);
|
|
20
|
+
if (inherited) return inherited.maxTurns ?? 0;
|
|
21
|
+
return defaultTurnPolicy ?? 0;
|
|
22
|
+
}
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -556,7 +556,9 @@ export class FleetList {
|
|
|
556
556
|
const color = status === "error" ? "error" : status === "stopped" ? "warning" : selected ? "accent" : "dim";
|
|
557
557
|
const name = sanitizeDisplayText(run.name);
|
|
558
558
|
const phase = run.currentPhase ? ` · ${sanitizeDisplayText(run.currentPhase)}` : "";
|
|
559
|
-
|
|
559
|
+
// A workflow is an expandable group, not another agent. Keep its
|
|
560
|
+
// type mark distinct at every outcome; color/text still report status.
|
|
561
|
+
const left = ` ${theme.fg(color, "▸")} ${theme.fg(selected ? "accent" : "muted", name)} · ${theme.fg(color, run.status)}${phase} · ${run.runningCalls}/${run.callCount} running · ${run.completedCalls} done · ${run.failedCalls} failed${run.restored ? " · restored" : ""}`;
|
|
560
562
|
const right = `${formatFleetElapsed(run.elapsedMs)}${run.tokenCount === undefined ? "" : ` · ${formatFleetTokens(run.tokenCount)}`}`;
|
|
561
563
|
lines.push(rightAlign(left, theme.fg("dim", right), width));
|
|
562
564
|
} else {
|
|
@@ -567,7 +569,7 @@ export class FleetList {
|
|
|
567
569
|
}
|
|
568
570
|
if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `${hiddenBelow} more below`), width));
|
|
569
571
|
|
|
570
|
-
|
|
572
|
+
// One separator belongs to Fleet; do not stack two empty rows before the footer.
|
|
571
573
|
return [...lines.slice(0, height - 1), ""].map(line => truncateToWidth(line, Math.max(0, width)));
|
|
572
574
|
}
|
|
573
575
|
|