@ian-pascoe/pi-minimal-subagents 0.20.1 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +7 -7
- package/src/minimal-subagents-coordinator.ts +135 -56
- package/src/minimal-subagents-extension.ts +1 -0
- package/src/minimal-subagents-registry.ts +4 -2
- package/src/minimal-subagents-tool-schemas.ts +1 -1
- package/src/minimal-subagents-tools.ts +19 -12
package/README.md
CHANGED
|
@@ -51,9 +51,11 @@ From this package checkout, load the source directly with
|
|
|
51
51
|
pass `turn_id` to re-read a turn whose result is still retained, claimed or
|
|
52
52
|
not.
|
|
53
53
|
4. `subagent_status` inspects children; `subagent_cancel` stops work but keeps
|
|
54
|
-
the session; `subagent_delete` removes it.
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
the session; `subagent_delete` removes it. A deleted child's ID can be
|
|
55
|
+
reused, and the new child starts fresh; generated `agent-N` IDs are never
|
|
56
|
+
reused. Spawning with a live child's `agent_id` fails with that child's
|
|
57
|
+
state and the next step (`agent_message`, `subagent_delete`, or another
|
|
58
|
+
`agent_id`).
|
|
57
59
|
|
|
58
60
|
## Configuration
|
|
59
61
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-minimal-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Persistent nested subagents with bounded delegation for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -30,8 +30,12 @@
|
|
|
30
30
|
"access": "public",
|
|
31
31
|
"provenance": true
|
|
32
32
|
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"typecheck": "tsrs --noEmit -p tsconfig.json"
|
|
36
|
+
},
|
|
33
37
|
"dependencies": {
|
|
34
|
-
"@ian-pascoe/pi-utils": "^0.
|
|
38
|
+
"@ian-pascoe/pi-utils": "^0.11.0",
|
|
35
39
|
"minimatch": "^10.2.6"
|
|
36
40
|
},
|
|
37
41
|
"peerDependencies": {
|
|
@@ -51,9 +55,5 @@
|
|
|
51
55
|
"skills": [
|
|
52
56
|
"./skills"
|
|
53
57
|
]
|
|
54
|
-
},
|
|
55
|
-
"scripts": {
|
|
56
|
-
"test": "vitest run",
|
|
57
|
-
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
58
58
|
}
|
|
59
|
-
}
|
|
59
|
+
}
|
|
@@ -203,6 +203,11 @@ export class MinimalSubagentsCoordinator {
|
|
|
203
203
|
private readonly runtimeInitializations = new Map<string, Promise<ChildAgentRuntime>>();
|
|
204
204
|
private readonly tombstones = new Set<string>();
|
|
205
205
|
private readonly pendingAgentIds = new Set<string>();
|
|
206
|
+
/**
|
|
207
|
+
* Agent IDs of every subtree whose deletion is still running, each with the deletions covering
|
|
208
|
+
* it; none of them may spawn. A branch restore forgets them all.
|
|
209
|
+
*/
|
|
210
|
+
private readonly deletingAgentIds = new Map<string, Set<symbol>>();
|
|
206
211
|
private deliveryLedger: DeliveryLedger = createDeliveryLedger();
|
|
207
212
|
private readonly waiters = new Map<string, Set<TurnWaiter>>();
|
|
208
213
|
private readonly pendingParentMessages = new Map<string, PendingParentMessage[]>();
|
|
@@ -258,6 +263,8 @@ export class MinimalSubagentsCoordinator {
|
|
|
258
263
|
this.assertAccepting();
|
|
259
264
|
this.assertCallerExists(callerId);
|
|
260
265
|
this.assertCallerMaySpawn(callerId);
|
|
266
|
+
this.assertSpawnCallerNotDeleting(callerId);
|
|
267
|
+
const callerAgent = this.agents.get(callerId);
|
|
261
268
|
if (parameters.task.trim().length === 0) {
|
|
262
269
|
throw new Error("Minimal subagents spawn validation: task must not be empty");
|
|
263
270
|
}
|
|
@@ -265,12 +272,11 @@ export class MinimalSubagentsCoordinator {
|
|
|
265
272
|
const friendlyId = parameters.agent_id ?? this.generateFriendlyId(callerId);
|
|
266
273
|
this.validateFriendlyId(friendlyId);
|
|
267
274
|
const agentId = this.buildChildAgentId(callerId, friendlyId);
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
if (this.agents.has(agentId) || this.pendingAgentIds.has(agentId)) {
|
|
275
|
+
if (
|
|
276
|
+
this.agents.has(agentId) ||
|
|
277
|
+
this.pendingAgentIds.has(agentId) ||
|
|
278
|
+
this.deletingAgentIds.has(agentId)
|
|
279
|
+
) {
|
|
274
280
|
throw new Error(this.duplicateAgentIdMessage(agentId));
|
|
275
281
|
}
|
|
276
282
|
|
|
@@ -366,6 +372,13 @@ export class MinimalSubagentsCoordinator {
|
|
|
366
372
|
agent.session_file = identity.sessionFile;
|
|
367
373
|
agent.session_id = identity.sessionId;
|
|
368
374
|
agent.session_leaf_id = identity.sessionLeafId;
|
|
375
|
+
// The caller may have been deleted, and its Agent ID reused, while the session was created.
|
|
376
|
+
if (callerId !== "root" && this.agents.get(callerId) !== callerAgent) {
|
|
377
|
+
throw new Error(`Minimal subagents spawn: caller ${callerId} was deleted`);
|
|
378
|
+
}
|
|
379
|
+
this.assertSpawnCallerNotDeleting(callerId);
|
|
380
|
+
// A deleted Agent ID is free again; the new Child Agent shares nothing with the deleted one.
|
|
381
|
+
this.tombstones.delete(agentId);
|
|
369
382
|
this.agents.set(agentId, agent);
|
|
370
383
|
this.dependencies.registry.append(
|
|
371
384
|
createRegistryEvent(this.dependencies.registry.rootSessionId, "agent-created", { agent }),
|
|
@@ -761,53 +774,113 @@ export class MinimalSubagentsCoordinator {
|
|
|
761
774
|
failures: [],
|
|
762
775
|
};
|
|
763
776
|
const failedAncestors = new Set<string>();
|
|
777
|
+
const deletion = Symbol(agentId);
|
|
778
|
+
const deletingIds = ordered.map((agent) => agent.agent_id);
|
|
779
|
+
for (const id of deletingIds) {
|
|
780
|
+
const deletions = this.deletingAgentIds.get(id) ?? new Set<symbol>();
|
|
781
|
+
deletions.add(deletion);
|
|
782
|
+
this.deletingAgentIds.set(id, deletions);
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
for (const agent of ordered) {
|
|
786
|
+
if (
|
|
787
|
+
[...failedAncestors].some(
|
|
788
|
+
(failedId) => agent.agent_id === failedId || failedId.startsWith(`${agent.agent_id}.`),
|
|
789
|
+
)
|
|
790
|
+
) {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
// A branch restore during an earlier await replaces every record; leave the new ones alone.
|
|
794
|
+
if (!this.isCurrentAgent(agent)) continue;
|
|
795
|
+
await this.deleteOne(agent, result, failedAncestors);
|
|
796
|
+
}
|
|
797
|
+
} finally {
|
|
798
|
+
for (const id of deletingIds) {
|
|
799
|
+
const deletions = this.deletingAgentIds.get(id);
|
|
800
|
+
deletions?.delete(deletion);
|
|
801
|
+
if (deletions?.size === 0) this.deletingAgentIds.delete(id);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
764
806
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
807
|
+
private async deleteOne(
|
|
808
|
+
agent: PersistedAgent,
|
|
809
|
+
result: DeleteResult,
|
|
810
|
+
failedAncestors: Set<string>,
|
|
811
|
+
): Promise<void> {
|
|
812
|
+
const runtime = this.runtimes.get(agent.agent_id);
|
|
813
|
+
try {
|
|
814
|
+
if (agent.active_turn_id) await this.cancelActiveTurn(agent);
|
|
815
|
+
await runtime?.dispose();
|
|
816
|
+
if (!this.isCurrentAgent(agent)) return;
|
|
817
|
+
this.runtimes.delete(agent.agent_id);
|
|
818
|
+
if (agent.session_file) {
|
|
819
|
+
await this.dependencies.sessions.trashSession(agent);
|
|
820
|
+
result.trashed_session_files.push(agent.session_file);
|
|
772
821
|
}
|
|
773
|
-
|
|
822
|
+
if (!this.isCurrentAgent(agent)) return;
|
|
823
|
+
this.agents.delete(agent.agent_id);
|
|
824
|
+
// A runtime that finished opening during the awaits above belongs to the deleted agent.
|
|
825
|
+
const openedLate = this.runtimes.get(agent.agent_id);
|
|
826
|
+
this.runtimes.delete(agent.agent_id);
|
|
827
|
+
// A respawn with this Agent ID must neither join the old runtime opening nor queue behind
|
|
828
|
+
// the old agent's deliveries; each settles on its own and drops only its own map entry.
|
|
829
|
+
this.runtimeInitializations.delete(agent.agent_id);
|
|
830
|
+
this.recipientQueues.delete(agent.agent_id);
|
|
831
|
+
this.pruneDeliveryStateForDeletedAgent(agent.agent_id);
|
|
832
|
+
this.pruneRecentMessageProjectionsForDeletedAgent(agent.agent_id);
|
|
833
|
+
this.tombstones.add(agent.agent_id);
|
|
834
|
+
result.deleted_agent_ids.push(agent.agent_id);
|
|
835
|
+
this.dependencies.registry.append(
|
|
836
|
+
createRegistryEvent(this.dependencies.registry.rootSessionId, "agent-deleted", {
|
|
837
|
+
agent_ids: [agent.agent_id],
|
|
838
|
+
}),
|
|
839
|
+
);
|
|
774
840
|
try {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
agent_ids: [agent.agent_id],
|
|
790
|
-
}),
|
|
791
|
-
);
|
|
792
|
-
} catch (error) {
|
|
793
|
-
failedAncestors.add(agent.agent_id);
|
|
794
|
-
if (runtime && agent.session_file) {
|
|
795
|
-
try {
|
|
796
|
-
this.runtimes.set(agent.agent_id, await this.dependencies.sessions.openRuntime(agent));
|
|
797
|
-
} catch (restoreError) {
|
|
798
|
-
agent.availability = "unavailable";
|
|
799
|
-
agent.unavailable_reason = `Deletion recovery failed: ${
|
|
800
|
-
restoreError instanceof Error ? restoreError.message : String(restoreError)
|
|
801
|
-
}`;
|
|
802
|
-
}
|
|
841
|
+
await openedLate?.dispose();
|
|
842
|
+
} catch {
|
|
843
|
+
// The agent is already deleted; a runtime that fails to close changes nothing it owns.
|
|
844
|
+
}
|
|
845
|
+
} catch (error) {
|
|
846
|
+
failedAncestors.add(agent.agent_id);
|
|
847
|
+
if (runtime && agent.session_file && this.isCurrentAgent(agent)) {
|
|
848
|
+
try {
|
|
849
|
+
this.runtimes.set(agent.agent_id, await this.dependencies.sessions.openRuntime(agent));
|
|
850
|
+
} catch (restoreError) {
|
|
851
|
+
agent.availability = "unavailable";
|
|
852
|
+
agent.unavailable_reason = `Deletion recovery failed: ${
|
|
853
|
+
restoreError instanceof Error ? restoreError.message : String(restoreError)
|
|
854
|
+
}`;
|
|
803
855
|
}
|
|
804
|
-
result.failures.push({
|
|
805
|
-
agent_id: agent.agent_id,
|
|
806
|
-
error: error instanceof Error ? error.message : String(error),
|
|
807
|
-
});
|
|
808
856
|
}
|
|
857
|
+
result.failures.push({
|
|
858
|
+
agent_id: agent.agent_id,
|
|
859
|
+
error: error instanceof Error ? error.message : String(error),
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Reject a coordinator tool call from a session that no longer owns `callerId`, such as a
|
|
866
|
+
* deleted Child Agent's runtime whose Agent ID a new Child Agent now reuses.
|
|
867
|
+
*/
|
|
868
|
+
assertCurrentCaller(callerId: string, sessionId: string): void {
|
|
869
|
+
if (callerId === "root") return;
|
|
870
|
+
if (this.agents.get(callerId)?.session_id !== sessionId) {
|
|
871
|
+
throw new Error(`Minimal subagents caller ${callerId}: caller session is no longer current`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Report whether this record is still the live agent for its Agent ID on the current branch. */
|
|
876
|
+
private isCurrentAgent(agent: PersistedAgent): boolean {
|
|
877
|
+
return this.agents.get(agent.agent_id) === agent;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private assertSpawnCallerNotDeleting(callerId: string): void {
|
|
881
|
+
if (this.deletingAgentIds.has(callerId)) {
|
|
882
|
+
throw new Error(`Minimal subagents spawn: ${callerId} is being deleted`);
|
|
809
883
|
}
|
|
810
|
-
return result;
|
|
811
884
|
}
|
|
812
885
|
|
|
813
886
|
/** Restore non-deleted descendants, interrupt unfinished work, and reconcile pending successful output. */
|
|
@@ -829,6 +902,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
829
902
|
this.runtimes.clear();
|
|
830
903
|
this.runtimeInitializations.clear();
|
|
831
904
|
this.pendingAgentIds.clear();
|
|
905
|
+
this.deletingAgentIds.clear();
|
|
832
906
|
this.tombstones.clear();
|
|
833
907
|
this.waiters.clear();
|
|
834
908
|
this.waitHandedDeliveryIds.clear();
|
|
@@ -1245,7 +1319,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1245
1319
|
const initialization = this.dependencies.sessions
|
|
1246
1320
|
.openRuntime(agent)
|
|
1247
1321
|
.then(async (runtime) => {
|
|
1248
|
-
if (this.
|
|
1322
|
+
if (!this.isCurrentAgent(agent)) {
|
|
1249
1323
|
await runtime.dispose();
|
|
1250
1324
|
throw new Error(`Minimal subagents runtime replaced while opening ${agent.agent_id}`);
|
|
1251
1325
|
}
|
|
@@ -1260,7 +1334,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1260
1334
|
return runtime;
|
|
1261
1335
|
})
|
|
1262
1336
|
.catch((error) => {
|
|
1263
|
-
if (!openingForTurn && this.
|
|
1337
|
+
if (!openingForTurn && this.isCurrentAgent(agent)) {
|
|
1264
1338
|
agent.availability = "unavailable";
|
|
1265
1339
|
agent.latest_activity_at = this.now().toISOString();
|
|
1266
1340
|
agent.unavailable_reason = error instanceof Error ? error.message : String(error);
|
|
@@ -1390,6 +1464,8 @@ export class MinimalSubagentsCoordinator {
|
|
|
1390
1464
|
try {
|
|
1391
1465
|
const handOff = async () => {
|
|
1392
1466
|
if (delivery.destination_agent_id !== "root") {
|
|
1467
|
+
// A stale hand-off must not open the runtime of a respawn that reuses the Agent ID.
|
|
1468
|
+
if (!this.isTerminalDeliveryCurrent(delivery)) return;
|
|
1393
1469
|
await this.ensureRuntime(
|
|
1394
1470
|
this.requireUsableAgent(delivery.destination_agent_id, "message"),
|
|
1395
1471
|
);
|
|
@@ -1676,7 +1752,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1676
1752
|
const target = this.requireUsableAgent(targetId, "message");
|
|
1677
1753
|
const runtime = this.runtimes.get(targetId) ?? (await this.ensureRuntime(target));
|
|
1678
1754
|
this.assertAccepting();
|
|
1679
|
-
if (!isCurrentDelivery()) {
|
|
1755
|
+
if (!isCurrentDelivery() || !this.isCurrentAgent(target)) {
|
|
1680
1756
|
throw new Error("Minimal subagents delivery abandoned after session branch change");
|
|
1681
1757
|
}
|
|
1682
1758
|
const visibleMessage = addCoordinatorMessageEnvelope(message);
|
|
@@ -1688,12 +1764,12 @@ export class MinimalSubagentsCoordinator {
|
|
|
1688
1764
|
const runMessage = runtime
|
|
1689
1765
|
.runMessage(visibleMessage)
|
|
1690
1766
|
.then((outcome) => {
|
|
1691
|
-
if (this.
|
|
1767
|
+
if (this.isCurrentAgent(target) && target.active_turn_id === turnId) {
|
|
1692
1768
|
this.settleTurn(target, turnId, terminalTurnResult(target.agent_id, turnId, outcome));
|
|
1693
1769
|
}
|
|
1694
1770
|
})
|
|
1695
1771
|
.catch((cause) => {
|
|
1696
|
-
if (this.
|
|
1772
|
+
if (this.isCurrentAgent(target) && target.active_turn_id === turnId) {
|
|
1697
1773
|
this.settleTurn(target, turnId, {
|
|
1698
1774
|
agent_id: target.agent_id,
|
|
1699
1775
|
turn_id: turnId,
|
|
@@ -1896,14 +1972,17 @@ export class MinimalSubagentsCoordinator {
|
|
|
1896
1972
|
private duplicateAgentIdMessage(agentId: string): string {
|
|
1897
1973
|
const prefix = `Minimal subagents duplicate agent ID: ${agentId}`;
|
|
1898
1974
|
const existing = this.agents.get(agentId);
|
|
1975
|
+
if (!existing && this.deletingAgentIds.has(agentId)) {
|
|
1976
|
+
return `${prefix} is still being deleted; retry shortly or choose another agent_id`;
|
|
1977
|
+
}
|
|
1899
1978
|
if (!existing) {
|
|
1900
1979
|
return `${prefix} is still being created; retry shortly or choose another agent_id`;
|
|
1901
1980
|
}
|
|
1902
1981
|
if (existing.availability === "unavailable") {
|
|
1903
|
-
return `${prefix} already exists (unavailable). Use subagent_delete to remove it (
|
|
1982
|
+
return `${prefix} already exists (unavailable). Use subagent_delete to remove it (its ID then becomes free for a new child), or choose another agent_id.`;
|
|
1904
1983
|
}
|
|
1905
1984
|
const state = existing.active_turn_id ? "running" : "idle";
|
|
1906
|
-
return `${prefix} already exists (${state}). Use agent_message to continue it, subagent_delete to remove it (
|
|
1985
|
+
return `${prefix} already exists (${state}). Use agent_message to continue it, subagent_delete to remove it (its ID then becomes free for a new child), or choose another agent_id.`;
|
|
1907
1986
|
}
|
|
1908
1987
|
|
|
1909
1988
|
private buildAgentSummary(agent: PersistedAgent, includeDescendants = true): AgentSummary {
|
|
@@ -2430,7 +2509,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
2430
2509
|
const turnId = agent.active_turn_id;
|
|
2431
2510
|
const runtime = this.runtimes.get(agent.agent_id);
|
|
2432
2511
|
if (runtime) await runtime.abort();
|
|
2433
|
-
if (this.
|
|
2512
|
+
if (!this.isCurrentAgent(agent)) continue;
|
|
2434
2513
|
this.settleTurn(agent, turnId, {
|
|
2435
2514
|
agent_id: agent.agent_id,
|
|
2436
2515
|
turn_id: turnId,
|
|
@@ -2445,7 +2524,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
2445
2524
|
const turnId = agent.active_turn_id;
|
|
2446
2525
|
const runtime = this.runtimes.get(agent.agent_id);
|
|
2447
2526
|
if (runtime) await runtime.abort();
|
|
2448
|
-
if (this.
|
|
2527
|
+
if (!this.isCurrentAgent(agent)) return undefined;
|
|
2449
2528
|
this.settleTurn(agent, turnId, {
|
|
2450
2529
|
agent_id: agent.agent_id,
|
|
2451
2530
|
turn_id: turnId,
|
|
@@ -532,6 +532,7 @@ export class MinimalSubagentsLifecycleController {
|
|
|
532
532
|
status: (...args) => this.requireCoordinator().status(...args),
|
|
533
533
|
cancel: (...args) => this.requireCoordinator().cancel(...args),
|
|
534
534
|
delete: (...args) => this.requireCoordinator().delete(...args),
|
|
535
|
+
assertCurrentCaller: (...args) => this.requireCoordinator().assertCurrentCaller(...args),
|
|
535
536
|
};
|
|
536
537
|
private uiController: MinimalSubagentsUiController | undefined;
|
|
537
538
|
private statusPanelController: MinimalSubagentsStatusPanelController | undefined;
|
|
@@ -1247,12 +1247,12 @@ export function replayRegistryEntries(
|
|
|
1247
1247
|
"invalid-event-fields",
|
|
1248
1248
|
`agent-created contains post-creation state for ${event.agent.agent_id}`,
|
|
1249
1249
|
);
|
|
1250
|
-
} else if (
|
|
1250
|
+
} else if (agents.has(event.agent.agent_id)) {
|
|
1251
1251
|
reportDiagnostic(
|
|
1252
1252
|
diagnostics,
|
|
1253
1253
|
entryIndex,
|
|
1254
1254
|
"invalid-event-reference",
|
|
1255
|
-
`agent-created duplicates
|
|
1255
|
+
`agent-created duplicates live agent ${event.agent.agent_id}`,
|
|
1256
1256
|
);
|
|
1257
1257
|
} else if (event.agent.parent_id !== "root" && !agents.has(event.agent.parent_id)) {
|
|
1258
1258
|
reportDiagnostic(
|
|
@@ -1262,6 +1262,8 @@ export function replayRegistryEntries(
|
|
|
1262
1262
|
`agent-created parent is missing for ${event.agent.agent_id}`,
|
|
1263
1263
|
);
|
|
1264
1264
|
} else {
|
|
1265
|
+
// Re-creating a deleted Agent ID starts an unrelated Child Agent (ADR 0007).
|
|
1266
|
+
tombstones.delete(event.agent.agent_id);
|
|
1265
1267
|
agents.set(event.agent.agent_id, structuredClone(event.agent));
|
|
1266
1268
|
}
|
|
1267
1269
|
break;
|
|
@@ -57,7 +57,7 @@ export function createCoordinatorToolSchemas(modelIds: readonly string[]) {
|
|
|
57
57
|
Type.String({
|
|
58
58
|
pattern: FRIENDLY_AGENT_ID_PATTERN,
|
|
59
59
|
description:
|
|
60
|
-
"Friendly
|
|
60
|
+
"Friendly ID segment, unique among live siblings and reusable after deletion; a root child uses this segment as its canonical ID",
|
|
61
61
|
}),
|
|
62
62
|
),
|
|
63
63
|
session_context: Type.Optional(SessionContextSchema),
|
|
@@ -68,6 +68,7 @@ export type CoordinatorToolOperations = Pick<
|
|
|
68
68
|
| "status"
|
|
69
69
|
| "cancel"
|
|
70
70
|
| "delete"
|
|
71
|
+
| "assertCurrentCaller"
|
|
71
72
|
>;
|
|
72
73
|
|
|
73
74
|
/** Dependencies and caller policy used to create caller-bound coordinator tools. */
|
|
@@ -101,9 +102,15 @@ function buildModelRolePromptGuidelines(
|
|
|
101
102
|
|
|
102
103
|
async function runCoordinatorToolActivity<T>(
|
|
103
104
|
options: CoordinatorToolDefinitionOptions,
|
|
105
|
+
context: ExtensionContext,
|
|
104
106
|
operation: () => Promise<T> | T,
|
|
105
107
|
): Promise<T> {
|
|
106
108
|
try {
|
|
109
|
+
// A deleted Child Agent's leftover tool call must not act as a respawn reusing its Agent ID.
|
|
110
|
+
options.coordinator.assertCurrentCaller(
|
|
111
|
+
options.callerId,
|
|
112
|
+
context.sessionManager.getSessionId(),
|
|
113
|
+
);
|
|
107
114
|
return await operation();
|
|
108
115
|
} finally {
|
|
109
116
|
options.onActivity?.();
|
|
@@ -301,7 +308,7 @@ export function createCoordinatorToolDefinitions(
|
|
|
301
308
|
openWorldHint: true,
|
|
302
309
|
},
|
|
303
310
|
async execute(_toolCallId, parameters, _signal, _onUpdate, context) {
|
|
304
|
-
return runCoordinatorToolActivity(options, async () => {
|
|
311
|
+
return runCoordinatorToolActivity(options, context, async () => {
|
|
305
312
|
const result = await options.coordinator.spawn(
|
|
306
313
|
options.callerId,
|
|
307
314
|
parameters,
|
|
@@ -334,8 +341,8 @@ export function createCoordinatorToolDefinitions(
|
|
|
334
341
|
idempotentHint: false,
|
|
335
342
|
openWorldHint: false,
|
|
336
343
|
},
|
|
337
|
-
async execute(toolCallId, parameters) {
|
|
338
|
-
return runCoordinatorToolActivity(options, async () => {
|
|
344
|
+
async execute(toolCallId, parameters, _signal, _onUpdate, context) {
|
|
345
|
+
return runCoordinatorToolActivity(options, context, async () => {
|
|
339
346
|
const result = await options.coordinator.sendAgentMessage(
|
|
340
347
|
options.callerId,
|
|
341
348
|
{
|
|
@@ -393,7 +400,7 @@ export function createCoordinatorToolDefinitions(
|
|
|
393
400
|
// Pi hands a Queued Steer over only after this tool call returns, so end the wait for it.
|
|
394
401
|
const steerWatch = watchQueuedSteer(context.sessionManager.getSessionId());
|
|
395
402
|
try {
|
|
396
|
-
return await runCoordinatorToolActivity(options, async () => {
|
|
403
|
+
return await runCoordinatorToolActivity(options, context, async () => {
|
|
397
404
|
const result = await options.coordinator.wait(
|
|
398
405
|
options.callerId,
|
|
399
406
|
parameters.agent_id,
|
|
@@ -434,8 +441,8 @@ export function createCoordinatorToolDefinitions(
|
|
|
434
441
|
idempotentHint: true,
|
|
435
442
|
openWorldHint: false,
|
|
436
443
|
},
|
|
437
|
-
async execute(_toolCallId, parameters) {
|
|
438
|
-
return runCoordinatorToolActivity(options, () => {
|
|
444
|
+
async execute(_toolCallId, parameters, _signal, _onUpdate, context) {
|
|
445
|
+
return runCoordinatorToolActivity(options, context, () => {
|
|
439
446
|
const result = options.coordinator.status(options.callerId, parameters.agent_id);
|
|
440
447
|
return structuredToolResult(
|
|
441
448
|
result,
|
|
@@ -459,8 +466,8 @@ export function createCoordinatorToolDefinitions(
|
|
|
459
466
|
idempotentHint: true,
|
|
460
467
|
openWorldHint: false,
|
|
461
468
|
},
|
|
462
|
-
async execute(_toolCallId, parameters) {
|
|
463
|
-
return runCoordinatorToolActivity(options, async () => {
|
|
469
|
+
async execute(_toolCallId, parameters, _signal, _onUpdate, context) {
|
|
470
|
+
return runCoordinatorToolActivity(options, context, async () => {
|
|
464
471
|
const result = await options.coordinator.cancel(
|
|
465
472
|
options.callerId,
|
|
466
473
|
parameters.agent_id,
|
|
@@ -476,8 +483,8 @@ export function createCoordinatorToolDefinitions(
|
|
|
476
483
|
name: "subagent_delete",
|
|
477
484
|
label: "Subagent Delete",
|
|
478
485
|
description:
|
|
479
|
-
"Delete one direct child's persistent session
|
|
480
|
-
promptSnippet: "Delete subagent sessions
|
|
486
|
+
"Delete one direct child's persistent session. Recursive deletion includes its subtree and defaults to true. A deleted ID can be reused; a new subagent with it starts fresh, with none of the deleted child's session or results.",
|
|
487
|
+
promptSnippet: "Delete subagent sessions, freeing their IDs",
|
|
481
488
|
parameters: options.schemas.subagent_delete,
|
|
482
489
|
annotations: {
|
|
483
490
|
readOnlyHint: false,
|
|
@@ -485,8 +492,8 @@ export function createCoordinatorToolDefinitions(
|
|
|
485
492
|
idempotentHint: true,
|
|
486
493
|
openWorldHint: false,
|
|
487
494
|
},
|
|
488
|
-
async execute(_toolCallId, parameters) {
|
|
489
|
-
return runCoordinatorToolActivity(options, async () => {
|
|
495
|
+
async execute(_toolCallId, parameters, _signal, _onUpdate, context) {
|
|
496
|
+
return runCoordinatorToolActivity(options, context, async () => {
|
|
490
497
|
const result = await options.coordinator.delete(
|
|
491
498
|
options.callerId,
|
|
492
499
|
parameters.agent_id,
|