@shanepadgett/tau-agent 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/subagents.md +25 -6
- package/extensions/attention/README.md +1 -0
- package/extensions/attention/index.ts +38 -0
- package/extensions/context/README.md +13 -3
- package/extensions/context/definitions.ts +3 -15
- package/extensions/context/evidence.ts +516 -0
- package/extensions/context/index.ts +114 -116
- package/extensions/context/panel.ts +60 -0
- package/extensions/context/settings.ts +30 -1
- package/extensions/context/sync.ts +171 -686
- package/extensions/context/validation.ts +4 -1
- package/extensions/context/write-scope.ts +109 -0
- package/extensions/context-pruning/README.md +22 -0
- package/extensions/context-pruning/file-evidence.ts +265 -0
- package/extensions/context-pruning/index.ts +379 -0
- package/extensions/context-pruning/projection.ts +108 -0
- package/extensions/context-pruning/prune.ts +346 -0
- package/extensions/context-pruning/render.ts +179 -0
- package/extensions/context-pruning/settings.ts +41 -0
- package/extensions/explore/README.md +3 -1
- package/extensions/explore/autoread.ts +85 -22
- package/extensions/explore/full-file-knowledge.ts +234 -0
- package/extensions/explore/index.ts +2 -3
- package/extensions/explore/read-cache.ts +150 -86
- package/extensions/explore/read-snapshots.ts +17 -4
- package/extensions/explore/read.ts +57 -38
- package/extensions/footer/index.ts +62 -60
- package/extensions/run-summary/index.ts +5 -5
- package/extensions/silent-command-runner/README.md +1 -1
- package/extensions/silent-command-runner/index.ts +45 -28
- package/extensions/soul/prompt.ts +3 -1
- package/extensions/subagent/README.md +21 -5
- package/extensions/subagent/agents/context-sync.md +90 -0
- package/extensions/subagent/agents/{generalist.md → dormant/generalist.md} +6 -0
- package/extensions/subagent/agents/{scout.md → dormant/scout.md} +6 -0
- package/extensions/subagent/agents/review.md +48 -0
- package/extensions/subagent/agents/web-research.md +6 -0
- package/extensions/subagent/agents.ts +15 -2
- package/extensions/subagent/cmux-dashboard.ts +454 -0
- package/extensions/subagent/index.ts +181 -234
- package/extensions/subagent/render.ts +1 -1
- package/extensions/subagent/resume.ts +78 -0
- package/extensions/subagent/run.ts +213 -118
- package/extensions/subagent/runtime.ts +856 -0
- package/extensions/subagent/session-resource.ts +169 -0
- package/extensions/tau-help/help.md +6 -2
- package/extensions/turn-budget/index.ts +8 -36
- package/package.json +2 -2
- package/schemas/tau.schema.json +48 -1
- package/shared/context-pruning-state.ts +364 -0
- package/shared/events.ts +18 -0
- package/shared/model-fallback/index.ts +21 -10
- package/shared/model-fallback/types.ts +5 -3
- package/shared/settings/load.ts +78 -1
- package/shared/tool-row-state.ts +21 -1
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AgentDefinition } from "./agents.ts";
|
|
4
|
+
import { buildColdResumePrompt, retainSubagentTurn } from "./resume.ts";
|
|
5
|
+
import {
|
|
6
|
+
cloneInvocationSnapshot,
|
|
7
|
+
createSubagentThread,
|
|
8
|
+
disposeSubagentThread,
|
|
9
|
+
extensionPathsForTools,
|
|
10
|
+
FifoGate,
|
|
11
|
+
runSubagentTurn,
|
|
12
|
+
type SubagentDetails,
|
|
13
|
+
type SubagentInvocationSnapshot,
|
|
14
|
+
type SubagentLifecycle,
|
|
15
|
+
type SubagentPhase,
|
|
16
|
+
type SubagentThread,
|
|
17
|
+
} from "./run.ts";
|
|
18
|
+
import { createSubagentSessionResource, type SubagentSessionResource } from "./session-resource.ts";
|
|
19
|
+
|
|
20
|
+
const MAX_RETAINED_THREADS = 16;
|
|
21
|
+
const GLOBAL_CONCURRENCY = 4;
|
|
22
|
+
const SUBAGENT_HOT_WINDOW_MS = 5 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
export type SnapshotObserver = (snapshot: SubagentInvocationSnapshot) => void;
|
|
25
|
+
|
|
26
|
+
export interface SubagentRuntimeOptions {
|
|
27
|
+
now?: () => number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SubagentToolResult {
|
|
31
|
+
content: Array<{ type: "text"; text: string }>;
|
|
32
|
+
details: SubagentDetails;
|
|
33
|
+
usage?: Usage;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ActiveInvocation {
|
|
37
|
+
snapshot: SubagentInvocationSnapshot;
|
|
38
|
+
controller: AbortController;
|
|
39
|
+
generation: number;
|
|
40
|
+
run: Promise<SubagentToolResult>;
|
|
41
|
+
admitTicket: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TrackedThread extends SubagentThread {
|
|
45
|
+
disposed: boolean;
|
|
46
|
+
disposePromise?: Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function emptyUsage(): SubagentDetails["usage"] {
|
|
50
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function baseDetails(options: {
|
|
54
|
+
agent: string;
|
|
55
|
+
displayName?: string;
|
|
56
|
+
task: string;
|
|
57
|
+
status: SubagentLifecycle;
|
|
58
|
+
phase: SubagentPhase;
|
|
59
|
+
model: string;
|
|
60
|
+
thinkingLevel: string;
|
|
61
|
+
threadId?: string;
|
|
62
|
+
invocationId?: string;
|
|
63
|
+
error?: string;
|
|
64
|
+
}): SubagentDetails {
|
|
65
|
+
return {
|
|
66
|
+
agent: options.agent,
|
|
67
|
+
displayName: options.displayName ?? options.agent,
|
|
68
|
+
...(options.threadId === undefined ? {} : { threadId: options.threadId }),
|
|
69
|
+
...(options.invocationId === undefined ? {} : { invocationId: options.invocationId }),
|
|
70
|
+
status: options.status,
|
|
71
|
+
phase: options.phase,
|
|
72
|
+
task: options.task,
|
|
73
|
+
model: options.model,
|
|
74
|
+
thinkingLevel: options.thinkingLevel,
|
|
75
|
+
toolCalls: 0,
|
|
76
|
+
actions: [],
|
|
77
|
+
omittedActions: 0,
|
|
78
|
+
omittedErrors: 0,
|
|
79
|
+
usage: emptyUsage(),
|
|
80
|
+
durationMs: 0,
|
|
81
|
+
...(options.error === undefined ? {} : { error: options.error }),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function failedToolResult(
|
|
86
|
+
agent: string,
|
|
87
|
+
task: string,
|
|
88
|
+
phase: SubagentPhase,
|
|
89
|
+
model: string,
|
|
90
|
+
thinkingLevel: string,
|
|
91
|
+
error: string,
|
|
92
|
+
threadId?: string,
|
|
93
|
+
status: "failed" | "aborted" = "failed",
|
|
94
|
+
): SubagentToolResult {
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: error }],
|
|
97
|
+
details: baseDetails({ agent, task, status, phase, model, thinkingLevel, threadId, error }),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function asTracked(thread: SubagentThread): TrackedThread {
|
|
102
|
+
if ("disposed" in thread) return thread as TrackedThread;
|
|
103
|
+
const tracked = thread as TrackedThread;
|
|
104
|
+
tracked.disposed = false;
|
|
105
|
+
return tracked;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Session-scoped subagent orchestration.
|
|
110
|
+
* One instance per extension factory lifetime; reset on session_start, dispose on shutdown.
|
|
111
|
+
*/
|
|
112
|
+
export class SubagentRuntime {
|
|
113
|
+
private generation = 0;
|
|
114
|
+
private nextThreadId = 1;
|
|
115
|
+
private nextInvocationId = 1;
|
|
116
|
+
private nextAdmitTicket = 1;
|
|
117
|
+
private admitHead = 1;
|
|
118
|
+
private readonly admitWaiters = new Map<number, Set<() => void>>();
|
|
119
|
+
private disposed = false;
|
|
120
|
+
/** True while reset/shutdown drains old work. New execute calls abort immediately. */
|
|
121
|
+
private lifecycleFence = false;
|
|
122
|
+
private globalGate = new FifoGate(GLOBAL_CONCURRENCY);
|
|
123
|
+
private readonly threads = new Map<string, TrackedThread>();
|
|
124
|
+
/** Startup reservation tokens. Never bulk-cleared; each invocation releases its own token. */
|
|
125
|
+
private readonly startupReservations = new Set<symbol>();
|
|
126
|
+
private readonly controllers = new Set<AbortController>();
|
|
127
|
+
private readonly invocations = new Map<string, ActiveInvocation>();
|
|
128
|
+
private readonly observers = new Set<SnapshotObserver>();
|
|
129
|
+
private readonly runtimeWarnings = new Set<string>();
|
|
130
|
+
private readonly nameOrdinals = new Map<string, number>();
|
|
131
|
+
private readonly assignedNames = new Set<string>();
|
|
132
|
+
private readonly pi: ExtensionAPI;
|
|
133
|
+
private readonly now: () => number;
|
|
134
|
+
private lifecycleChain = Promise.resolve();
|
|
135
|
+
|
|
136
|
+
constructor(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}) {
|
|
137
|
+
this.pi = pi;
|
|
138
|
+
this.now = options.now ?? Date.now;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
subscribe(observer: SnapshotObserver): () => void {
|
|
142
|
+
this.observers.add(observer);
|
|
143
|
+
return () => {
|
|
144
|
+
this.observers.delete(observer);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
listThreads(cwd: string): SubagentThread[] {
|
|
149
|
+
return [...this.threads.values()]
|
|
150
|
+
.filter((thread) => thread.cwd === cwd && !thread.disposed)
|
|
151
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
threadIds(cwd: string): string[] {
|
|
155
|
+
return this.listThreads(cwd).map((thread) => thread.id);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Exposed for tests. */
|
|
159
|
+
get retainedCount(): number {
|
|
160
|
+
return this.threads.size;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Exposed for tests. */
|
|
164
|
+
get capacityInUse(): number {
|
|
165
|
+
return this.threads.size + this.startupReservations.size;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Exposed for tests. */
|
|
169
|
+
get currentGeneration(): number {
|
|
170
|
+
return this.generation;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async reset(): Promise<void> {
|
|
174
|
+
await this.drainLifecycle({ permanent: false });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async shutdown(): Promise<void> {
|
|
178
|
+
await this.drainLifecycle({ permanent: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async drainLifecycle(options: { permanent: boolean }): Promise<void> {
|
|
182
|
+
await this.runLifecycle(async () => {
|
|
183
|
+
if (options.permanent) {
|
|
184
|
+
if (this.disposed) return;
|
|
185
|
+
this.disposed = true;
|
|
186
|
+
}
|
|
187
|
+
this.lifecycleFence = true;
|
|
188
|
+
this.generation += 1;
|
|
189
|
+
this.abortAllControllers();
|
|
190
|
+
this.wakeAllAdmitWaiters();
|
|
191
|
+
const running = [...this.invocations.values()].map((item) => item.run.catch(() => undefined));
|
|
192
|
+
await Promise.all(running);
|
|
193
|
+
this.invocations.clear();
|
|
194
|
+
// Replace gate only after old turns have released slots.
|
|
195
|
+
this.globalGate = new FifoGate(GLOBAL_CONCURRENCY);
|
|
196
|
+
if (options.permanent) this.observers.clear();
|
|
197
|
+
else {
|
|
198
|
+
this.nextAdmitTicket = 1;
|
|
199
|
+
this.admitHead = 1;
|
|
200
|
+
this.admitWaiters.clear();
|
|
201
|
+
this.runtimeWarnings.clear();
|
|
202
|
+
this.nameOrdinals.clear();
|
|
203
|
+
this.assignedNames.clear();
|
|
204
|
+
}
|
|
205
|
+
await this.disposeAllThreads();
|
|
206
|
+
if (!options.permanent) this.lifecycleFence = false;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
execute(options: {
|
|
211
|
+
agent: string;
|
|
212
|
+
task: string;
|
|
213
|
+
files?: readonly string[];
|
|
214
|
+
continuing: boolean;
|
|
215
|
+
threadKey?: string;
|
|
216
|
+
definition?: AgentDefinition;
|
|
217
|
+
ctx: ExtensionContext;
|
|
218
|
+
parentModel: string;
|
|
219
|
+
parentThinking: string;
|
|
220
|
+
signal?: AbortSignal;
|
|
221
|
+
onUpdate?: (details: SubagentDetails) => void | Promise<void>;
|
|
222
|
+
resolveFreshDefinition: () => Promise<
|
|
223
|
+
{ ok: true; definition: AgentDefinition } | { ok: false; error: string; phase: SubagentPhase }
|
|
224
|
+
>;
|
|
225
|
+
}): Promise<SubagentToolResult> {
|
|
226
|
+
const generation = this.generation;
|
|
227
|
+
const invocationId = `inv-${this.nextInvocationId++}`;
|
|
228
|
+
const admitTicket = this.nextAdmitTicket++;
|
|
229
|
+
const controller = new AbortController();
|
|
230
|
+
this.controllers.add(controller);
|
|
231
|
+
const startedAt = this.now();
|
|
232
|
+
const initialAgent = options.agent;
|
|
233
|
+
const initialSnapshot: SubagentInvocationSnapshot = {
|
|
234
|
+
...baseDetails({
|
|
235
|
+
agent: initialAgent,
|
|
236
|
+
task: options.task,
|
|
237
|
+
status: "waiting",
|
|
238
|
+
phase: "queue",
|
|
239
|
+
model: options.parentModel,
|
|
240
|
+
thinkingLevel: options.parentThinking,
|
|
241
|
+
threadId: options.continuing ? options.threadKey : undefined,
|
|
242
|
+
invocationId,
|
|
243
|
+
}),
|
|
244
|
+
invocationId,
|
|
245
|
+
startedAt,
|
|
246
|
+
};
|
|
247
|
+
const active: ActiveInvocation = {
|
|
248
|
+
snapshot: initialSnapshot,
|
|
249
|
+
controller,
|
|
250
|
+
generation,
|
|
251
|
+
admitTicket,
|
|
252
|
+
run: Promise.resolve(
|
|
253
|
+
failedToolResult(
|
|
254
|
+
initialAgent,
|
|
255
|
+
options.task,
|
|
256
|
+
"queue",
|
|
257
|
+
options.parentModel,
|
|
258
|
+
options.parentThinking,
|
|
259
|
+
"Subagent invocation failed to start",
|
|
260
|
+
options.threadKey,
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
};
|
|
264
|
+
this.invocations.set(invocationId, active);
|
|
265
|
+
this.publish(active.snapshot);
|
|
266
|
+
|
|
267
|
+
const run = this.executeInner(options, {
|
|
268
|
+
generation,
|
|
269
|
+
invocationId,
|
|
270
|
+
controller,
|
|
271
|
+
startedAt,
|
|
272
|
+
active,
|
|
273
|
+
admitTicket,
|
|
274
|
+
});
|
|
275
|
+
active.run = run;
|
|
276
|
+
return run;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async executeInner(
|
|
280
|
+
options: {
|
|
281
|
+
agent: string;
|
|
282
|
+
task: string;
|
|
283
|
+
files?: readonly string[];
|
|
284
|
+
continuing: boolean;
|
|
285
|
+
threadKey?: string;
|
|
286
|
+
ctx: ExtensionContext;
|
|
287
|
+
parentModel: string;
|
|
288
|
+
parentThinking: string;
|
|
289
|
+
definition?: AgentDefinition;
|
|
290
|
+
signal?: AbortSignal;
|
|
291
|
+
onUpdate?: (details: SubagentDetails) => void | Promise<void>;
|
|
292
|
+
resolveFreshDefinition: () => Promise<
|
|
293
|
+
{ ok: true; definition: AgentDefinition } | { ok: false; error: string; phase: SubagentPhase }
|
|
294
|
+
>;
|
|
295
|
+
},
|
|
296
|
+
state: {
|
|
297
|
+
generation: number;
|
|
298
|
+
invocationId: string;
|
|
299
|
+
controller: AbortController;
|
|
300
|
+
startedAt: number;
|
|
301
|
+
active: ActiveInvocation;
|
|
302
|
+
admitTicket: number;
|
|
303
|
+
},
|
|
304
|
+
): Promise<SubagentToolResult> {
|
|
305
|
+
const { task, continuing, ctx, parentModel, parentThinking, signal, onUpdate, resolveFreshDefinition } = options;
|
|
306
|
+
const { generation, invocationId, controller, startedAt, active, admitTicket } = state;
|
|
307
|
+
let agent = options.agent;
|
|
308
|
+
let displayName = options.agent;
|
|
309
|
+
let definition = options.definition;
|
|
310
|
+
let thread: TrackedThread | undefined;
|
|
311
|
+
let threadId = continuing ? options.threadKey : undefined;
|
|
312
|
+
const combined = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]);
|
|
313
|
+
|
|
314
|
+
let releaseThread: (() => void) | undefined;
|
|
315
|
+
let releaseGlobal: (() => void) | undefined;
|
|
316
|
+
let reservedThread: TrackedThread | undefined;
|
|
317
|
+
let provisionalThread: TrackedThread | undefined;
|
|
318
|
+
let provisionalResource: SubagentSessionResource | undefined;
|
|
319
|
+
let reservationToken: symbol | undefined;
|
|
320
|
+
let admitAdvanced = false;
|
|
321
|
+
let phase: SubagentPhase = "queue";
|
|
322
|
+
let lastPublishedStatus: SubagentLifecycle | undefined = "waiting";
|
|
323
|
+
|
|
324
|
+
const fanOut = (details: SubagentDetails, force = false) => {
|
|
325
|
+
const current = this.invocations.get(invocationId);
|
|
326
|
+
if (!current || current.generation !== this.generation) return;
|
|
327
|
+
if (!force && details.status === lastPublishedStatus && details.status !== "running") return;
|
|
328
|
+
lastPublishedStatus = details.status;
|
|
329
|
+
const next: SubagentInvocationSnapshot = {
|
|
330
|
+
...details,
|
|
331
|
+
invocationId,
|
|
332
|
+
startedAt,
|
|
333
|
+
threadId: details.threadId ?? threadId,
|
|
334
|
+
agent: details.agent || agent,
|
|
335
|
+
displayName: details.displayName || displayName,
|
|
336
|
+
};
|
|
337
|
+
current.snapshot = next;
|
|
338
|
+
this.publish(next);
|
|
339
|
+
if (!onUpdate) return;
|
|
340
|
+
// Detached: presentation latency must not block admission or child turns.
|
|
341
|
+
void Promise.resolve()
|
|
342
|
+
.then(() => onUpdate(cloneInvocationSnapshot(next)))
|
|
343
|
+
.catch(() => undefined);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const finish = (details: SubagentDetails, text?: string): SubagentToolResult => {
|
|
347
|
+
fanOut(details, true);
|
|
348
|
+
return { content: [{ type: "text", text: text ?? details.error ?? details.response ?? "" }], details };
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const fail = (
|
|
352
|
+
error: string,
|
|
353
|
+
failPhase: SubagentPhase,
|
|
354
|
+
overrides: Partial<SubagentDetails> = {},
|
|
355
|
+
): SubagentToolResult =>
|
|
356
|
+
finish(
|
|
357
|
+
baseDetails({
|
|
358
|
+
agent: overrides.agent ?? agent,
|
|
359
|
+
displayName: overrides.displayName ?? thread?.displayName ?? displayName,
|
|
360
|
+
task,
|
|
361
|
+
status: overrides.status === "aborted" ? "aborted" : "failed",
|
|
362
|
+
phase: failPhase,
|
|
363
|
+
model: overrides.model ?? thread?.model ?? parentModel,
|
|
364
|
+
thinkingLevel: overrides.thinkingLevel ?? thread?.thinkingLevel ?? parentThinking,
|
|
365
|
+
threadId: overrides.threadId ?? thread?.id ?? threadId,
|
|
366
|
+
invocationId,
|
|
367
|
+
error,
|
|
368
|
+
}),
|
|
369
|
+
error,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const abortNow = (failPhase: SubagentPhase = phase, markAdmit = false): SubagentToolResult => {
|
|
373
|
+
if (markAdmit && !admitAdvanced) {
|
|
374
|
+
this.advanceAdmitTicket(admitTicket);
|
|
375
|
+
admitAdvanced = true;
|
|
376
|
+
}
|
|
377
|
+
return finish(
|
|
378
|
+
this.terminalFromAbort(
|
|
379
|
+
agent,
|
|
380
|
+
displayName,
|
|
381
|
+
task,
|
|
382
|
+
failPhase,
|
|
383
|
+
thread?.model ?? parentModel,
|
|
384
|
+
thread?.thinkingLevel ?? parentThinking,
|
|
385
|
+
threadId,
|
|
386
|
+
invocationId,
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
if (this.lifecycleFence || this.disposed || generation !== this.generation) return abortNow();
|
|
393
|
+
|
|
394
|
+
if (continuing) {
|
|
395
|
+
const key = options.threadKey ?? "";
|
|
396
|
+
const existing = this.threads.get(key);
|
|
397
|
+
if (!existing || existing.cwd !== ctx.cwd || existing.disposed) {
|
|
398
|
+
const names = this.threadIds(ctx.cwd).join(", ") || "none";
|
|
399
|
+
return fail(`Subagent thread ${key} is unavailable. Active threads: ${names}`, "discovery", {
|
|
400
|
+
agent: key,
|
|
401
|
+
threadId: key,
|
|
402
|
+
model: parentModel,
|
|
403
|
+
thinkingLevel: parentThinking,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
existing.pendingTurns += 1;
|
|
407
|
+
reservedThread = existing;
|
|
408
|
+
thread = existing;
|
|
409
|
+
agent = existing.definition.name;
|
|
410
|
+
displayName = existing.displayName;
|
|
411
|
+
threadId = existing.id;
|
|
412
|
+
definition = existing.definition;
|
|
413
|
+
fanOut(
|
|
414
|
+
{
|
|
415
|
+
...active.snapshot,
|
|
416
|
+
agent,
|
|
417
|
+
displayName,
|
|
418
|
+
threadId,
|
|
419
|
+
model: existing.model,
|
|
420
|
+
thinkingLevel: existing.thinkingLevel,
|
|
421
|
+
},
|
|
422
|
+
true,
|
|
423
|
+
);
|
|
424
|
+
} else {
|
|
425
|
+
phase = "discovery";
|
|
426
|
+
const resolved = await resolveFreshDefinition();
|
|
427
|
+
if (!this.isLive(generation, combined)) return abortNow();
|
|
428
|
+
if (!resolved.ok) return fail(resolved.error, resolved.phase);
|
|
429
|
+
definition = resolved.definition;
|
|
430
|
+
agent = definition.name;
|
|
431
|
+
displayName = this.assignDisplayName(definition);
|
|
432
|
+
threadId = `thread-${this.nextThreadId++}`;
|
|
433
|
+
fanOut({ ...active.snapshot, agent, displayName, threadId }, true);
|
|
434
|
+
|
|
435
|
+
const reserved = this.reserveFreshCapacity();
|
|
436
|
+
if (!reserved.ok) return fail(reserved.error, "queue");
|
|
437
|
+
reservationToken = reserved.token;
|
|
438
|
+
if (reserved.evicted) await this.disposeThread(reserved.evicted);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// FIFO barrier assigned at execute() entry — earlier tickets go first regardless of discovery speed.
|
|
442
|
+
const admitted = await this.waitAdmitTicket(admitTicket, combined, generation);
|
|
443
|
+
if (!admitted) return abortNow(phase, true);
|
|
444
|
+
|
|
445
|
+
if (thread) {
|
|
446
|
+
releaseThread = await thread.turnGate.acquire(combined);
|
|
447
|
+
if (thread.disposed || this.threads.get(thread.id) !== thread) {
|
|
448
|
+
this.advanceAdmitTicket(admitTicket);
|
|
449
|
+
admitAdvanced = true;
|
|
450
|
+
return fail(`Subagent thread ${thread.id} is unavailable after queueing`, "queue", {
|
|
451
|
+
status: combined.aborted ? "aborted" : "failed",
|
|
452
|
+
model: thread.model,
|
|
453
|
+
thinkingLevel: thread.thinkingLevel,
|
|
454
|
+
threadId: thread.id,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!this.isLive(generation, combined)) return abortNow(phase, true);
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
releaseGlobal = await this.globalGate.acquire(combined);
|
|
462
|
+
} catch {
|
|
463
|
+
return abortNow(phase, true);
|
|
464
|
+
}
|
|
465
|
+
// Next ticket may compete for remaining global slots.
|
|
466
|
+
this.advanceAdmitTicket(admitTicket);
|
|
467
|
+
admitAdvanced = true;
|
|
468
|
+
if (!this.isLive(generation, combined)) return abortNow();
|
|
469
|
+
|
|
470
|
+
if (!thread) {
|
|
471
|
+
phase = "startup";
|
|
472
|
+
if (!threadId || !definition) throw new Error("Subagent startup state is incomplete");
|
|
473
|
+
const selectedDefinition = definition;
|
|
474
|
+
fanOut({ ...active.snapshot, status: "starting", phase: "startup", agent, threadId }, true);
|
|
475
|
+
const created = asTracked(
|
|
476
|
+
await createSubagentThread({
|
|
477
|
+
id: threadId,
|
|
478
|
+
displayName,
|
|
479
|
+
definition: selectedDefinition,
|
|
480
|
+
extensionPaths: extensionPathsForTools(this.pi, selectedDefinition.tools),
|
|
481
|
+
initialTask: task,
|
|
482
|
+
ctx,
|
|
483
|
+
thinkingLevel: parentThinking,
|
|
484
|
+
signal: combined,
|
|
485
|
+
onWarning: (warning) => {
|
|
486
|
+
const message = `Subagent definition ${selectedDefinition.path}: ${warning}`;
|
|
487
|
+
if (this.runtimeWarnings.has(message)) return;
|
|
488
|
+
this.runtimeWarnings.add(message);
|
|
489
|
+
ctx.ui.notify(message, "warning");
|
|
490
|
+
},
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
if (!this.isLive(generation, combined)) {
|
|
494
|
+
await this.disposeThread(created);
|
|
495
|
+
thread = created;
|
|
496
|
+
return abortNow("startup");
|
|
497
|
+
}
|
|
498
|
+
provisionalThread = created;
|
|
499
|
+
thread = created;
|
|
500
|
+
thread.pendingTurns += 1;
|
|
501
|
+
reservedThread = thread;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let resumePrompt: string | undefined;
|
|
505
|
+
if (
|
|
506
|
+
continuing &&
|
|
507
|
+
thread &&
|
|
508
|
+
(thread.lastAssistantMessageAt === undefined ||
|
|
509
|
+
this.now() - thread.lastAssistantMessageAt >= SUBAGENT_HOT_WINDOW_MS)
|
|
510
|
+
) {
|
|
511
|
+
phase = "startup";
|
|
512
|
+
fanOut({ ...active.snapshot, status: "starting", phase: "startup", agent, threadId: thread.id }, true);
|
|
513
|
+
const oldResource = thread.resource;
|
|
514
|
+
provisionalResource = await createSubagentSessionResource(thread.sessionInputs, combined);
|
|
515
|
+
if (!this.isLive(generation, combined) || thread.disposed || this.threads.get(thread.id) !== thread) {
|
|
516
|
+
await provisionalResource.dispose();
|
|
517
|
+
provisionalResource = undefined;
|
|
518
|
+
if (this.threads.get(thread.id) === thread) this.threads.delete(thread.id);
|
|
519
|
+
await this.disposeThread(thread);
|
|
520
|
+
return abortNow("startup");
|
|
521
|
+
}
|
|
522
|
+
thread.resource = provisionalResource;
|
|
523
|
+
provisionalResource = undefined;
|
|
524
|
+
await oldResource.dispose();
|
|
525
|
+
if (!this.isLive(generation, combined)) {
|
|
526
|
+
if (this.threads.get(thread.id) === thread) this.threads.delete(thread.id);
|
|
527
|
+
await this.disposeThread(thread);
|
|
528
|
+
return abortNow("startup");
|
|
529
|
+
}
|
|
530
|
+
resumePrompt = buildColdResumePrompt({
|
|
531
|
+
definition: thread.definition,
|
|
532
|
+
state: thread.resumeState,
|
|
533
|
+
followUp: task,
|
|
534
|
+
hasAutoreadFiles: (options.files?.length ?? 0) > 0,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const result = await runSubagentTurn({
|
|
539
|
+
thread,
|
|
540
|
+
task,
|
|
541
|
+
files: options.files,
|
|
542
|
+
invocationId,
|
|
543
|
+
initial: thread.turns === 0 || resumePrompt !== undefined,
|
|
544
|
+
...(resumePrompt === undefined ? {} : { resumePrompt }),
|
|
545
|
+
signal: combined,
|
|
546
|
+
onUpdate: (details) => {
|
|
547
|
+
fanOut({ ...details, invocationId, threadId: thread?.id ?? threadId });
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
if (!this.isLive(generation, combined)) {
|
|
552
|
+
const details = {
|
|
553
|
+
...result.details,
|
|
554
|
+
status: "aborted" as const,
|
|
555
|
+
invocationId,
|
|
556
|
+
error: result.details.error ?? "Subagent session reset",
|
|
557
|
+
};
|
|
558
|
+
fanOut(details, true);
|
|
559
|
+
this.releaseReservation(reservationToken);
|
|
560
|
+
reservationToken = undefined;
|
|
561
|
+
if (provisionalThread) {
|
|
562
|
+
await this.disposeThread(provisionalThread);
|
|
563
|
+
provisionalThread = undefined;
|
|
564
|
+
} else if (thread && this.threads.get(thread.id) === thread) {
|
|
565
|
+
this.threads.delete(thread.id);
|
|
566
|
+
await this.disposeThread(thread);
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
content: [{ type: "text", text: details.error ?? "aborted" }],
|
|
570
|
+
details,
|
|
571
|
+
...(result.usage === undefined ? {} : { usage: result.usage }),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
lastPublishedStatus = result.details.status;
|
|
576
|
+
if (result.retainable) {
|
|
577
|
+
thread.resumeState = retainSubagentTurn(thread.resumeState, {
|
|
578
|
+
task,
|
|
579
|
+
outcome: result.terminalOutcome,
|
|
580
|
+
terminalText: result.content,
|
|
581
|
+
files: options.files ?? [],
|
|
582
|
+
});
|
|
583
|
+
thread.lastAssistantMessageAt = result.assistantMessageEndAt.at(-1);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Drop reservation before publish so capacity never reads above 16.
|
|
587
|
+
this.releaseReservation(reservationToken);
|
|
588
|
+
reservationToken = undefined;
|
|
589
|
+
const retained = await this.finalizeThreadRetention({
|
|
590
|
+
generation,
|
|
591
|
+
continuing,
|
|
592
|
+
thread,
|
|
593
|
+
provisional: provisionalThread === thread,
|
|
594
|
+
result,
|
|
595
|
+
});
|
|
596
|
+
provisionalThread = undefined;
|
|
597
|
+
|
|
598
|
+
if (!retained) {
|
|
599
|
+
return {
|
|
600
|
+
content: [{ type: "text", text: result.content }],
|
|
601
|
+
details: { ...result.details, displayName: thread.displayName, invocationId, threadId: thread.id },
|
|
602
|
+
...(result.usage === undefined ? {} : { usage: result.usage }),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
content: [
|
|
608
|
+
{
|
|
609
|
+
type: "text",
|
|
610
|
+
text: `Thread: ${thread.id}\nReuse with subagent({ thread: "${thread.id}", task: "..." })\n\n${result.content}`,
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
details: { ...result.details, displayName: thread.displayName, invocationId, threadId: thread.id },
|
|
614
|
+
...(result.usage === undefined ? {} : { usage: result.usage }),
|
|
615
|
+
};
|
|
616
|
+
} catch (error) {
|
|
617
|
+
const message = error instanceof Error ? error.message : `Agent ${agent} ${phase} failed`;
|
|
618
|
+
const status: SubagentLifecycle = combined.aborted ? "aborted" : "failed";
|
|
619
|
+
const details = baseDetails({
|
|
620
|
+
agent,
|
|
621
|
+
task,
|
|
622
|
+
status,
|
|
623
|
+
phase,
|
|
624
|
+
model: thread?.model ?? parentModel,
|
|
625
|
+
thinkingLevel: thread?.thinkingLevel ?? parentThinking,
|
|
626
|
+
displayName: thread?.displayName ?? displayName,
|
|
627
|
+
threadId: thread?.id ?? threadId,
|
|
628
|
+
invocationId,
|
|
629
|
+
error: message,
|
|
630
|
+
});
|
|
631
|
+
fanOut(details, true);
|
|
632
|
+
if (provisionalResource) {
|
|
633
|
+
await provisionalResource.dispose().catch(() => undefined);
|
|
634
|
+
provisionalResource = undefined;
|
|
635
|
+
}
|
|
636
|
+
if (provisionalThread) {
|
|
637
|
+
await this.disposeThread(provisionalThread);
|
|
638
|
+
provisionalThread = undefined;
|
|
639
|
+
} else if (thread && continuing && this.threads.get(thread.id) === thread) {
|
|
640
|
+
this.threads.delete(thread.id);
|
|
641
|
+
await this.disposeThread(thread);
|
|
642
|
+
}
|
|
643
|
+
return { content: [{ type: "text", text: message }], details };
|
|
644
|
+
} finally {
|
|
645
|
+
if (!admitAdvanced) this.advanceAdmitTicket(admitTicket);
|
|
646
|
+
this.releaseReservation(reservationToken);
|
|
647
|
+
if (provisionalThread && this.threads.get(provisionalThread.id) !== provisionalThread) {
|
|
648
|
+
await this.disposeThread(provisionalThread).catch(() => undefined);
|
|
649
|
+
}
|
|
650
|
+
if (provisionalResource) await provisionalResource.dispose().catch(() => undefined);
|
|
651
|
+
if (reservedThread) reservedThread.pendingTurns = Math.max(0, reservedThread.pendingTurns - 1);
|
|
652
|
+
releaseGlobal?.();
|
|
653
|
+
releaseThread?.();
|
|
654
|
+
this.controllers.delete(controller);
|
|
655
|
+
this.invocations.delete(invocationId);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private async waitAdmitTicket(ticket: number, signal: AbortSignal, generation: number): Promise<boolean> {
|
|
660
|
+
if (signal.aborted || generation !== this.generation || this.disposed || this.lifecycleFence) {
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
if (this.admitHead === ticket) return true;
|
|
664
|
+
return new Promise<boolean>((resolve) => {
|
|
665
|
+
const wake = () => {
|
|
666
|
+
if (signal.aborted || generation !== this.generation || this.disposed || this.lifecycleFence) {
|
|
667
|
+
cleanup();
|
|
668
|
+
resolve(false);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (this.admitHead === ticket) {
|
|
672
|
+
cleanup();
|
|
673
|
+
resolve(true);
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
const onAbort = () => wake();
|
|
677
|
+
const cleanup = () => {
|
|
678
|
+
signal.removeEventListener("abort", onAbort);
|
|
679
|
+
const waiters = this.admitWaiters.get(ticket);
|
|
680
|
+
if (!waiters) return;
|
|
681
|
+
waiters.delete(wake);
|
|
682
|
+
if (waiters.size === 0) this.admitWaiters.delete(ticket);
|
|
683
|
+
};
|
|
684
|
+
const waiters = this.admitWaiters.get(ticket) ?? new Set<() => void>();
|
|
685
|
+
waiters.add(wake);
|
|
686
|
+
this.admitWaiters.set(ticket, waiters);
|
|
687
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
688
|
+
wake();
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private advanceAdmitTicket(ticket: number): void {
|
|
693
|
+
if (ticket !== this.admitHead) {
|
|
694
|
+
// Out-of-order completion: still must not stall the head forever.
|
|
695
|
+
// Mark by bumping only when head matches; otherwise queue skip via recursive advance when head reaches us.
|
|
696
|
+
// Store skipped tickets.
|
|
697
|
+
this.skippedAdmitTickets.add(ticket);
|
|
698
|
+
this.drainAdmitSkips();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
this.admitHead += 1;
|
|
702
|
+
this.drainAdmitSkips();
|
|
703
|
+
const waiters = this.admitWaiters.get(this.admitHead);
|
|
704
|
+
if (waiters) for (const wake of waiters) wake();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private readonly skippedAdmitTickets = new Set<number>();
|
|
708
|
+
|
|
709
|
+
private drainAdmitSkips(): void {
|
|
710
|
+
while (this.skippedAdmitTickets.has(this.admitHead)) {
|
|
711
|
+
this.skippedAdmitTickets.delete(this.admitHead);
|
|
712
|
+
this.admitHead += 1;
|
|
713
|
+
const waiters = this.admitWaiters.get(this.admitHead);
|
|
714
|
+
if (waiters) for (const wake of waiters) wake();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private wakeAllAdmitWaiters(): void {
|
|
719
|
+
for (const waiters of this.admitWaiters.values()) for (const wake of waiters) wake();
|
|
720
|
+
this.admitWaiters.clear();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private async finalizeThreadRetention(options: {
|
|
724
|
+
generation: number;
|
|
725
|
+
continuing: boolean;
|
|
726
|
+
thread: TrackedThread;
|
|
727
|
+
provisional: boolean;
|
|
728
|
+
result: { details: SubagentDetails; retainable: boolean };
|
|
729
|
+
}): Promise<boolean> {
|
|
730
|
+
const { generation, continuing, thread, provisional, result } = options;
|
|
731
|
+
if (generation !== this.generation || this.disposed) {
|
|
732
|
+
if (this.threads.get(thread.id) === thread) this.threads.delete(thread.id);
|
|
733
|
+
await this.disposeThread(thread);
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
if (provisional) {
|
|
737
|
+
if (result.details.status === "completed" && result.retainable) {
|
|
738
|
+
this.threads.set(thread.id, thread);
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
await this.disposeThread(thread);
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
if (!continuing) return this.threads.has(thread.id);
|
|
745
|
+
if (result.retainable && !thread.disposed) return true;
|
|
746
|
+
if (this.threads.get(thread.id) === thread) this.threads.delete(thread.id);
|
|
747
|
+
await this.disposeThread(thread);
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private reserveFreshCapacity(): { ok: true; token: symbol; evicted?: TrackedThread } | { ok: false; error: string } {
|
|
752
|
+
if (this.threads.size + this.startupReservations.size < MAX_RETAINED_THREADS) {
|
|
753
|
+
const token = Symbol("startup-reservation");
|
|
754
|
+
this.startupReservations.add(token);
|
|
755
|
+
return { ok: true, token };
|
|
756
|
+
}
|
|
757
|
+
const evicted = [...this.threads.values()]
|
|
758
|
+
.filter((item) => item.pendingTurns === 0 && !item.disposed)
|
|
759
|
+
.sort((a, b) => a.lastUsedAt - b.lastUsedAt)[0];
|
|
760
|
+
if (!evicted) {
|
|
761
|
+
return {
|
|
762
|
+
ok: false,
|
|
763
|
+
error: "Subagent thread limit reached while all retained threads are busy",
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
this.threads.delete(evicted.id);
|
|
767
|
+
const token = Symbol("startup-reservation");
|
|
768
|
+
this.startupReservations.add(token);
|
|
769
|
+
return { ok: true, token, evicted };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private assignDisplayName(definition: AgentDefinition): string {
|
|
773
|
+
const ordinal = this.nameOrdinals.get(definition.name) ?? 0;
|
|
774
|
+
this.nameOrdinals.set(definition.name, ordinal + 1);
|
|
775
|
+
const base = definition.names[ordinal % definition.names.length] ?? definition.name;
|
|
776
|
+
let cycle = Math.floor(ordinal / definition.names.length) + 1;
|
|
777
|
+
let candidate = cycle === 1 ? base : `${base}-${cycle}`;
|
|
778
|
+
while (this.assignedNames.has(candidate)) {
|
|
779
|
+
cycle += 1;
|
|
780
|
+
candidate = `${base}-${cycle}`;
|
|
781
|
+
}
|
|
782
|
+
this.assignedNames.add(candidate);
|
|
783
|
+
return candidate;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private releaseReservation(token: symbol | undefined): void {
|
|
787
|
+
if (!token) return;
|
|
788
|
+
this.startupReservations.delete(token);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private isLive(generation: number, signal: AbortSignal): boolean {
|
|
792
|
+
return !this.disposed && !this.lifecycleFence && generation === this.generation && !signal.aborted;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private terminalFromAbort(
|
|
796
|
+
agent: string,
|
|
797
|
+
displayName: string,
|
|
798
|
+
task: string,
|
|
799
|
+
phase: SubagentPhase,
|
|
800
|
+
model: string,
|
|
801
|
+
thinkingLevel: string,
|
|
802
|
+
threadId: string | undefined,
|
|
803
|
+
invocationId: string,
|
|
804
|
+
): SubagentDetails {
|
|
805
|
+
return baseDetails({
|
|
806
|
+
agent,
|
|
807
|
+
displayName,
|
|
808
|
+
task,
|
|
809
|
+
status: "aborted",
|
|
810
|
+
phase,
|
|
811
|
+
model,
|
|
812
|
+
thinkingLevel,
|
|
813
|
+
threadId,
|
|
814
|
+
invocationId,
|
|
815
|
+
error: "Subagent call aborted",
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private publish(snapshot: SubagentInvocationSnapshot): void {
|
|
820
|
+
const immutable = cloneInvocationSnapshot(snapshot);
|
|
821
|
+
for (const observer of this.observers) {
|
|
822
|
+
try {
|
|
823
|
+
observer(cloneInvocationSnapshot(immutable));
|
|
824
|
+
} catch {
|
|
825
|
+
// Observers must not break the runtime.
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private abortAllControllers(): void {
|
|
831
|
+
for (const controller of this.controllers) controller.abort();
|
|
832
|
+
this.controllers.clear();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private async disposeThread(thread: TrackedThread): Promise<void> {
|
|
836
|
+
if (thread.disposePromise) return thread.disposePromise;
|
|
837
|
+
thread.disposed = true;
|
|
838
|
+
thread.disposePromise = disposeSubagentThread(thread).catch(() => undefined);
|
|
839
|
+
return thread.disposePromise;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private async disposeAllThreads(): Promise<void> {
|
|
843
|
+
const retained = [...this.threads.values()];
|
|
844
|
+
this.threads.clear();
|
|
845
|
+
await Promise.all(retained.map((thread) => this.disposeThread(thread)));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private runLifecycle(work: () => Promise<void>): Promise<void> {
|
|
849
|
+
const next = this.lifecycleChain.then(work, work);
|
|
850
|
+
this.lifecycleChain = next.then(
|
|
851
|
+
() => undefined,
|
|
852
|
+
() => undefined,
|
|
853
|
+
);
|
|
854
|
+
return next;
|
|
855
|
+
}
|
|
856
|
+
}
|