pi-extended-teams 2.1.16
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 +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- package/src/utils/write-queue.ts +202 -0
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { StringEnum } from "../internal/schema";
|
|
4
|
+
import { isTeamsDebugEnabled, teamDebugLogPath, writeTeamsDebugEvent } from "../internal/debug";
|
|
5
|
+
import { getModelSelectionState, requireQualifiedKnownModel } from "../internal/model-selection";
|
|
6
|
+
import { getPiSessionId } from "../internal/session-files";
|
|
7
|
+
import { createSessionContextReference, removeSessionContextReference } from "../internal/session-context-reference";
|
|
8
|
+
import * as paths from "../../src/utils/paths";
|
|
9
|
+
import * as teams from "../../src/utils/teams";
|
|
10
|
+
import * as runtime from "../../src/utils/runtime";
|
|
11
|
+
import * as messaging from "../../src/utils/messaging";
|
|
12
|
+
import * as writeQueue from "../../src/utils/write-queue";
|
|
13
|
+
import { ACCEPTED_FAVORITE_MODEL_SLOTS, FAVORITE_MODEL_SLOTS, canonicalPersistedModelSlot, isFavoriteModelSlot, loadSettings, normalizeFavoriteModelSlot, requireFavoriteModelLevel, resolveModel, roleForFavoriteModelSlot, type AgentRole, type CanonicalFavoriteModelSlot } from "../../src/utils/settings";
|
|
14
|
+
import type { Member } from "../../src/utils/models";
|
|
15
|
+
|
|
16
|
+
import type { RunningReadAgent } from "../runtime/types";
|
|
17
|
+
import type { ReadAgentTeardownResult } from "../agents/read-agent-session-lifecycle";
|
|
18
|
+
import type { ShutdownTeammateOptions } from "../team/lifecycle";
|
|
19
|
+
import {
|
|
20
|
+
onLifecycleTombstoneCleared,
|
|
21
|
+
readLifecycleTombstone,
|
|
22
|
+
withLifecycleTombstoneLock,
|
|
23
|
+
type LifecycleTombstoneLock,
|
|
24
|
+
} from "../../src/utils/lifecycle-tombstone";
|
|
25
|
+
import { isEligibleNestedReadParent, NESTED_READ_MODEL_SLOTS, type NestedReadModelSlot } from "../runtime/nested-read-agents";
|
|
26
|
+
import {
|
|
27
|
+
createPendingChildController,
|
|
28
|
+
type ParentRunIdentity,
|
|
29
|
+
type PendingChildAcceptance,
|
|
30
|
+
type PendingChildController,
|
|
31
|
+
type PendingChildRun,
|
|
32
|
+
} from "../runtime/pending-child-controller";
|
|
33
|
+
|
|
34
|
+
export const CHILD_AGENT_LIFECYCLE_PROBE = "pi-extended-teams:child-agent-lifecycle-probe";
|
|
35
|
+
|
|
36
|
+
interface ChildAgentLifecycleProbe {
|
|
37
|
+
sessionId: string;
|
|
38
|
+
respond(snapshot: { sessionId: string; running: number; queued: number }): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TeamToolsOptions {
|
|
42
|
+
terminal: any;
|
|
43
|
+
runningReadAgents: Map<string, RunningReadAgent>;
|
|
44
|
+
readAgentKey(teamName: string, agentName: string): string;
|
|
45
|
+
isCurrentReadAgentRun(key: string, state: RunningReadAgent): boolean;
|
|
46
|
+
renderReadAgentStatus(): void;
|
|
47
|
+
readAgentOptions(): any;
|
|
48
|
+
runReadAgentInProcess(teamName: string, member: Member, prompt: string, ctx: any, options: any): Promise<void> | void;
|
|
49
|
+
startWriteAgent(teamName: string, member: Member, prompt: string): Promise<string>;
|
|
50
|
+
shutdownTeammate(teamName: string, member: Member, options?: ShutdownTeammateOptions): Promise<ReadAgentTeardownResult>;
|
|
51
|
+
adoptTeamAsLead(teamName: string, ctx?: any): void;
|
|
52
|
+
buildRoster(teamName: string): Promise<any>;
|
|
53
|
+
isTeammate: boolean;
|
|
54
|
+
agentName: string;
|
|
55
|
+
getTeamName(): string | null | undefined;
|
|
56
|
+
getSessionCtx?(): any;
|
|
57
|
+
setSessionCtx?(ctx: any): void;
|
|
58
|
+
pendingChildController?: PendingChildController;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface NestedReadAgentToolBinding {
|
|
62
|
+
teamName: string;
|
|
63
|
+
parent: Member;
|
|
64
|
+
parentRunId: string;
|
|
65
|
+
outerCtx: any;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TeamToolsRuntime {
|
|
69
|
+
createNestedReadAgentTools(binding: NestedReadAgentToolBinding): any[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface SpawnTeammateOptions {
|
|
73
|
+
once?: boolean;
|
|
74
|
+
nestedParent?: {
|
|
75
|
+
teamName: string;
|
|
76
|
+
name: string;
|
|
77
|
+
lifecycleRunId: string;
|
|
78
|
+
cwd: string;
|
|
79
|
+
};
|
|
80
|
+
allowNestedReadAgents?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface QueuedReadSpawn {
|
|
84
|
+
id: string;
|
|
85
|
+
teamName: string;
|
|
86
|
+
member: Member;
|
|
87
|
+
prompt: string;
|
|
88
|
+
params: any;
|
|
89
|
+
resolved: ReturnType<typeof resolveModel>;
|
|
90
|
+
ctx: any;
|
|
91
|
+
requestedAt: number;
|
|
92
|
+
nameReservationId?: string;
|
|
93
|
+
pendingChildAcceptance?: PendingChildAcceptance;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamToolsRuntime {
|
|
97
|
+
if (options.isTeammate) {
|
|
98
|
+
return { createNestedReadAgentTools: () => [] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function emitOrchestrationResponse(requestId: string | undefined, type: string, payload: Record<string, any>): void {
|
|
102
|
+
if (!requestId) return;
|
|
103
|
+
pi.events?.emit?.("pi-extended-teams:orchestration-response", { requestId, type, ...payload });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function operationMetadataFromParams(params: any): Record<string, any> | undefined {
|
|
107
|
+
const metadata = { ...(params.metadata || {}) };
|
|
108
|
+
if (params.operation_id) metadata.operationId = params.operation_id;
|
|
109
|
+
if (params.workflow_run_id) metadata.workflowRunId = params.workflow_run_id;
|
|
110
|
+
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasOwnParam(params: any, key: string): boolean {
|
|
114
|
+
return !!params && Object.prototype.hasOwnProperty.call(params, key);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rejectDirectModelSelection(params: any, context: string): void {
|
|
118
|
+
const forbidden = ["model", "thinking", "role"].filter((key) => hasOwnParam(params, key));
|
|
119
|
+
if (forbidden.length === 0) return;
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${context} must use model_slot only. Do not pass ${forbidden.join(", ")}; choose a configured intent tier from /agents-favorite-models. See README.md for tier examples.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function requireSpawnLevel(params: any, context: string): CanonicalFavoriteModelSlot {
|
|
126
|
+
rejectDirectModelSelection(params, context);
|
|
127
|
+
if (params?.session_context !== undefined && params.session_context !== "none" && params.session_context !== "lazy") {
|
|
128
|
+
throw new Error(`${context} session_context must be none or lazy.`);
|
|
129
|
+
}
|
|
130
|
+
const slot = normalizeFavoriteModelSlot(params?.model_slot);
|
|
131
|
+
if (!slot) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`${context} requires a configured model_slot intent tier: ${FAVORITE_MODEL_SLOTS.join(", ")}. Define tiers with /agents-favorite-models and see README.md for examples.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return slot;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function configuredFavoriteModelForSpawn(params: any, ctx: any, context = "spawn_agent"): string {
|
|
140
|
+
const slot = requireSpawnLevel(params, context);
|
|
141
|
+
const settings = loadSettings({ projectDir: params.cwd || ctx.cwd });
|
|
142
|
+
return requireFavoriteModelLevel(settings, slot).model;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function requireNestedReadSpawnLevel(params: any, context: string): NestedReadModelSlot {
|
|
146
|
+
rejectDirectModelSelection(params, context);
|
|
147
|
+
if (!NESTED_READ_MODEL_SLOTS.includes(params?.model_slot)) {
|
|
148
|
+
throw new Error(`${context} requires a canonical read-* model_slot: ${NESTED_READ_MODEL_SLOTS.join(", ")}.`);
|
|
149
|
+
}
|
|
150
|
+
return params.model_slot;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function rejectUnexpectedNestedParams(params: any, allowed: readonly string[], context: string): void {
|
|
154
|
+
const unexpected = Object.keys(params || {}).filter((key) => !allowed.includes(key));
|
|
155
|
+
if (unexpected.length > 0) {
|
|
156
|
+
throw new Error(`${context} accepts only ${allowed.join(", ")}; do not pass ${unexpected.join(", ")}.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function mergeSwarmAgentParams(defaults: any = {}, agent: any = {}): any {
|
|
161
|
+
return { ...defaults, ...agent };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function memberMatchesOperation(member: Member, params: any): boolean {
|
|
165
|
+
if (!params.operation_id) return false;
|
|
166
|
+
const operationId = member.metadata?.operationId || member.metadata?.orchestration?.operationId;
|
|
167
|
+
const workflowRunId = member.metadata?.workflowRunId || member.metadata?.orchestration?.workflowRunId;
|
|
168
|
+
return operationId === params.operation_id && (params.workflow_run_id === undefined || workflowRunId === params.workflow_run_id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function memberResolutionDetails(member: Member, params: any, extras: Record<string, any> = {}): Record<string, any> {
|
|
172
|
+
const role = member.role ?? "write";
|
|
173
|
+
const requestedRole = isFavoriteModelSlot(params.model_slot) ? roleForFavoriteModelSlot(params.model_slot) : role;
|
|
174
|
+
const category = member.category ?? null;
|
|
175
|
+
return {
|
|
176
|
+
agentId: member.agentId,
|
|
177
|
+
role,
|
|
178
|
+
requestedRole,
|
|
179
|
+
resolvedRole: role,
|
|
180
|
+
requestedCategory: params.category ?? null,
|
|
181
|
+
category,
|
|
182
|
+
resolvedCategory: category,
|
|
183
|
+
requestedModelSlot: canonicalPersistedModelSlot(params.model_slot) ?? null,
|
|
184
|
+
modelSlot: canonicalPersistedModelSlot(member.modelSlot) ?? null,
|
|
185
|
+
sessionContext: member.sessionContext ?? "none",
|
|
186
|
+
sessionContextAvailable: null,
|
|
187
|
+
model: member.model ?? null,
|
|
188
|
+
thinking: member.thinking ?? null,
|
|
189
|
+
...extras,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function queuedResolutionDetails(safeTeamName: string, queued: writeQueue.QueuedWriteSpawn, params: any, extras: Record<string, any> = {}): Record<string, any> {
|
|
194
|
+
const category = queued.category ?? null;
|
|
195
|
+
const level = requireFavoriteModelLevel(loadSettings({ projectDir: queued.cwd }), queued.modelSlot);
|
|
196
|
+
return {
|
|
197
|
+
agentId: `${queued.name}@${safeTeamName}`,
|
|
198
|
+
role: "write",
|
|
199
|
+
requestedRole: isFavoriteModelSlot(params.model_slot) ? roleForFavoriteModelSlot(params.model_slot) : level.role,
|
|
200
|
+
resolvedRole: "write",
|
|
201
|
+
requestedCategory: params.category ?? null,
|
|
202
|
+
category,
|
|
203
|
+
resolvedCategory: category,
|
|
204
|
+
requestedModelSlot: canonicalPersistedModelSlot(params.model_slot) ?? null,
|
|
205
|
+
modelSlot: level.slot,
|
|
206
|
+
model: level.model,
|
|
207
|
+
thinking: level.thinking,
|
|
208
|
+
modelSource: "queued",
|
|
209
|
+
...extras,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function spawnResolutionDetails(member: Member, params: any, resolved: ReturnType<typeof resolveModel>, extras: Record<string, any> = {}): Record<string, any> {
|
|
214
|
+
return {
|
|
215
|
+
...memberResolutionDetails(member, params, extras),
|
|
216
|
+
modelSource: resolved.modelSource,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const pendingChildController = options.pendingChildController ?? createPendingChildController();
|
|
221
|
+
const queuedReadSpawnsByTeam = new Map<string, QueuedReadSpawn[]>();
|
|
222
|
+
const readQueueDrainingTeams = new Set<string>();
|
|
223
|
+
const readAdmissionReservationsByTeam = new Map<string, Map<string, number>>();
|
|
224
|
+
const nestedReadNameReservationsByTeam = new Map<string, Map<string, string>>();
|
|
225
|
+
|
|
226
|
+
function activeAgentCount(teamName: string, role?: string): number {
|
|
227
|
+
let count = 0;
|
|
228
|
+
for (const agent of options.runningReadAgents.values()) {
|
|
229
|
+
if (agent.teamName !== teamName) continue;
|
|
230
|
+
if (role && (agent.role || "read") !== role) continue;
|
|
231
|
+
count += 1;
|
|
232
|
+
}
|
|
233
|
+
return count;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function activeReadCount(teamName: string): number {
|
|
237
|
+
const activeKeys = new Set(readAdmissionReservationsByTeam.get(teamName)?.keys() ?? []);
|
|
238
|
+
for (const [key, agent] of options.runningReadAgents) {
|
|
239
|
+
if (agent.teamName === teamName && (agent.role || "read") === "read") activeKeys.add(key);
|
|
240
|
+
}
|
|
241
|
+
return activeKeys.size;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function reserveReadAdmission(teamName: string, key: string): void {
|
|
245
|
+
const reservations = readAdmissionReservationsByTeam.get(teamName) ?? new Map<string, number>();
|
|
246
|
+
reservations.set(key, (reservations.get(key) ?? 0) + 1);
|
|
247
|
+
readAdmissionReservationsByTeam.set(teamName, reservations);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function releaseReadAdmission(teamName: string, key: string): void {
|
|
251
|
+
const reservations = readAdmissionReservationsByTeam.get(teamName);
|
|
252
|
+
if (!reservations) return;
|
|
253
|
+
const count = reservations.get(key) ?? 0;
|
|
254
|
+
if (count > 1) reservations.set(key, count - 1);
|
|
255
|
+
else reservations.delete(key);
|
|
256
|
+
if (reservations.size === 0) readAdmissionReservationsByTeam.delete(teamName);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function reserveNestedReadName(teamName: string, name: string): string {
|
|
260
|
+
const reservations = nestedReadNameReservationsByTeam.get(teamName) ?? new Map<string, string>();
|
|
261
|
+
if (reservations.has(name)) {
|
|
262
|
+
throw new Error(`Nested read agent ${name} is already active or queued; nested delegation cannot replace an existing run.`);
|
|
263
|
+
}
|
|
264
|
+
const reservationId = crypto.randomUUID();
|
|
265
|
+
reservations.set(name, reservationId);
|
|
266
|
+
nestedReadNameReservationsByTeam.set(teamName, reservations);
|
|
267
|
+
return reservationId;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function releaseNestedReadName(teamName: string, name: string, reservationId?: string): void {
|
|
271
|
+
if (!reservationId) return;
|
|
272
|
+
const reservations = nestedReadNameReservationsByTeam.get(teamName);
|
|
273
|
+
if (reservations?.get(name) !== reservationId) return;
|
|
274
|
+
reservations.delete(name);
|
|
275
|
+
if (reservations.size === 0) nestedReadNameReservationsByTeam.delete(teamName);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function readQueue(teamName: string): QueuedReadSpawn[] {
|
|
279
|
+
return queuedReadSpawnsByTeam.get(teamName) ?? [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const lifecycleFenceUnsubscribe = onLifecycleTombstoneCleared((clearedTeamName) => {
|
|
283
|
+
if (readQueue(clearedTeamName).length > 0) void drainQueuedReadSpawns(clearedTeamName);
|
|
284
|
+
});
|
|
285
|
+
const lifecycleProbeUnsubscribe = pi.events?.on?.(CHILD_AGENT_LIFECYCLE_PROBE, (payload: ChildAgentLifecycleProbe) => {
|
|
286
|
+
const sessionId = getPiSessionId(options.getSessionCtx?.());
|
|
287
|
+
if (!sessionId || payload?.sessionId !== sessionId || typeof payload.respond !== "function") return;
|
|
288
|
+
const activeTeamName = options.getTeamName();
|
|
289
|
+
if (!activeTeamName) return;
|
|
290
|
+
payload.respond({
|
|
291
|
+
sessionId,
|
|
292
|
+
running: activeAgentCount(activeTeamName),
|
|
293
|
+
queued: readQueue(activeTeamName).length,
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
let lifecycleProbeCleanedUp = false;
|
|
297
|
+
let pendingChildCancelUnsubscribe = (): void => {};
|
|
298
|
+
pi.on?.("session_shutdown", () => {
|
|
299
|
+
if (lifecycleProbeCleanedUp) return;
|
|
300
|
+
lifecycleProbeCleanedUp = true;
|
|
301
|
+
if (typeof lifecycleProbeUnsubscribe === "function") lifecycleProbeUnsubscribe();
|
|
302
|
+
pendingChildCancelUnsubscribe();
|
|
303
|
+
lifecycleFenceUnsubscribe();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
function setReadQueue(teamName: string, queue: QueuedReadSpawn[]): void {
|
|
307
|
+
if (queue.length > 0) queuedReadSpawnsByTeam.set(teamName, queue);
|
|
308
|
+
else queuedReadSpawnsByTeam.delete(teamName);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function findQueuedReadSpawn(teamName: string, params: any): QueuedReadSpawn | undefined {
|
|
312
|
+
return readQueue(teamName).find((queued) => queued.member.name === params.name || memberMatchesOperation(queued.member, params));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function pendingParentForMember(teamName: string, member: Member): ParentRunIdentity | undefined {
|
|
316
|
+
if (member.delegationDepth !== 1 || !member.parentAgentName || !member.parentLifecycleRunId) return undefined;
|
|
317
|
+
return {
|
|
318
|
+
teamName,
|
|
319
|
+
parentName: member.parentAgentName,
|
|
320
|
+
parentRunId: member.parentLifecycleRunId,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function removeQueuedReadSpawnById(
|
|
325
|
+
teamName: string,
|
|
326
|
+
id: string,
|
|
327
|
+
settlePendingAcceptance = true
|
|
328
|
+
): QueuedReadSpawn | undefined {
|
|
329
|
+
const queue = readQueue(teamName);
|
|
330
|
+
const removed = queue.find((queued) => queued.id === id);
|
|
331
|
+
if (!removed) return undefined;
|
|
332
|
+
setReadQueue(teamName, queue.filter((queued) => queued.id !== id));
|
|
333
|
+
releaseNestedReadName(teamName, removed.member.name, removed.nameReservationId);
|
|
334
|
+
if (settlePendingAcceptance && removed.pendingChildAcceptance) {
|
|
335
|
+
pendingChildController.settleAcceptance(removed.pendingChildAcceptance);
|
|
336
|
+
}
|
|
337
|
+
return removed;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function removeQueuedReadSpawnsByName(teamName: string, name: string): QueuedReadSpawn[] {
|
|
341
|
+
const queue = readQueue(teamName);
|
|
342
|
+
const removed = queue.filter((queued) => queued.member.name === name);
|
|
343
|
+
if (removed.length === 0) return [];
|
|
344
|
+
setReadQueue(teamName, queue.filter((queued) => queued.member.name !== name));
|
|
345
|
+
for (const queued of removed) {
|
|
346
|
+
releaseNestedReadName(teamName, queued.member.name, queued.nameReservationId);
|
|
347
|
+
if (queued.pendingChildAcceptance) pendingChildController.settleAcceptance(queued.pendingChildAcceptance);
|
|
348
|
+
}
|
|
349
|
+
return removed;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
pendingChildCancelUnsubscribe = pendingChildController.onParentCancelled((parent) => {
|
|
353
|
+
const queue = readQueue(parent.teamName);
|
|
354
|
+
for (const queued of queue) {
|
|
355
|
+
const queuedParent = pendingParentForMember(parent.teamName, queued.member);
|
|
356
|
+
if (!queuedParent
|
|
357
|
+
|| queuedParent.parentName !== parent.parentName
|
|
358
|
+
|| queuedParent.parentRunId !== parent.parentRunId) continue;
|
|
359
|
+
removeQueuedReadSpawnById(parent.teamName, queued.id);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
function queuedReadResolutionDetails(queued: QueuedReadSpawn, params: any, extras: Record<string, any> = {}): Record<string, any> {
|
|
364
|
+
return spawnResolutionDetails(queued.member, params, queued.resolved, {
|
|
365
|
+
mode: "in-process",
|
|
366
|
+
terminalId: null,
|
|
367
|
+
queued: true,
|
|
368
|
+
queueId: queued.id,
|
|
369
|
+
...extras,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function assertNestedParentRuntimeActive(
|
|
374
|
+
teamName: string,
|
|
375
|
+
parentName: string,
|
|
376
|
+
parentLifecycleRunId: string
|
|
377
|
+
): RunningReadAgent {
|
|
378
|
+
const parentStateKey = options.readAgentKey(teamName, parentName);
|
|
379
|
+
const parentState = options.runningReadAgents.get(parentStateKey);
|
|
380
|
+
const closingTeardownStates = new Set(["stopping", "quarantined", "persistence_failed", "finalized"]);
|
|
381
|
+
if (!parentState
|
|
382
|
+
|| !options.isCurrentReadAgentRun(parentStateKey, parentState)
|
|
383
|
+
|| parentState.runId !== parentLifecycleRunId
|
|
384
|
+
|| parentState.messageDeliveryClosed === true
|
|
385
|
+
|| parentState.persistedRecipientClosed === true
|
|
386
|
+
|| parentState.status === "finishing"
|
|
387
|
+
|| parentState.stopRequested
|
|
388
|
+
|| (parentState.teardownState !== undefined && closingTeardownStates.has(parentState.teardownState))) {
|
|
389
|
+
throw new Error(`Nested read spawning is no longer authorized for ${parentName}: the bound parent lifecycle is not active.`);
|
|
390
|
+
}
|
|
391
|
+
return parentState;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function assertNestedParentAuthorized(
|
|
395
|
+
teamName: string,
|
|
396
|
+
parentName: string,
|
|
397
|
+
parentLifecycleRunId: string,
|
|
398
|
+
parentCwd: string,
|
|
399
|
+
lifecycleLock?: LifecycleTombstoneLock
|
|
400
|
+
): Promise<Member> {
|
|
401
|
+
const initialFence = lifecycleLock?.read() ?? await readLifecycleTombstone(teamName, parentName);
|
|
402
|
+
if (initialFence.status !== "absent") {
|
|
403
|
+
throw new Error(`Nested read spawning is no longer authorized for ${parentName}: the bound parent lifecycle is not active.`);
|
|
404
|
+
}
|
|
405
|
+
const initialParentState = assertNestedParentRuntimeActive(teamName, parentName, parentLifecycleRunId);
|
|
406
|
+
|
|
407
|
+
const config = await teams.readConfig(teamName);
|
|
408
|
+
const persistedParent = config.members.find((candidate) => candidate.name === parentName);
|
|
409
|
+
const finalFence = lifecycleLock?.read() ?? await readLifecycleTombstone(teamName, parentName);
|
|
410
|
+
const finalParentState = assertNestedParentRuntimeActive(teamName, parentName, parentLifecycleRunId);
|
|
411
|
+
if (finalFence.status !== "absent" || finalParentState !== initialParentState) {
|
|
412
|
+
throw new Error(`Nested read spawning is no longer authorized for ${parentName}: the bound parent lifecycle is not active.`);
|
|
413
|
+
}
|
|
414
|
+
if (!persistedParent
|
|
415
|
+
|| persistedParent.lifecycleRunId !== parentLifecycleRunId
|
|
416
|
+
|| persistedParent.cwd !== parentCwd
|
|
417
|
+
|| persistedParent.isActive === false
|
|
418
|
+
|| !isEligibleNestedReadParent(persistedParent)) {
|
|
419
|
+
throw new Error(`Nested read spawning is not authorized for ${parentName}: the persisted parent policy or provenance does not match.`);
|
|
420
|
+
}
|
|
421
|
+
return persistedParent;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function assertNestedChildAdmission(
|
|
425
|
+
teamName: string,
|
|
426
|
+
child: Member,
|
|
427
|
+
lifecycleLock?: LifecycleTombstoneLock
|
|
428
|
+
): Promise<void> {
|
|
429
|
+
if (child.role !== "read"
|
|
430
|
+
|| child.delegationDepth !== 1
|
|
431
|
+
|| child.helperKind !== "read_helper"
|
|
432
|
+
|| child.allowNestedReadAgents !== false
|
|
433
|
+
|| !child.parentAgentName
|
|
434
|
+
|| !child.parentLifecycleRunId
|
|
435
|
+
|| child.requestedBy !== child.parentAgentName
|
|
436
|
+
|| !NESTED_READ_MODEL_SLOTS.includes(child.modelSlot as NestedReadModelSlot)) {
|
|
437
|
+
throw new Error(`Nested read agent ${child.name} is missing runtime-owned read-only policy or parent provenance.`);
|
|
438
|
+
}
|
|
439
|
+
await assertNestedParentAuthorized(
|
|
440
|
+
teamName,
|
|
441
|
+
child.parentAgentName,
|
|
442
|
+
child.parentLifecycleRunId,
|
|
443
|
+
child.cwd,
|
|
444
|
+
lifecycleLock
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function settlePendingChild(identity: PendingChildAcceptance | PendingChildRun | undefined): void {
|
|
449
|
+
if (!identity) return;
|
|
450
|
+
if ("childRunId" in identity) pendingChildController.settleChildRun(identity);
|
|
451
|
+
else pendingChildController.settleAcceptance(identity);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function rollbackNestedChildAdmission(
|
|
455
|
+
teamName: string,
|
|
456
|
+
member: Member,
|
|
457
|
+
cause: unknown,
|
|
458
|
+
pendingChild: PendingChildAcceptance | PendingChildRun | undefined
|
|
459
|
+
): Promise<never> {
|
|
460
|
+
const runId = member.lifecycleRunId;
|
|
461
|
+
if (!runId) {
|
|
462
|
+
settlePendingChild(pendingChild);
|
|
463
|
+
throw cause;
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
const removed = await teams.removeMemberMatchingRun(teamName, member.name, runId);
|
|
467
|
+
if (!removed) throw new Error(`matching child run ${runId} was not present`);
|
|
468
|
+
} catch (rollbackError) {
|
|
469
|
+
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
|
470
|
+
const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
|
471
|
+
throw new Error(`${causeMessage} Exact-run rollback for nested read agent ${member.name} failed: ${rollbackMessage}`);
|
|
472
|
+
} finally {
|
|
473
|
+
settlePendingChild(pendingChild);
|
|
474
|
+
}
|
|
475
|
+
throw cause;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
interface AdmittedReadAgentLaunch {
|
|
479
|
+
launch: Promise<void> | void;
|
|
480
|
+
pendingChildRun?: PendingChildRun;
|
|
481
|
+
sessionContextAvailable: boolean;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function admitAndLaunchReadAgentMember(
|
|
485
|
+
teamName: string,
|
|
486
|
+
member: Member,
|
|
487
|
+
prompt: string,
|
|
488
|
+
ctx: any,
|
|
489
|
+
queuedAcceptance?: PendingChildAcceptance
|
|
490
|
+
): Promise<AdmittedReadAgentLaunch> {
|
|
491
|
+
const addValidateAndLaunch = async (parentLifecycleLock?: LifecycleTombstoneLock): Promise<AdmittedReadAgentLaunch> => {
|
|
492
|
+
let pendingAcceptance = queuedAcceptance;
|
|
493
|
+
if (member.delegationDepth === 1) {
|
|
494
|
+
await assertNestedChildAdmission(teamName, member, parentLifecycleLock);
|
|
495
|
+
const parent = pendingParentForMember(teamName, member);
|
|
496
|
+
if (!parent) throw new Error(`Nested read agent ${member.name} is missing exact parent identity.`);
|
|
497
|
+
pendingAcceptance ??= pendingChildController.acceptChild(parent, member.name);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
await teams.addMember(teamName, member);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
settlePendingChild(pendingAcceptance);
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let pendingChildRun: PendingChildRun | undefined;
|
|
508
|
+
if (member.delegationDepth === 1) {
|
|
509
|
+
const runId = member.lifecycleRunId;
|
|
510
|
+
if (!pendingAcceptance || !runId) {
|
|
511
|
+
return rollbackNestedChildAdmission(
|
|
512
|
+
teamName,
|
|
513
|
+
member,
|
|
514
|
+
new Error(`Nested read agent ${member.name} did not receive an exact lifecycle run identity.`),
|
|
515
|
+
pendingAcceptance
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
pendingChildRun = pendingChildController.bindAcceptedChild(pendingAcceptance, member.name, runId);
|
|
519
|
+
if (!pendingChildRun) {
|
|
520
|
+
return rollbackNestedChildAdmission(
|
|
521
|
+
teamName,
|
|
522
|
+
member,
|
|
523
|
+
new Error(`Nested read agent ${member.name} lost its parent acceptance before launch.`),
|
|
524
|
+
pendingAcceptance
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
await assertNestedChildAdmission(teamName, member, parentLifecycleLock);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return rollbackNestedChildAdmission(teamName, member, error, pendingChildRun);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let sessionContextReference: ReturnType<typeof createSessionContextReference> = null;
|
|
536
|
+
if (member.sessionContext === "lazy" && member.lifecycleRunId) {
|
|
537
|
+
try {
|
|
538
|
+
sessionContextReference = createSessionContextReference({
|
|
539
|
+
teamName,
|
|
540
|
+
agentName: member.name,
|
|
541
|
+
lifecycleRunId: member.lifecycleRunId,
|
|
542
|
+
sessionManager: ctx?.sessionManager,
|
|
543
|
+
});
|
|
544
|
+
} catch {
|
|
545
|
+
// Context fallback is optional and must never block an otherwise valid spawn.
|
|
546
|
+
sessionContextReference = null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const launchPrompt = sessionContextReference
|
|
550
|
+
? `${prompt}${sessionContextReference.promptSuffix}`
|
|
551
|
+
: prompt;
|
|
552
|
+
const removeReference = () => {
|
|
553
|
+
try {
|
|
554
|
+
removeSessionContextReference(sessionContextReference);
|
|
555
|
+
} catch {
|
|
556
|
+
// Exact-run teardown remains authoritative; stale reference cleanup is best effort.
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
try {
|
|
561
|
+
const launch = options.runReadAgentInProcess(teamName, member, launchPrompt, ctx, options.readAgentOptions());
|
|
562
|
+
return {
|
|
563
|
+
launch: sessionContextReference
|
|
564
|
+
? Promise.resolve(launch).finally(removeReference)
|
|
565
|
+
: launch,
|
|
566
|
+
pendingChildRun,
|
|
567
|
+
sessionContextAvailable: !!sessionContextReference,
|
|
568
|
+
};
|
|
569
|
+
} catch (error) {
|
|
570
|
+
removeReference();
|
|
571
|
+
if (member.delegationDepth === 1) {
|
|
572
|
+
return rollbackNestedChildAdmission(teamName, member, error, pendingChildRun);
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
if (member.delegationDepth !== 1 || !member.parentAgentName) {
|
|
579
|
+
return addValidateAndLaunch();
|
|
580
|
+
}
|
|
581
|
+
return withLifecycleTombstoneLock(teamName, member.parentAgentName, async parentLifecycleLock => {
|
|
582
|
+
return addValidateAndLaunch(parentLifecycleLock);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function startReadAgentMember(
|
|
587
|
+
teamName: string,
|
|
588
|
+
member: Member,
|
|
589
|
+
prompt: string,
|
|
590
|
+
ctx: any,
|
|
591
|
+
nameReservationId?: string,
|
|
592
|
+
releaseNameOnFailure = true,
|
|
593
|
+
queuedAcceptance?: PendingChildAcceptance
|
|
594
|
+
): Promise<boolean> {
|
|
595
|
+
const key = options.readAgentKey(teamName, member.name);
|
|
596
|
+
const reservesReadCapacity = (member.role || "read") === "read";
|
|
597
|
+
if (reservesReadCapacity) reserveReadAdmission(teamName, key);
|
|
598
|
+
|
|
599
|
+
const releaseNameReservation = () => {
|
|
600
|
+
releaseNestedReadName(teamName, member.name, nameReservationId);
|
|
601
|
+
};
|
|
602
|
+
const releaseReservationAndDrain = () => {
|
|
603
|
+
if (releaseNameOnFailure) releaseNameReservation();
|
|
604
|
+
if (reservesReadCapacity) releaseReadAdmission(teamName, key);
|
|
605
|
+
void drainQueuedReadSpawns(teamName);
|
|
606
|
+
};
|
|
607
|
+
const finishRun = (pendingChildRun: PendingChildRun | undefined) => {
|
|
608
|
+
settlePendingChild(pendingChildRun);
|
|
609
|
+
if (reservesReadCapacity) releaseReadAdmission(teamName, key);
|
|
610
|
+
void drainQueuedReadSpawns(teamName);
|
|
611
|
+
};
|
|
612
|
+
const drainAfterRun = (pendingChildRun: PendingChildRun | undefined) => {
|
|
613
|
+
const currentState = options.runningReadAgents.get(key);
|
|
614
|
+
if (currentState?.teardownFinalizationPromise) {
|
|
615
|
+
// A completing runner can resolve before lifecycle finalization releases
|
|
616
|
+
// capacity or exact child ownership. Attach one observer; finalization
|
|
617
|
+
// remains authoritative while the run is quarantined.
|
|
618
|
+
void Promise.resolve(currentState.teardownFinalizationPromise).then(
|
|
619
|
+
() => finishRun(pendingChildRun),
|
|
620
|
+
() => finishRun(pendingChildRun),
|
|
621
|
+
);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
finishRun(pendingChildRun);
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
const admitted = await admitAndLaunchReadAgentMember(teamName, member, prompt, ctx, queuedAcceptance);
|
|
629
|
+
releaseNameReservation();
|
|
630
|
+
void Promise.resolve(admitted.launch).then(
|
|
631
|
+
() => drainAfterRun(admitted.pendingChildRun),
|
|
632
|
+
() => drainAfterRun(admitted.pendingChildRun)
|
|
633
|
+
);
|
|
634
|
+
return admitted.sessionContextAvailable;
|
|
635
|
+
} catch (error) {
|
|
636
|
+
settlePendingChild(queuedAcceptance);
|
|
637
|
+
releaseReservationAndDrain();
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function enqueueReadSpawn(
|
|
643
|
+
teamName: string,
|
|
644
|
+
member: Member,
|
|
645
|
+
prompt: string,
|
|
646
|
+
params: any,
|
|
647
|
+
resolved: ReturnType<typeof resolveModel>,
|
|
648
|
+
ctx: any,
|
|
649
|
+
nameReservationId?: string
|
|
650
|
+
): Promise<QueuedReadSpawn> {
|
|
651
|
+
const append = (pendingChildAcceptance?: PendingChildAcceptance): QueuedReadSpawn => {
|
|
652
|
+
const queue = readQueue(teamName);
|
|
653
|
+
if (queue.some((queued) => queued.member.name === member.name)) {
|
|
654
|
+
throw new Error(`Nested read agent ${member.name} is already active or queued; nested delegation cannot replace an existing run.`);
|
|
655
|
+
}
|
|
656
|
+
const queued: QueuedReadSpawn = {
|
|
657
|
+
id: crypto.randomUUID(),
|
|
658
|
+
teamName,
|
|
659
|
+
member,
|
|
660
|
+
prompt,
|
|
661
|
+
params,
|
|
662
|
+
resolved,
|
|
663
|
+
ctx,
|
|
664
|
+
requestedAt: Date.now(),
|
|
665
|
+
nameReservationId,
|
|
666
|
+
pendingChildAcceptance,
|
|
667
|
+
};
|
|
668
|
+
setReadQueue(teamName, [...queue, queued]);
|
|
669
|
+
return queued;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
if (member.delegationDepth !== 1 || !member.parentAgentName) return append();
|
|
673
|
+
return withLifecycleTombstoneLock(teamName, member.parentAgentName, async parentLifecycleLock => {
|
|
674
|
+
await assertNestedChildAdmission(teamName, member, parentLifecycleLock);
|
|
675
|
+
const currentConfig = await teams.readConfig(teamName);
|
|
676
|
+
if (currentConfig.members.some((candidate) => candidate.name === member.name)) {
|
|
677
|
+
throw new Error(`Nested read agent ${member.name} is already active or queued; nested delegation cannot replace an existing run.`);
|
|
678
|
+
}
|
|
679
|
+
const parent = pendingParentForMember(teamName, member);
|
|
680
|
+
if (!parent) throw new Error(`Nested read agent ${member.name} is missing exact parent identity.`);
|
|
681
|
+
const pendingChildAcceptance = pendingChildController.acceptChild(parent, member.name);
|
|
682
|
+
try {
|
|
683
|
+
return append(pendingChildAcceptance);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
pendingChildController.settleAcceptance(pendingChildAcceptance);
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function drainQueuedReadSpawns(teamName: string): Promise<void> {
|
|
692
|
+
if (readQueueDrainingTeams.has(teamName)) return;
|
|
693
|
+
readQueueDrainingTeams.add(teamName);
|
|
694
|
+
try {
|
|
695
|
+
while (true) {
|
|
696
|
+
const next = readQueue(teamName)[0];
|
|
697
|
+
if (!next) return;
|
|
698
|
+
const settings = loadSettings({ projectDir: next.member.cwd });
|
|
699
|
+
if (activeReadCount(teamName) >= settings.readAgents.maxConcurrent) return;
|
|
700
|
+
|
|
701
|
+
const queued = readQueue(teamName)[0];
|
|
702
|
+
if (!queued) return;
|
|
703
|
+
const fence = await readLifecycleTombstone(teamName, queued.member.name);
|
|
704
|
+
if (fence.status !== "absent") return;
|
|
705
|
+
try {
|
|
706
|
+
const config = await teams.readConfig(teamName);
|
|
707
|
+
if (config.members.some((member) => member.name === queued.member.name)) {
|
|
708
|
+
removeQueuedReadSpawnById(teamName, queued.id);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
queued.member.joinedAt = Date.now();
|
|
712
|
+
await startReadAgentMember(
|
|
713
|
+
teamName,
|
|
714
|
+
queued.member,
|
|
715
|
+
queued.prompt,
|
|
716
|
+
queued.ctx,
|
|
717
|
+
queued.nameReservationId,
|
|
718
|
+
false,
|
|
719
|
+
queued.pendingChildAcceptance
|
|
720
|
+
);
|
|
721
|
+
removeQueuedReadSpawnById(teamName, queued.id, false);
|
|
722
|
+
} catch (error) {
|
|
723
|
+
const latestFence = await readLifecycleTombstone(teamName, queued.member.name);
|
|
724
|
+
if (latestFence.status !== "absent") {
|
|
725
|
+
await messaging.sendPlainMessage(
|
|
726
|
+
teamName,
|
|
727
|
+
"system",
|
|
728
|
+
"team-lead",
|
|
729
|
+
`Retained queued agent ${queued.member.name}: lifecycle quarantine appeared before admission.`,
|
|
730
|
+
`Queued agent ${queued.member.name} retained by quarantine`,
|
|
731
|
+
"yellow"
|
|
732
|
+
).catch(() => {});
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
// Invalid non-lifecycle requests may be dropped so later work can run.
|
|
736
|
+
removeQueuedReadSpawnById(teamName, queued.id);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
} finally {
|
|
740
|
+
readQueueDrainingTeams.delete(teamName);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function currentSessionAgentGroupName(ctx: any): string {
|
|
745
|
+
const sessionId = getPiSessionId(ctx) || "local-session";
|
|
746
|
+
return paths.sanitizeName(`session-${sessionId}`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function ensureCurrentSessionAgentGroup(ctx: any, explicitDefaultModel: string): Promise<string> {
|
|
750
|
+
const sessionName = currentSessionAgentGroupName(ctx);
|
|
751
|
+
if (teams.teamExists(sessionName)) {
|
|
752
|
+
options.adoptTeamAsLead(sessionName, ctx);
|
|
753
|
+
return sessionName;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const { availableModels } = await getModelSelectionState(ctx, ctx.cwd, [explicitDefaultModel]);
|
|
757
|
+
const defaultModel = requireQualifiedKnownModel(explicitDefaultModel, availableModels, "model_slot");
|
|
758
|
+
if (!defaultModel) throw new Error("Agent sessions require a configured model_slot level before spawning.");
|
|
759
|
+
teams.createTeam(sessionName, getPiSessionId(ctx) || "local-session", "lead-agent", "Pi session agents", defaultModel);
|
|
760
|
+
options.adoptTeamAsLead(sessionName, ctx);
|
|
761
|
+
return sessionName;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function spawnTeammate(params: any, ctx: any, spawnOptions: SpawnTeammateOptions = {}): Promise<{ content: any[]; details: any }> {
|
|
765
|
+
const safeName = paths.sanitizeName(params.name);
|
|
766
|
+
const safeTeamName = paths.sanitizeName(params.team_name);
|
|
767
|
+
const cwd = params.cwd || ctx.cwd;
|
|
768
|
+
const teamConfig = await teams.readConfig(safeTeamName);
|
|
769
|
+
let nestedNameReservationId: string | undefined;
|
|
770
|
+
let nestedNameReservationTransferred = false;
|
|
771
|
+
|
|
772
|
+
if (spawnOptions.nestedParent) {
|
|
773
|
+
rejectUnexpectedNestedParams(params, ["name", "prompt", "model_slot", "team_name", "cwd"], `Nested read agent ${safeName}`);
|
|
774
|
+
requireNestedReadSpawnLevel(params, `Nested read agent ${safeName}`);
|
|
775
|
+
if (safeTeamName !== spawnOptions.nestedParent.teamName || params.team_name !== spawnOptions.nestedParent.teamName) {
|
|
776
|
+
throw new Error("Nested read agents must remain in the bound parent team.");
|
|
777
|
+
}
|
|
778
|
+
if (cwd !== spawnOptions.nestedParent.cwd || params.cwd !== spawnOptions.nestedParent.cwd) {
|
|
779
|
+
throw new Error("Nested read agents must use the bound parent cwd.");
|
|
780
|
+
}
|
|
781
|
+
await assertNestedParentAuthorized(
|
|
782
|
+
safeTeamName,
|
|
783
|
+
spawnOptions.nestedParent.name,
|
|
784
|
+
spawnOptions.nestedParent.lifecycleRunId,
|
|
785
|
+
spawnOptions.nestedParent.cwd
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
const activeDuplicate = teamConfig.members.find((candidate) => candidate.name === safeName);
|
|
789
|
+
const queuedReadDuplicate = findQueuedReadSpawn(safeTeamName, { name: safeName });
|
|
790
|
+
const queuedWriteDuplicate = await writeQueue.findQueuedWriteSpawn(safeTeamName, { name: safeName });
|
|
791
|
+
if (activeDuplicate || queuedReadDuplicate || queuedWriteDuplicate) {
|
|
792
|
+
throw new Error(`Nested read agent ${safeName} is already active or queued; nested delegation cannot replace an existing run.`);
|
|
793
|
+
}
|
|
794
|
+
nestedNameReservationId = reserveNestedReadName(safeTeamName, safeName);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
try {
|
|
798
|
+
if (spawnOptions.once) {
|
|
799
|
+
const existingOnceMember = teamConfig.members.find(m => m.agentType === "teammate" && (m.name === safeName || memberMatchesOperation(m, params)));
|
|
800
|
+
if (existingOnceMember) {
|
|
801
|
+
return {
|
|
802
|
+
content: [{ type: "text", text: `Teammate ${safeName} already exists; reusing existing member.` }],
|
|
803
|
+
details: memberResolutionDetails(existingOnceMember, params, {
|
|
804
|
+
existing: true,
|
|
805
|
+
idempotent: true,
|
|
806
|
+
queued: false,
|
|
807
|
+
terminalId: existingOnceMember.tmuxPaneId || null,
|
|
808
|
+
modelSource: "existing",
|
|
809
|
+
}),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const queuedRead = findQueuedReadSpawn(safeTeamName, { ...params, name: safeName });
|
|
814
|
+
if (queuedRead) {
|
|
815
|
+
const queuePosition = readQueue(safeTeamName).findIndex((item) => item.id === queuedRead.id) + 1;
|
|
816
|
+
return {
|
|
817
|
+
content: [{ type: "text", text: `Read teammate ${safeName} is already queued at position ${queuePosition}.` }],
|
|
818
|
+
details: queuedReadResolutionDetails(queuedRead, params, { queuePosition, existing: true, idempotent: true }),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const queued = await writeQueue.findQueuedWriteSpawn(safeTeamName, {
|
|
823
|
+
name: safeName,
|
|
824
|
+
operationId: params.operation_id,
|
|
825
|
+
workflowRunId: params.workflow_run_id,
|
|
826
|
+
});
|
|
827
|
+
if (queued) {
|
|
828
|
+
const queuedItems = await writeQueue.listWriteQueue(safeTeamName);
|
|
829
|
+
const queuePosition = queuedItems.findIndex(item => item.id === queued.id) + 1;
|
|
830
|
+
return {
|
|
831
|
+
content: [{ type: "text", text: `Write teammate ${safeName} is already queued at position ${queuePosition}.` }],
|
|
832
|
+
details: queuedResolutionDetails(safeTeamName, queued, params, { queued: true, queueId: queued.id, queuePosition, existing: true, idempotent: true }),
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const existingMember = teamConfig.members.find(m => m.name === safeName && m.agentType === "teammate");
|
|
838
|
+
if (existingMember) {
|
|
839
|
+
const key = options.readAgentKey(safeTeamName, existingMember.name);
|
|
840
|
+
const expectedState = options.runningReadAgents.get(key);
|
|
841
|
+
const teardown = await options.shutdownTeammate(safeTeamName, existingMember);
|
|
842
|
+
const currentState = options.runningReadAgents.get(key);
|
|
843
|
+
const lifecycleBlocked = teardown.status !== "settled"
|
|
844
|
+
|| !teardown.finalized
|
|
845
|
+
|| !teardown.removedMember
|
|
846
|
+
|| (currentState === expectedState && !!currentState && (
|
|
847
|
+
currentState.status === "finishing"
|
|
848
|
+
|| currentState.teardownState === "stopping"
|
|
849
|
+
|| currentState.teardownState === "quarantined"
|
|
850
|
+
|| currentState.teardownState === "persistence_failed"
|
|
851
|
+
));
|
|
852
|
+
if (lifecycleBlocked) {
|
|
853
|
+
const reason = teardown.status === "persistence_failed"
|
|
854
|
+
? "cleanup is blocked because persisted message admission could not be closed"
|
|
855
|
+
: teardown.status === "cleanup_failed"
|
|
856
|
+
? `cleanup failed${teardown.error ? `: ${teardown.error}` : ""}`
|
|
857
|
+
: "the previous run is still finishing or quarantined";
|
|
858
|
+
throw new Error(`Agent ${safeName} cannot be restarted yet: ${reason}. Retry after lifecycle cleanup settles.`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const settings = loadSettings({ projectDir: cwd });
|
|
863
|
+
const modelSlot = spawnOptions.nestedParent
|
|
864
|
+
? requireNestedReadSpawnLevel(params, `Nested read agent ${safeName}`)
|
|
865
|
+
: requireSpawnLevel(params, `Agent ${safeName}`);
|
|
866
|
+
const role: AgentRole = roleForFavoriteModelSlot(modelSlot);
|
|
867
|
+
const requestedLevel = requireFavoriteModelLevel(settings, modelSlot);
|
|
868
|
+
const requestedFavoriteModel = requestedLevel.model;
|
|
869
|
+
const { availableModels } = await getModelSelectionState(ctx, ctx.cwd, [teamConfig.defaultModel, requestedFavoriteModel].filter(Boolean) as string[]);
|
|
870
|
+
const resolved = resolveModel(settings, {
|
|
871
|
+
role,
|
|
872
|
+
modelSlot,
|
|
873
|
+
explicitModel: null,
|
|
874
|
+
explicitThinking: null,
|
|
875
|
+
teamDefaultModel: teamConfig.defaultModel,
|
|
876
|
+
currentModel: null,
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
const chosenModel = requireQualifiedKnownModel(resolved.model ?? undefined, availableModels, "resolved model");
|
|
880
|
+
if (!chosenModel) {
|
|
881
|
+
throw new Error(
|
|
882
|
+
"No model could be resolved from the configured model_slot level. Define levels with /agents-favorite-models before spawning agents."
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const chosenThinking = requestedLevel.thinking as Member["thinking"];
|
|
887
|
+
const debugLogPath = role === "write" && isTeamsDebugEnabled(settings) ? teamDebugLogPath(safeTeamName) : undefined;
|
|
888
|
+
|
|
889
|
+
const member: Member = {
|
|
890
|
+
agentId: `${safeName}@${safeTeamName}`,
|
|
891
|
+
name: safeName,
|
|
892
|
+
agentType: "teammate",
|
|
893
|
+
role,
|
|
894
|
+
category: params.category,
|
|
895
|
+
modelSlot,
|
|
896
|
+
model: chosenModel,
|
|
897
|
+
joinedAt: Date.now(),
|
|
898
|
+
tmuxPaneId: "",
|
|
899
|
+
cwd,
|
|
900
|
+
subscriptions: [],
|
|
901
|
+
prompt: params.prompt,
|
|
902
|
+
color: role === "read" ? "cyan" : "blue",
|
|
903
|
+
thinking: chosenThinking,
|
|
904
|
+
planModeRequired: params.plan_mode_required,
|
|
905
|
+
metadata: operationMetadataFromParams(params),
|
|
906
|
+
delegationDepth: spawnOptions.nestedParent ? 1 : 0,
|
|
907
|
+
allowNestedReadAgents: !spawnOptions.nestedParent && spawnOptions.allowNestedReadAgents === true,
|
|
908
|
+
sessionContext: !spawnOptions.nestedParent && params.session_context === "lazy" ? "lazy" : undefined,
|
|
909
|
+
parentAgentName: spawnOptions.nestedParent?.name,
|
|
910
|
+
parentLifecycleRunId: spawnOptions.nestedParent?.lifecycleRunId,
|
|
911
|
+
requestedBy: spawnOptions.nestedParent?.name,
|
|
912
|
+
helperKind: spawnOptions.nestedParent ? "read_helper" : undefined,
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
if (role === "read") {
|
|
916
|
+
if (!spawnOptions.nestedParent) removeQueuedReadSpawnsByName(safeTeamName, safeName);
|
|
917
|
+
const currentReadCount = activeReadCount(safeTeamName);
|
|
918
|
+
if (currentReadCount >= settings.readAgents.maxConcurrent) {
|
|
919
|
+
if (!settings.readAgents.queueOverflow) {
|
|
920
|
+
throw new Error(`Read-agent capacity reached (${currentReadCount}/${settings.readAgents.maxConcurrent}) and queueOverflow is disabled.`);
|
|
921
|
+
}
|
|
922
|
+
const queued = await enqueueReadSpawn(
|
|
923
|
+
safeTeamName,
|
|
924
|
+
member,
|
|
925
|
+
params.prompt,
|
|
926
|
+
params,
|
|
927
|
+
resolved,
|
|
928
|
+
ctx,
|
|
929
|
+
nestedNameReservationId
|
|
930
|
+
);
|
|
931
|
+
nestedNameReservationTransferred = !!nestedNameReservationId;
|
|
932
|
+
const queuePosition = readQueue(safeTeamName).findIndex((item) => item.id === queued.id) + 1;
|
|
933
|
+
return {
|
|
934
|
+
content: [{ type: "text", text: `Read teammate ${params.name} queued at position ${queuePosition}; capacity is ${currentReadCount}/${settings.readAgents.maxConcurrent}.` }],
|
|
935
|
+
details: queuedReadResolutionDetails(queued, params, { queuePosition }),
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx, nestedNameReservationId);
|
|
940
|
+
return {
|
|
941
|
+
content: [{ type: "text", text: `Read teammate ${params.name} started in-process.` }],
|
|
942
|
+
details: spawnResolutionDetails(member, params, resolved, {
|
|
943
|
+
mode: "in-process",
|
|
944
|
+
terminalId: null,
|
|
945
|
+
queued: false,
|
|
946
|
+
sessionContextAvailable,
|
|
947
|
+
}),
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
await writeQueue.removeQueuedWriteSpawnsByName(safeTeamName, safeName);
|
|
952
|
+
const activeWriteCount = activeAgentCount(safeTeamName, "write");
|
|
953
|
+
await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.request", {
|
|
954
|
+
agentName: safeName,
|
|
955
|
+
cwd,
|
|
956
|
+
category: params.category ?? null,
|
|
957
|
+
requestedRole: role,
|
|
958
|
+
resolvedRole: role,
|
|
959
|
+
model: chosenModel,
|
|
960
|
+
modelSource: resolved.modelSource,
|
|
961
|
+
thinking: chosenThinking ?? null,
|
|
962
|
+
activeWriteCount,
|
|
963
|
+
maxConcurrent: settings.writeAgents.maxConcurrent,
|
|
964
|
+
queueOverflow: false,
|
|
965
|
+
mode: "in-process",
|
|
966
|
+
debugLogPath: debugLogPath ?? null,
|
|
967
|
+
}, settings);
|
|
968
|
+
|
|
969
|
+
if (activeWriteCount >= settings.writeAgents.maxConcurrent) {
|
|
970
|
+
await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.failure", {
|
|
971
|
+
agentName: safeName,
|
|
972
|
+
reason: "capacity-reached",
|
|
973
|
+
activeWriteCount,
|
|
974
|
+
maxConcurrent: settings.writeAgents.maxConcurrent,
|
|
975
|
+
mode: "in-process",
|
|
976
|
+
debugLogPath: debugLogPath ?? null,
|
|
977
|
+
}, settings);
|
|
978
|
+
throw new Error(`Edit-agent capacity reached (${activeWriteCount}/${settings.writeAgents.maxConcurrent}). Wait for an active edit agent to finish before spawning another.`);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.success", {
|
|
982
|
+
agentName: safeName,
|
|
983
|
+
terminalId: null,
|
|
984
|
+
windowId: null,
|
|
985
|
+
mode: "in-process",
|
|
986
|
+
debugLogPath: debugLogPath ?? null,
|
|
987
|
+
}, settings);
|
|
988
|
+
const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx);
|
|
989
|
+
options.renderReadAgentStatus();
|
|
990
|
+
const debugSuffix = debugLogPath ? ` Debug log: ${debugLogPath}.` : "";
|
|
991
|
+
return {
|
|
992
|
+
content: [{ type: "text", text: `Edit agent ${params.name} started in-process and is followable from Pi.${debugSuffix}` }],
|
|
993
|
+
details: spawnResolutionDetails(member, params, resolved, {
|
|
994
|
+
mode: "in-process",
|
|
995
|
+
terminalId: null,
|
|
996
|
+
queued: false,
|
|
997
|
+
debugLogPath,
|
|
998
|
+
sessionContextAvailable,
|
|
999
|
+
}),
|
|
1000
|
+
};
|
|
1001
|
+
} finally {
|
|
1002
|
+
if (!nestedNameReservationTransferred) {
|
|
1003
|
+
releaseNestedReadName(safeTeamName, safeName, nestedNameReservationId);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
pi.events?.on?.("pi-extended-teams:orchestration-request", async (payload: any) => {
|
|
1009
|
+
const requestId = payload?.requestId;
|
|
1010
|
+
const type = String(payload?.type || "");
|
|
1011
|
+
const params = payload?.params || {};
|
|
1012
|
+
const requestCtx = payload?.ctx;
|
|
1013
|
+
const ctx = requestCtx || options.getSessionCtx?.();
|
|
1014
|
+
if (requestCtx) options.setSessionCtx?.(requestCtx);
|
|
1015
|
+
|
|
1016
|
+
try {
|
|
1017
|
+
if (options.isTeammate) throw new Error("Teammates cannot satisfy orchestration requests directly.");
|
|
1018
|
+
if (!ctx) throw new Error("No active lead session context is available for orchestration request. If pi-extended-teams was registered after session_start, include the current Pi command context as payload.ctx.");
|
|
1019
|
+
|
|
1020
|
+
if (type === "ensure_team") {
|
|
1021
|
+
const safeTeamName = paths.sanitizeName(params.team_name);
|
|
1022
|
+
if (teams.teamExists(safeTeamName)) {
|
|
1023
|
+
const config = await teams.readConfig(safeTeamName);
|
|
1024
|
+
options.adoptTeamAsLead(safeTeamName, ctx);
|
|
1025
|
+
emitOrchestrationResponse(requestId, type, { ok: true, details: { config, created: false, idempotent: true } });
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (params.default_model || params.model || params.thinking || params.role) {
|
|
1030
|
+
throw new Error("ensure_team must use default_model_slot only; direct model, thinking, or role is not allowed.");
|
|
1031
|
+
}
|
|
1032
|
+
const level = requireFavoriteModelLevel(loadSettings({ projectDir: ctx.cwd }), params.default_model_slot || "read-review");
|
|
1033
|
+
const { availableModels } = await getModelSelectionState(ctx, ctx.cwd, [level.model]);
|
|
1034
|
+
const defaultModel = requireQualifiedKnownModel(level.model, availableModels, "default_model_slot");
|
|
1035
|
+
if (!defaultModel) throw new Error(`Favorite level ${level.slot} resolved to unavailable model ${level.model}.`);
|
|
1036
|
+
const result = await teams.ensureTeam({
|
|
1037
|
+
name: safeTeamName,
|
|
1038
|
+
sessionId: "local-session",
|
|
1039
|
+
leadAgentId: "lead-agent",
|
|
1040
|
+
description: params.description,
|
|
1041
|
+
defaultModel,
|
|
1042
|
+
metadata: operationMetadataFromParams(params),
|
|
1043
|
+
});
|
|
1044
|
+
options.adoptTeamAsLead(safeTeamName, ctx);
|
|
1045
|
+
emitOrchestrationResponse(requestId, type, { ok: true, details: { config: result.config, created: result.created, idempotent: true } });
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (type === "spawn_teammate_once") {
|
|
1050
|
+
const safeTeamName = paths.sanitizeName(params.team_name);
|
|
1051
|
+
if (!teams.teamExists(safeTeamName)) throw new Error(`Team ${params.team_name} does not exist`);
|
|
1052
|
+
options.adoptTeamAsLead(safeTeamName, ctx);
|
|
1053
|
+
const result = await spawnTeammate(params, ctx, { once: true });
|
|
1054
|
+
emitOrchestrationResponse(requestId, type, { ok: true, details: result.details, content: result.content });
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
throw new Error(`Unsupported orchestration request type: ${type}`);
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
emitOrchestrationResponse(requestId, type, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
const levelDescription = "Required configured intent tier. Read tiers: read-collect gathers bounded facts without owning the conclusion; read-review is the normal default for focused review, verification, and bounded synthesis; read-analyze explains behavior or root cause across connected evidence; read-critical is only for irreducible high-stakes security, architecture, concurrency, migration, or data-correctness reasoning. Write tiers: write-patch makes a narrow localized change; write-feature implements a bounded feature with a known design; write-system owns a cross-cutting integration or refactor within explicitly claimed files; write-critical is only for high-risk security, concurrency, recovery, migration, or data-integrity changes. Prefer canonical tiers; legacy reading-*/writing-* aliases remain accepted for this minor release. Do not pass role, model, or thinking directly; see README.md.";
|
|
1065
|
+
const publicAgentBaseParams = {
|
|
1066
|
+
name: Type.Optional(Type.String({ description: "Stable display name. Defaults to a generated agent name." })),
|
|
1067
|
+
prompt: Type.String({ description: "The agent's assignment, relevant prior context, evidence already gathered, constraints, and report shape." }),
|
|
1068
|
+
cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the lead session cwd." })),
|
|
1069
|
+
session_context: Type.Optional(StringEnum(["none", "lazy"] as const, { description: "Optional filtered snapshot of the lead's active session branch. Use lazy only when omitted session history may materially affect the lane; the child reads it on demand rather than receiving transcript content in its prompt.", default: "none" })),
|
|
1070
|
+
metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
1071
|
+
allow_nested_read_agents: Type.Optional(Type.Boolean({ description: "Opt in eligible depth-0 write-feature/write-critical agents to restricted read-only child spawning.", default: false })),
|
|
1072
|
+
};
|
|
1073
|
+
const publicAgentParams = {
|
|
1074
|
+
...publicAgentBaseParams,
|
|
1075
|
+
model_slot: StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription, default: "read-review" }),
|
|
1076
|
+
};
|
|
1077
|
+
const publicSwarmAgentParams = {
|
|
1078
|
+
...publicAgentBaseParams,
|
|
1079
|
+
model_slot: Type.Optional(StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription })),
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
function generatedAgentName(index?: number): string {
|
|
1083
|
+
const position = index === undefined ? "" : `-${index + 1}`;
|
|
1084
|
+
return `agent-${Date.now().toString(36)}${position}-${crypto.randomUUID().slice(0, 8)}`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function spawnPublicAgent(params: any, ctx: any): Promise<{ content: any[]; details: any }> {
|
|
1088
|
+
if (options.isTeammate) throw new Error("Only the lead session can spawn agents.");
|
|
1089
|
+
if (!ctx) throw new Error("No active Pi session context is available for spawn_agent.");
|
|
1090
|
+
|
|
1091
|
+
const sessionDefaultModel = configuredFavoriteModelForSpawn(params, ctx);
|
|
1092
|
+
const sessionName = await ensureCurrentSessionAgentGroup(ctx, sessionDefaultModel);
|
|
1093
|
+
const name = params.name || generatedAgentName();
|
|
1094
|
+
const result = await spawnTeammate({
|
|
1095
|
+
...params,
|
|
1096
|
+
name,
|
|
1097
|
+
team_name: sessionName,
|
|
1098
|
+
}, ctx, { allowNestedReadAgents: params.allow_nested_read_agents === true });
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
content: [{ type: "text", text: `Agent ${name} started (${result.details.role}, ${result.details.mode || "in-process"}).` }],
|
|
1102
|
+
details: { ...result.details, name, session: sessionName },
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const nestedReadAgentParams = {
|
|
1107
|
+
name: Type.Optional(Type.String({ description: "Stable child name. Defaults to a generated name." })),
|
|
1108
|
+
prompt: Type.String({ description: "Read-only assignment and required report shape." }),
|
|
1109
|
+
model_slot: StringEnum(NESTED_READ_MODEL_SLOTS, { description: "Required canonical read-* intent tier." }),
|
|
1110
|
+
};
|
|
1111
|
+
const nestedSwarmAgentParams = {
|
|
1112
|
+
name: Type.Optional(Type.String({ description: "Stable child name. Defaults to a generated name." })),
|
|
1113
|
+
prompt: Type.String({ description: "Read-only assignment and required report shape." }),
|
|
1114
|
+
model_slot: Type.Optional(StringEnum(NESTED_READ_MODEL_SLOTS, { description: "Canonical read-* tier, or inherit defaults.model_slot." })),
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
async function spawnNestedReadAgent(
|
|
1118
|
+
params: any,
|
|
1119
|
+
binding: NestedReadAgentToolBinding,
|
|
1120
|
+
context = "spawn_agent"
|
|
1121
|
+
): Promise<{ content: any[]; details: any }> {
|
|
1122
|
+
rejectUnexpectedNestedParams(params, ["name", "prompt", "model_slot"], context);
|
|
1123
|
+
requireNestedReadSpawnLevel(params, context);
|
|
1124
|
+
await assertNestedParentAuthorized(binding.teamName, binding.parent.name, binding.parentRunId, binding.parent.cwd);
|
|
1125
|
+
const name = params.name || generatedAgentName();
|
|
1126
|
+
const result = await spawnTeammate({
|
|
1127
|
+
name,
|
|
1128
|
+
prompt: params.prompt,
|
|
1129
|
+
model_slot: params.model_slot,
|
|
1130
|
+
team_name: binding.teamName,
|
|
1131
|
+
cwd: binding.parent.cwd,
|
|
1132
|
+
}, binding.outerCtx, {
|
|
1133
|
+
nestedParent: {
|
|
1134
|
+
teamName: binding.teamName,
|
|
1135
|
+
name: binding.parent.name,
|
|
1136
|
+
lifecycleRunId: binding.parentRunId,
|
|
1137
|
+
cwd: binding.parent.cwd,
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
1140
|
+
return {
|
|
1141
|
+
content: result.content,
|
|
1142
|
+
details: { ...result.details, name, session: binding.teamName },
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function createNestedReadAgentTools(binding: NestedReadAgentToolBinding): any[] {
|
|
1147
|
+
if (!isEligibleNestedReadParent(binding.parent)
|
|
1148
|
+
|| binding.parent.lifecycleRunId !== binding.parentRunId
|
|
1149
|
+
|| binding.parent.delegationDepth !== 0) {
|
|
1150
|
+
return [];
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
return [
|
|
1154
|
+
{
|
|
1155
|
+
name: "spawn_agent",
|
|
1156
|
+
label: "Spawn Read Agent",
|
|
1157
|
+
description: "Spawn one depth-1 read-only child in this writer's team and cwd. Only canonical read-* model_slot values are accepted. The child reports back to this writer and cannot delegate.",
|
|
1158
|
+
parameters: Type.Object(nestedReadAgentParams),
|
|
1159
|
+
async execute(_toolCallId: string, params: any) {
|
|
1160
|
+
return spawnNestedReadAgent(params, binding);
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
name: "spawn_swarm_agents",
|
|
1165
|
+
label: "Spawn Read Swarm Agents",
|
|
1166
|
+
description: "Spawn any number of depth-1 read-only children in this writer's team and cwd, subject to the team's global read capacity and queue. Children report back to this writer and cannot delegate.",
|
|
1167
|
+
parameters: Type.Object({
|
|
1168
|
+
defaults: Type.Optional(Type.Object({
|
|
1169
|
+
model_slot: Type.Optional(StringEnum(NESTED_READ_MODEL_SLOTS, { description: "Shared canonical read-* tier." })),
|
|
1170
|
+
})),
|
|
1171
|
+
agents: Type.Array(Type.Object(nestedSwarmAgentParams), { description: "Read-only child assignments." }),
|
|
1172
|
+
}),
|
|
1173
|
+
async execute(_toolCallId: string, params: any) {
|
|
1174
|
+
rejectUnexpectedNestedParams(params, ["defaults", "agents"], "spawn_swarm_agents");
|
|
1175
|
+
rejectUnexpectedNestedParams(params.defaults || {}, ["model_slot"], "spawn_swarm_agents defaults");
|
|
1176
|
+
if (!Array.isArray(params.agents) || params.agents.length === 0) {
|
|
1177
|
+
throw new Error("spawn_swarm_agents requires at least one read-only child.");
|
|
1178
|
+
}
|
|
1179
|
+
const mergedAgents = params.agents.map((agent: any, index: number) => {
|
|
1180
|
+
rejectUnexpectedNestedParams(agent, ["name", "prompt", "model_slot"], `spawn_swarm_agents agent ${index + 1}`);
|
|
1181
|
+
const merged = mergeSwarmAgentParams(params.defaults || {}, agent);
|
|
1182
|
+
requireNestedReadSpawnLevel(merged, `spawn_swarm_agents agent ${merged.name || index + 1}`);
|
|
1183
|
+
return merged;
|
|
1184
|
+
});
|
|
1185
|
+
await assertNestedParentAuthorized(binding.teamName, binding.parent.name, binding.parentRunId, binding.parent.cwd);
|
|
1186
|
+
|
|
1187
|
+
const spawned: any[] = [];
|
|
1188
|
+
const failed: Array<{ name: string; error: string }> = [];
|
|
1189
|
+
for (let index = 0; index < mergedAgents.length; index += 1) {
|
|
1190
|
+
const merged = mergedAgents[index];
|
|
1191
|
+
const name = merged.name || generatedAgentName(index);
|
|
1192
|
+
try {
|
|
1193
|
+
const result = await spawnNestedReadAgent({ ...merged, name }, binding, `spawn_swarm_agents agent ${name}`);
|
|
1194
|
+
spawned.push({ ...result.details, name });
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
failed.push({ name, error: error instanceof Error ? error.message : String(error) });
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const lines = [`Spawned ${spawned.length}/${params.agents.length} nested read agents.`];
|
|
1201
|
+
for (const item of spawned) lines.push(`- ${item.name}: ${item.queued ? "queued" : "started"}`);
|
|
1202
|
+
for (const item of failed) lines.push(`- ${item.name}: failed — ${item.error}`);
|
|
1203
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { session: binding.teamName, spawned, failed } };
|
|
1204
|
+
},
|
|
1205
|
+
},
|
|
1206
|
+
];
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
pi.registerTool({
|
|
1210
|
+
name: "spawn_agent",
|
|
1211
|
+
label: "Spawn Agent",
|
|
1212
|
+
description: "Spawn one agent by configured intent tier only. Give it the relevant goal, decisions, prior attempts, inspected evidence, constraints, and expected delta rather than a context-free task. Use session_context=lazy only as an on-demand fallback when omitted session history may materially matter; it never replaces a good mission prompt. read-review is the normal read default; use read-collect for bounded fact gathering, read-analyze for connected explanation/root cause, and read-critical only for irreducible high-stakes reasoning. For edits, choose write-patch, write-feature, write-system, or the rare high-risk write-critical by scope and risk. After spawning, do not duplicate or take over its lane; work only on unrelated work, then wait literally idle for the automatic report—never sleep, poll, repeatedly read inbox/status, or treat healthy silence as failure. Wait for the actual report before synthesizing; intervene only on a reported blocker/error, actual health failure, or explicit user cancellation. model_slot selects behavior, model, and thinking; do not pass role, model, or thinking directly.",
|
|
1213
|
+
parameters: Type.Object(publicAgentParams),
|
|
1214
|
+
async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
|
|
1215
|
+
return spawnPublicAgent(params, ctx);
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
pi.registerTool({
|
|
1220
|
+
name: "spawn_swarm_agents",
|
|
1221
|
+
label: "Spawn Swarm Agents",
|
|
1222
|
+
description: "Spawn a batch by configured intent tiers only. Give each lane the relevant goal, decisions, prior attempts, inspected evidence, constraints, and expected delta. Use session_context=lazy selectively as an on-demand fallback, never instead of a good mission prompt. Use read-review as the normal default, read-collect for bounded collection lanes, read-analyze for connected explanation, and read-critical only for irreducible high-stakes reasoning; choose write-patch/feature/system/critical by edit scope and risk. Each spawned lane is delegation-locked: do not duplicate/take it over, and after unrelated work is done wait literally idle for automatic reports without sleep, polling, repeated inbox/status reads, or premature intervention. Synthesize only after actual reports; intervene only on blocker/error, actual failure, or explicit cancellation. Each agent gets model_slot directly or from defaults; do not pass role, model, or thinking directly.",
|
|
1223
|
+
parameters: Type.Object({
|
|
1224
|
+
defaults: Type.Optional(Type.Object({
|
|
1225
|
+
cwd: Type.Optional(Type.String()),
|
|
1226
|
+
model_slot: Type.Optional(StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription })),
|
|
1227
|
+
metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
1228
|
+
session_context: Type.Optional(StringEnum(["none", "lazy"] as const, { description: "Shared lazy session-reference policy.", default: "none" })),
|
|
1229
|
+
allow_nested_read_agents: Type.Optional(Type.Boolean({ description: "Shared opt-in for eligible depth-0 write-feature/write-critical agents.", default: false })),
|
|
1230
|
+
})),
|
|
1231
|
+
agents: Type.Array(Type.Object(publicSwarmAgentParams), { description: "Agents to spawn as one batch. Each one must have model_slot directly or inherit it from defaults." }),
|
|
1232
|
+
}),
|
|
1233
|
+
async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
|
|
1234
|
+
if (options.isTeammate) throw new Error("Only the lead session can spawn agents.");
|
|
1235
|
+
if (!ctx) throw new Error("No active Pi session context is available for spawn_swarm_agents.");
|
|
1236
|
+
if (!Array.isArray(params.agents) || params.agents.length === 0) throw new Error("spawn_swarm_agents requires at least one agent.");
|
|
1237
|
+
|
|
1238
|
+
const mergedAgents = params.agents.map((agent: any) => mergeSwarmAgentParams(params.defaults || {}, agent));
|
|
1239
|
+
const defaultModel = configuredFavoriteModelForSpawn(mergedAgents[0], ctx, "spawn_swarm_agents");
|
|
1240
|
+
for (let index = 0; index < mergedAgents.length; index += 1) {
|
|
1241
|
+
configuredFavoriteModelForSpawn(mergedAgents[index], ctx, `spawn_swarm_agents agent ${mergedAgents[index].name || index + 1}`);
|
|
1242
|
+
}
|
|
1243
|
+
const sessionName = await ensureCurrentSessionAgentGroup(ctx, defaultModel);
|
|
1244
|
+
const spawned: any[] = [];
|
|
1245
|
+
const failed: Array<{ name: string; error: string }> = [];
|
|
1246
|
+
|
|
1247
|
+
for (let index = 0; index < mergedAgents.length; index += 1) {
|
|
1248
|
+
const merged = mergedAgents[index];
|
|
1249
|
+
const name = merged.name || generatedAgentName(index);
|
|
1250
|
+
try {
|
|
1251
|
+
const result = await spawnTeammate({
|
|
1252
|
+
...merged,
|
|
1253
|
+
name,
|
|
1254
|
+
team_name: sessionName,
|
|
1255
|
+
}, ctx, { allowNestedReadAgents: merged.allow_nested_read_agents === true });
|
|
1256
|
+
spawned.push({ ...result.details, name });
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
failed.push({ name, error: error instanceof Error ? error.message : String(error) });
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const lines = [`Spawned ${spawned.length}/${params.agents.length} agents in the current Pi session.`];
|
|
1263
|
+
for (const item of spawned) lines.push(`- ${item.name}: ${item.role}, ${item.mode || "in-process"}`);
|
|
1264
|
+
for (const item of failed) lines.push(`- ${item.name}: failed — ${item.error}`);
|
|
1265
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { session: sessionName, spawned, failed } };
|
|
1266
|
+
},
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
return { createNestedReadAgentTools };
|
|
1270
|
+
}
|