@signalridge/pi-subagents 1.13.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 +29 -0
- package/README.md +14 -0
- package/package.json +2 -1
- package/src/agent-manager.ts +102 -51
- package/src/agent-runner.ts +280 -146
- 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/operation-approvals.ts +49 -0
- package/src/run-budget-policy.ts +22 -0
- package/src/ui/fleet-list.ts +4 -2
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,49 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { defineTool, type ExtensionContext, type ExtensionToolContext, type SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type ApprovalService, createApprovalToolDefinitions } from "@signalridge/pi-approval-core";
|
|
4
|
+
import { captureQuestionOrigin } from "./question-origin.js";
|
|
5
|
+
|
|
6
|
+
/** Human service capture and native context leases live for the entire child session. */
|
|
7
|
+
export function createChildApprovalTools(options: {
|
|
8
|
+
service: ApprovalService;
|
|
9
|
+
assertServiceCurrent: () => void;
|
|
10
|
+
root: ExtensionContext;
|
|
11
|
+
rootOrigin: (current: ExtensionContext) => boolean;
|
|
12
|
+
agentId?: string;
|
|
13
|
+
settings: SettingsManager;
|
|
14
|
+
waiting: (toolCallId: string) => () => void;
|
|
15
|
+
isBound: () => boolean;
|
|
16
|
+
isToolCurrent: (name: string) => boolean;
|
|
17
|
+
}): ToolDefinition[] {
|
|
18
|
+
const leases = new WeakMap<ExtensionToolContext, () => boolean>();
|
|
19
|
+
const [edit, write, bash] = createApprovalToolDefinitions({
|
|
20
|
+
source: `child:${options.agentId ?? randomUUID()}`,
|
|
21
|
+
shellPath: options.settings.getShellPath(),
|
|
22
|
+
commandPrefix: options.settings.getShellCommandPrefix(),
|
|
23
|
+
isToolCurrent: (_ctx, name) => options.isToolCurrent(name),
|
|
24
|
+
isCurrent: (ctx) => {
|
|
25
|
+
let lease = leases.get(ctx);
|
|
26
|
+
if (!lease) {
|
|
27
|
+
const childOrigin = captureQuestionOrigin(ctx);
|
|
28
|
+
const invocationRoot = captureQuestionOrigin(options.root);
|
|
29
|
+
lease = () => {
|
|
30
|
+
if (ctx.signal?.aborted || !options.isBound() || !options.rootOrigin(options.root) ||
|
|
31
|
+
!invocationRoot(options.root) || !childOrigin(ctx)) return false;
|
|
32
|
+
options.assertServiceCurrent();
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
leases.set(ctx, lease);
|
|
36
|
+
}
|
|
37
|
+
return lease();
|
|
38
|
+
},
|
|
39
|
+
request: async (operation, owner) => {
|
|
40
|
+
const resume = options.waiting(operation.toolCallId);
|
|
41
|
+
try {
|
|
42
|
+
await options.service.request(operation, owner);
|
|
43
|
+
} finally {
|
|
44
|
+
resume();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return [defineTool(edit), defineTool(write), defineTool(bash)];
|
|
49
|
+
}
|
|
@@ -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
|
+
}
|