@pi-unipi/subagents 2.4.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/agent-manager.d.ts +81 -0
- package/dist/agent-manager.d.ts.map +1 -0
- package/dist/agent-manager.js +292 -0
- package/dist/agent-manager.js.map +1 -0
- package/dist/agent-runner.d.ts +51 -0
- package/dist/agent-runner.d.ts.map +1 -0
- package/dist/agent-runner.js +262 -0
- package/dist/agent-runner.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +132 -0
- package/dist/config.js.map +1 -0
- package/dist/conversation-viewer.d.ts +40 -0
- package/dist/conversation-viewer.d.ts.map +1 -0
- package/dist/conversation-viewer.js +276 -0
- package/dist/conversation-viewer.js.map +1 -0
- package/dist/core-compat.d.ts +14 -0
- package/dist/core-compat.d.ts.map +1 -0
- package/dist/core-compat.js +24 -0
- package/dist/core-compat.js.map +1 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.d.ts.map +1 -0
- package/dist/custom-agents.js +106 -0
- package/dist/custom-agents.js.map +1 -0
- package/dist/file-lock.d.ts +42 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +91 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +751 -0
- package/dist/index.js.map +1 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.d.ts.map +1 -0
- package/dist/model-resolver.js +61 -0
- package/dist/model-resolver.js.map +1 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +47 -0
- package/dist/types.js.map +1 -0
- package/dist/widget.d.ts +56 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widget.js +396 -0
- package/dist/widget.js.map +1 -0
- package/package.json +10 -6
- package/src/__tests__/badge-generation.test.ts +0 -315
- package/src/__tests__/config.test.ts +0 -240
- package/src/__tests__/esc-propagation.test.ts +0 -162
- package/src/__tests__/file-lock.test.ts +0 -244
- package/src/__tests__/shutdown-stale-ctx.test.ts +0 -185
- package/src/__tests__/workflow-integration.test.ts +0 -334
- package/src/agent-manager.ts +0 -334
- package/src/agent-runner.ts +0 -329
- package/src/config.ts +0 -147
- package/src/conversation-viewer.ts +0 -299
- package/src/custom-agents.ts +0 -118
- package/src/file-lock.ts +0 -102
- package/src/index.ts +0 -862
- package/src/model-resolver.ts +0 -79
- package/src/prompts.ts +0 -39
- package/src/skills/explore/SKILL.md +0 -32
- package/src/skills/work/SKILL.md +0 -40
- package/src/types.ts +0 -146
- package/src/widget.ts +0 -454
- package/tsconfig.json +0 -19
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test: Workflow integration — `/unipi:work` with subagent support
|
|
3
|
-
*
|
|
4
|
-
* Verifies:
|
|
5
|
-
* - spawn_helper and get_helper_result tools are properly defined
|
|
6
|
-
* - Agent types (explore, work) are correctly configured
|
|
7
|
-
* - Concurrency limit is respected
|
|
8
|
-
* - Custom agent type loading works
|
|
9
|
-
* - System prompt builder generates correct prompts
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { describe, it } from "node:test";
|
|
13
|
-
import assert from "node:assert/strict";
|
|
14
|
-
|
|
15
|
-
// Test type definitions
|
|
16
|
-
const BUILTIN_TYPES = ["explore", "work"] as const;
|
|
17
|
-
|
|
18
|
-
interface AgentConfig {
|
|
19
|
-
name: string;
|
|
20
|
-
displayName?: string;
|
|
21
|
-
description: string;
|
|
22
|
-
builtinToolNames?: string[];
|
|
23
|
-
disallowedTools?: string[];
|
|
24
|
-
extensions: true | string[] | false;
|
|
25
|
-
skills: true | string[] | false;
|
|
26
|
-
model?: string;
|
|
27
|
-
thinking?: string;
|
|
28
|
-
maxTurns?: number;
|
|
29
|
-
systemPrompt: string;
|
|
30
|
-
promptMode: "replace" | "append";
|
|
31
|
-
inheritContext?: boolean;
|
|
32
|
-
runInBackground?: boolean;
|
|
33
|
-
isolated?: boolean;
|
|
34
|
-
memory?: string;
|
|
35
|
-
isDefault?: boolean;
|
|
36
|
-
enabled?: boolean;
|
|
37
|
-
source?: "builtin" | "project" | "global";
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Test prompt builder
|
|
41
|
-
function buildAgentPrompt(
|
|
42
|
-
config: AgentConfig,
|
|
43
|
-
cwd: string,
|
|
44
|
-
env: { isGitRepo: boolean; branch: string; platform: string },
|
|
45
|
-
parentSystemPrompt: string,
|
|
46
|
-
): string {
|
|
47
|
-
if (config.promptMode === "append") {
|
|
48
|
-
return [
|
|
49
|
-
parentSystemPrompt,
|
|
50
|
-
"",
|
|
51
|
-
"---",
|
|
52
|
-
"",
|
|
53
|
-
`## Agent Role: ${config.displayName ?? config.name}`,
|
|
54
|
-
config.systemPrompt,
|
|
55
|
-
].join("\n");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return [
|
|
59
|
-
`# ${config.displayName ?? config.name}`,
|
|
60
|
-
"",
|
|
61
|
-
config.systemPrompt,
|
|
62
|
-
"",
|
|
63
|
-
"---",
|
|
64
|
-
"",
|
|
65
|
-
`Working directory: ${cwd}`,
|
|
66
|
-
`Git: ${env.isGitRepo ? `${env.branch} on ${env.platform}` : "not a git repo"}`,
|
|
67
|
-
].join("\n");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Test concurrency manager
|
|
71
|
-
class ConcurrencyManager {
|
|
72
|
-
private maxConcurrent: number;
|
|
73
|
-
private running: number = 0;
|
|
74
|
-
private queue: Array<{ id: string; resolve: () => void }> = [];
|
|
75
|
-
|
|
76
|
-
constructor(maxConcurrent: number) {
|
|
77
|
-
this.maxConcurrent = maxConcurrent;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async acquire(id: string): Promise<() => void> {
|
|
81
|
-
if (this.running >= this.maxConcurrent) {
|
|
82
|
-
await new Promise<void>((resolve) => {
|
|
83
|
-
this.queue.push({ id, resolve });
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
this.running++;
|
|
88
|
-
|
|
89
|
-
let released = false;
|
|
90
|
-
return () => {
|
|
91
|
-
if (released) return;
|
|
92
|
-
released = true;
|
|
93
|
-
this.running--;
|
|
94
|
-
|
|
95
|
-
// Start next in queue
|
|
96
|
-
if (this.queue.length > 0) {
|
|
97
|
-
const next = this.queue.shift()!;
|
|
98
|
-
next.resolve();
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
getRunning(): number {
|
|
104
|
-
return this.running;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
getQueueLength(): number {
|
|
108
|
-
return this.queue.length;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
describe("Workflow Integration", () => {
|
|
113
|
-
describe("Tool Definitions", () => {
|
|
114
|
-
it("should define spawn_helper tool with correct parameters", () => {
|
|
115
|
-
const toolDef = {
|
|
116
|
-
name: "spawn_helper",
|
|
117
|
-
description: "Launch a sub-agent for parallel work.",
|
|
118
|
-
parameters: {
|
|
119
|
-
type: "object",
|
|
120
|
-
required: ["type", "prompt", "description"],
|
|
121
|
-
properties: {
|
|
122
|
-
type: { type: "string" },
|
|
123
|
-
prompt: { type: "string" },
|
|
124
|
-
description: { type: "string" },
|
|
125
|
-
run_in_background: { type: "boolean" },
|
|
126
|
-
max_turns: { type: "number" },
|
|
127
|
-
model: { type: "string" },
|
|
128
|
-
thinking: { type: "string" },
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
assert.equal(toolDef.name, "spawn_helper");
|
|
134
|
-
assert.equal(toolDef.parameters.required.length, 3);
|
|
135
|
-
assert.ok(toolDef.parameters.properties.type);
|
|
136
|
-
assert.ok(toolDef.parameters.properties.prompt);
|
|
137
|
-
assert.ok(toolDef.parameters.properties.description);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("should define get_helper_result tool with correct parameters", () => {
|
|
141
|
-
const toolDef = {
|
|
142
|
-
name: "get_helper_result",
|
|
143
|
-
description: "Check status and retrieve results from a background agent.",
|
|
144
|
-
parameters: {
|
|
145
|
-
type: "object",
|
|
146
|
-
required: ["agent_id"],
|
|
147
|
-
properties: {
|
|
148
|
-
agent_id: { type: "string" },
|
|
149
|
-
wait: { type: "boolean" },
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
assert.equal(toolDef.name, "get_helper_result");
|
|
155
|
-
assert.equal(toolDef.parameters.required.length, 1);
|
|
156
|
-
assert.ok(toolDef.parameters.properties.agent_id);
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
describe("Agent Types", () => {
|
|
161
|
-
it("should have explore and work as builtin types", () => {
|
|
162
|
-
assert.deepEqual(BUILTIN_TYPES, ["explore", "work"]);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("should define explore agent with read-only tools", () => {
|
|
166
|
-
const exploreConfig: AgentConfig = {
|
|
167
|
-
name: "explore",
|
|
168
|
-
description: "Fast parallel codebase exploration",
|
|
169
|
-
builtinToolNames: ["read", "bash", "grep", "find", "ls"],
|
|
170
|
-
systemPrompt: "You are a read-only exploration agent.",
|
|
171
|
-
promptMode: "replace",
|
|
172
|
-
extensions: true,
|
|
173
|
-
skills: true,
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
assert.ok(!exploreConfig.builtinToolNames?.includes("write"));
|
|
177
|
-
assert.ok(!exploreConfig.builtinToolNames?.includes("edit"));
|
|
178
|
-
assert.ok(exploreConfig.builtinToolNames?.includes("read"));
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("should define work agent with read-write tools", () => {
|
|
182
|
-
const workConfig: AgentConfig = {
|
|
183
|
-
name: "work",
|
|
184
|
-
description: "Parallel file writes with transparent locking",
|
|
185
|
-
builtinToolNames: ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
|
186
|
-
systemPrompt: "You are a read-write work agent.",
|
|
187
|
-
promptMode: "replace",
|
|
188
|
-
extensions: true,
|
|
189
|
-
skills: true,
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
assert.ok(workConfig.builtinToolNames?.includes("write"));
|
|
193
|
-
assert.ok(workConfig.builtinToolNames?.includes("edit"));
|
|
194
|
-
assert.ok(workConfig.builtinToolNames?.includes("read"));
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
describe("System Prompt Builder", () => {
|
|
199
|
-
const env = {
|
|
200
|
-
isGitRepo: true,
|
|
201
|
-
branch: "main",
|
|
202
|
-
platform: "GitHub",
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
it("should build replace mode prompt", () => {
|
|
206
|
-
const config: AgentConfig = {
|
|
207
|
-
name: "explore",
|
|
208
|
-
displayName: "Explorer",
|
|
209
|
-
description: "Test",
|
|
210
|
-
systemPrompt: "Find all authentication files.",
|
|
211
|
-
promptMode: "replace",
|
|
212
|
-
extensions: true,
|
|
213
|
-
skills: true,
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt");
|
|
217
|
-
|
|
218
|
-
assert.ok(prompt.startsWith("# Explorer"));
|
|
219
|
-
assert.ok(prompt.includes("Find all authentication files."));
|
|
220
|
-
assert.ok(prompt.includes("Working directory: /workspace"));
|
|
221
|
-
assert.ok(!prompt.includes("Parent prompt"));
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it("should build append mode prompt", () => {
|
|
225
|
-
const config: AgentConfig = {
|
|
226
|
-
name: "work",
|
|
227
|
-
displayName: "Worker",
|
|
228
|
-
description: "Test",
|
|
229
|
-
systemPrompt: "Refactor the auth module.",
|
|
230
|
-
promptMode: "append",
|
|
231
|
-
extensions: true,
|
|
232
|
-
skills: true,
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt");
|
|
236
|
-
|
|
237
|
-
assert.ok(prompt.startsWith("Parent prompt"));
|
|
238
|
-
assert.ok(prompt.includes("## Agent Role: Worker"));
|
|
239
|
-
assert.ok(prompt.includes("Refactor the auth module."));
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
describe("Concurrency Limit", () => {
|
|
244
|
-
it("should respect max concurrent limit", async () => {
|
|
245
|
-
const manager = new ConcurrencyManager(2);
|
|
246
|
-
const events: string[] = [];
|
|
247
|
-
|
|
248
|
-
// Start 3 agents, only 2 should run immediately
|
|
249
|
-
const release1 = await manager.acquire("agent-1");
|
|
250
|
-
events.push("agent-1-started");
|
|
251
|
-
|
|
252
|
-
const release2 = await manager.acquire("agent-2");
|
|
253
|
-
events.push("agent-2-started");
|
|
254
|
-
|
|
255
|
-
assert.equal(manager.getRunning(), 2);
|
|
256
|
-
assert.deepEqual(events, ["agent-1-started", "agent-2-started"]);
|
|
257
|
-
|
|
258
|
-
// Third agent should queue
|
|
259
|
-
const acquire3Promise = manager.acquire("agent-3").then((release) => {
|
|
260
|
-
events.push("agent-3-started");
|
|
261
|
-
return release;
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
assert.equal(manager.getQueueLength(), 1);
|
|
265
|
-
|
|
266
|
-
// Release first agent
|
|
267
|
-
release1();
|
|
268
|
-
const release3 = await acquire3Promise;
|
|
269
|
-
|
|
270
|
-
assert.equal(manager.getRunning(), 2);
|
|
271
|
-
assert.deepEqual(events, ["agent-1-started", "agent-2-started", "agent-3-started"]);
|
|
272
|
-
|
|
273
|
-
release2();
|
|
274
|
-
release3();
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("should queue agents in order", async () => {
|
|
278
|
-
const manager = new ConcurrencyManager(1);
|
|
279
|
-
const events: string[] = [];
|
|
280
|
-
|
|
281
|
-
const release1 = await manager.acquire("agent-1");
|
|
282
|
-
events.push("1");
|
|
283
|
-
|
|
284
|
-
const p2 = manager.acquire("agent-2").then(r => { events.push("2"); return r; });
|
|
285
|
-
const p3 = manager.acquire("agent-3").then(r => { events.push("3"); return r; });
|
|
286
|
-
|
|
287
|
-
release1();
|
|
288
|
-
const release2 = await p2;
|
|
289
|
-
release2();
|
|
290
|
-
const release3 = await p3;
|
|
291
|
-
|
|
292
|
-
assert.deepEqual(events, ["1", "2", "3"]);
|
|
293
|
-
release3();
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
describe("Custom Agent Loading", () => {
|
|
298
|
-
it("should parse agent markdown frontmatter", () => {
|
|
299
|
-
const markdown = `---
|
|
300
|
-
name: code-checker
|
|
301
|
-
description: Code quality checker
|
|
302
|
-
tools: read, grep, find, bash
|
|
303
|
-
thinking: high
|
|
304
|
-
---
|
|
305
|
-
You are a code quality checker. Review code for issues.`;
|
|
306
|
-
|
|
307
|
-
// Simple frontmatter parser
|
|
308
|
-
const match = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
309
|
-
assert.ok(match);
|
|
310
|
-
|
|
311
|
-
const frontmatter = match![1];
|
|
312
|
-
const body = match![2];
|
|
313
|
-
|
|
314
|
-
assert.ok(frontmatter.includes("name: code-checker"));
|
|
315
|
-
assert.ok(frontmatter.includes("tools: read, grep, find, bash"));
|
|
316
|
-
assert.ok(body.includes("You are a code quality checker."));
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("should validate required fields", () => {
|
|
320
|
-
const validAgent = {
|
|
321
|
-
name: "test-agent",
|
|
322
|
-
description: "Test agent",
|
|
323
|
-
systemPrompt: "Do something.",
|
|
324
|
-
promptMode: "replace" as const,
|
|
325
|
-
extensions: true as const,
|
|
326
|
-
skills: true as const,
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
assert.ok(validAgent.name);
|
|
330
|
-
assert.ok(validAgent.description);
|
|
331
|
-
assert.ok(validAgent.systemPrompt);
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
});
|
package/src/agent-manager.ts
DELETED
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/subagents — Agent manager
|
|
3
|
-
*
|
|
4
|
-
* Tracks agents, manages concurrency queue, handles spawn/resume/abort.
|
|
5
|
-
* Background agents subject to concurrency limit. Foreground bypass queue.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
9
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
10
|
-
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { runAgent, type ToolActivity } from "./agent-runner.js";
|
|
12
|
-
import { resolveModel, type ModelRegistry } from "./model-resolver.js";
|
|
13
|
-
import type { AgentRecord, AgentConfig, AgentType, ThinkingLevel } from "./types.js";
|
|
14
|
-
import { BUILTIN_CONFIGS } from "./types.js";
|
|
15
|
-
import { loadCustomAgents } from "./custom-agents.js";
|
|
16
|
-
import { FileLock } from "./file-lock.js";
|
|
17
|
-
|
|
18
|
-
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
19
|
-
export type OnAgentStart = (record: AgentRecord) => void;
|
|
20
|
-
|
|
21
|
-
/** Default max concurrent background agents. */
|
|
22
|
-
const DEFAULT_MAX_CONCURRENT = 4;
|
|
23
|
-
|
|
24
|
-
interface SpawnArgs {
|
|
25
|
-
pi: ExtensionAPI;
|
|
26
|
-
ctx: ExtensionContext;
|
|
27
|
-
type: AgentType;
|
|
28
|
-
prompt: string;
|
|
29
|
-
options: SpawnOptions;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface SpawnOptions {
|
|
33
|
-
description: string;
|
|
34
|
-
model?: Model<any>;
|
|
35
|
-
modelInput?: string;
|
|
36
|
-
modelRegistry?: ModelRegistry;
|
|
37
|
-
thinkingLevel?: ThinkingLevel;
|
|
38
|
-
maxTurns?: number;
|
|
39
|
-
isolated?: boolean;
|
|
40
|
-
inheritContext?: boolean;
|
|
41
|
-
isBackground?: boolean;
|
|
42
|
-
onToolActivity?: (activity: ToolActivity) => void;
|
|
43
|
-
onTextDelta?: (delta: string, fullText: string) => void;
|
|
44
|
-
onSessionCreated?: (session: AgentSession) => void;
|
|
45
|
-
onTurnEnd?: (turnCount: number) => void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export class AgentManager {
|
|
49
|
-
private agents = new Map<string, AgentRecord>();
|
|
50
|
-
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
51
|
-
private onComplete?: OnAgentComplete;
|
|
52
|
-
private onStart?: OnAgentStart;
|
|
53
|
-
private maxConcurrent: number;
|
|
54
|
-
private customAgents: Map<string, AgentConfig>;
|
|
55
|
-
|
|
56
|
-
/** Per-file transparent locking for write agents. */
|
|
57
|
-
readonly fileLock = new FileLock();
|
|
58
|
-
|
|
59
|
-
/** Queue of background agents waiting to start. */
|
|
60
|
-
private queue: { id: string; args: SpawnArgs }[] = [];
|
|
61
|
-
/** Number of currently running background agents. */
|
|
62
|
-
private runningBackground = 0;
|
|
63
|
-
|
|
64
|
-
constructor(onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart?: OnAgentStart) {
|
|
65
|
-
this.onComplete = onComplete;
|
|
66
|
-
this.onStart = onStart;
|
|
67
|
-
this.maxConcurrent = maxConcurrent;
|
|
68
|
-
this.customAgents = loadCustomAgents(process.cwd());
|
|
69
|
-
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Get resolved agent config for a type. */
|
|
73
|
-
getAgentConfig(type: AgentType): AgentConfig | undefined {
|
|
74
|
-
return this.customAgents.get(type) ?? BUILTIN_CONFIGS[type];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
setMaxConcurrent(n: number) {
|
|
78
|
-
this.maxConcurrent = Math.max(1, n);
|
|
79
|
-
this.drainQueue();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
getMaxConcurrent(): number {
|
|
83
|
-
return this.maxConcurrent;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Spawn an agent. Returns ID immediately for background, waits for foreground.
|
|
88
|
-
*/
|
|
89
|
-
spawn(
|
|
90
|
-
pi: ExtensionAPI,
|
|
91
|
-
ctx: ExtensionContext,
|
|
92
|
-
type: AgentType,
|
|
93
|
-
prompt: string,
|
|
94
|
-
options: SpawnOptions,
|
|
95
|
-
): string {
|
|
96
|
-
const id = randomUUID().slice(0, 17);
|
|
97
|
-
const abortController = new AbortController();
|
|
98
|
-
const record: AgentRecord = {
|
|
99
|
-
id,
|
|
100
|
-
type,
|
|
101
|
-
description: options.description,
|
|
102
|
-
status: options.isBackground ? "queued" : "running",
|
|
103
|
-
toolUses: 0,
|
|
104
|
-
startedAt: Date.now(),
|
|
105
|
-
abortController,
|
|
106
|
-
lockedFiles: new Set(),
|
|
107
|
-
};
|
|
108
|
-
this.agents.set(id, record);
|
|
109
|
-
|
|
110
|
-
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
111
|
-
|
|
112
|
-
if (options.isBackground && this.runningBackground >= this.maxConcurrent) {
|
|
113
|
-
this.queue.push({ id, args });
|
|
114
|
-
return id;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
this.startAgent(id, record, args);
|
|
118
|
-
return id;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Actually start an agent. */
|
|
122
|
-
private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
|
|
123
|
-
record.status = "running";
|
|
124
|
-
record.startedAt = Date.now();
|
|
125
|
-
if (options.isBackground) this.runningBackground++;
|
|
126
|
-
this.onStart?.(record);
|
|
127
|
-
|
|
128
|
-
// Resolve model: explicit input > config model > parent model
|
|
129
|
-
let model = options.model;
|
|
130
|
-
if (options.modelInput && options.modelRegistry) {
|
|
131
|
-
const resolved = resolveModel(options.modelInput, options.modelRegistry);
|
|
132
|
-
if (typeof resolved === "string") {
|
|
133
|
-
// Error message — return early with error
|
|
134
|
-
record.status = "error";
|
|
135
|
-
record.error = resolved;
|
|
136
|
-
record.completedAt = Date.now();
|
|
137
|
-
if (options.isBackground) {
|
|
138
|
-
this.runningBackground--;
|
|
139
|
-
this.onComplete?.(record);
|
|
140
|
-
}
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
model = resolved;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const agentConfig = this.getAgentConfig(type);
|
|
147
|
-
const promise = runAgent(ctx, type, prompt, {
|
|
148
|
-
pi,
|
|
149
|
-
model,
|
|
150
|
-
agentConfig,
|
|
151
|
-
maxTurns: options.maxTurns,
|
|
152
|
-
isolated: options.isolated,
|
|
153
|
-
inheritContext: options.inheritContext,
|
|
154
|
-
thinkingLevel: options.thinkingLevel,
|
|
155
|
-
signal: record.abortController!.signal,
|
|
156
|
-
onToolActivity: (activity) => {
|
|
157
|
-
if (activity.type === "end") record.toolUses++;
|
|
158
|
-
options.onToolActivity?.(activity);
|
|
159
|
-
},
|
|
160
|
-
onTurnEnd: options.onTurnEnd,
|
|
161
|
-
onTextDelta: options.onTextDelta,
|
|
162
|
-
onSessionCreated: (session) => {
|
|
163
|
-
record.session = session;
|
|
164
|
-
options.onSessionCreated?.(session);
|
|
165
|
-
},
|
|
166
|
-
})
|
|
167
|
-
.then(({ responseText, session, aborted, steered }) => {
|
|
168
|
-
if (record.status !== "stopped") {
|
|
169
|
-
record.status = aborted ? "aborted" : steered ? "completed" : "completed";
|
|
170
|
-
}
|
|
171
|
-
record.result = responseText;
|
|
172
|
-
record.session = session;
|
|
173
|
-
record.completedAt ??= Date.now();
|
|
174
|
-
|
|
175
|
-
// Release any held file locks
|
|
176
|
-
this.fileLock.releaseAll(id);
|
|
177
|
-
record.lockedFiles.clear();
|
|
178
|
-
|
|
179
|
-
if (options.isBackground) {
|
|
180
|
-
this.runningBackground--;
|
|
181
|
-
this.onComplete?.(record);
|
|
182
|
-
this.drainQueue();
|
|
183
|
-
}
|
|
184
|
-
return responseText;
|
|
185
|
-
})
|
|
186
|
-
.catch((err) => {
|
|
187
|
-
if (record.status !== "stopped") {
|
|
188
|
-
record.status = "error";
|
|
189
|
-
}
|
|
190
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
191
|
-
record.completedAt ??= Date.now();
|
|
192
|
-
|
|
193
|
-
// Release any held file locks
|
|
194
|
-
this.fileLock.releaseAll(id);
|
|
195
|
-
record.lockedFiles.clear();
|
|
196
|
-
|
|
197
|
-
if (options.isBackground) {
|
|
198
|
-
this.runningBackground--;
|
|
199
|
-
this.onComplete?.(record);
|
|
200
|
-
this.drainQueue();
|
|
201
|
-
}
|
|
202
|
-
return "";
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
record.promise = promise;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Start queued agents up to concurrency limit. */
|
|
209
|
-
private drainQueue() {
|
|
210
|
-
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
|
|
211
|
-
const next = this.queue.shift()!;
|
|
212
|
-
const record = this.agents.get(next.id);
|
|
213
|
-
if (!record || record.status !== "queued") continue;
|
|
214
|
-
this.startAgent(next.id, record, next.args);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Spawn and wait (foreground).
|
|
220
|
-
*/
|
|
221
|
-
async spawnAndWait(
|
|
222
|
-
pi: ExtensionAPI,
|
|
223
|
-
ctx: ExtensionContext,
|
|
224
|
-
type: AgentType,
|
|
225
|
-
prompt: string,
|
|
226
|
-
options: Omit<SpawnOptions, "isBackground">,
|
|
227
|
-
): Promise<AgentRecord> {
|
|
228
|
-
const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
|
|
229
|
-
const record = this.agents.get(id)!;
|
|
230
|
-
await record.promise;
|
|
231
|
-
return record;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
getRecord(id: string): AgentRecord | undefined {
|
|
235
|
-
return this.agents.get(id);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
listAgents(): AgentRecord[] {
|
|
239
|
-
return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
abort(id: string): boolean {
|
|
243
|
-
const record = this.agents.get(id);
|
|
244
|
-
if (!record) return false;
|
|
245
|
-
|
|
246
|
-
if (record.status === "queued") {
|
|
247
|
-
this.queue = this.queue.filter((q) => q.id !== id);
|
|
248
|
-
record.status = "stopped";
|
|
249
|
-
record.completedAt = Date.now();
|
|
250
|
-
return true;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (record.status !== "running") return false;
|
|
254
|
-
record.abortController?.abort();
|
|
255
|
-
record.status = "stopped";
|
|
256
|
-
record.completedAt = Date.now();
|
|
257
|
-
this.fileLock.releaseAll(id);
|
|
258
|
-
record.lockedFiles.clear();
|
|
259
|
-
return true;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Abort all agents (for ESC propagation). */
|
|
263
|
-
abortAll(): number {
|
|
264
|
-
let count = 0;
|
|
265
|
-
for (const queued of this.queue) {
|
|
266
|
-
const record = this.agents.get(queued.id);
|
|
267
|
-
if (record) {
|
|
268
|
-
record.status = "stopped";
|
|
269
|
-
record.completedAt = Date.now();
|
|
270
|
-
count++;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
this.queue = [];
|
|
274
|
-
for (const record of this.agents.values()) {
|
|
275
|
-
if (record.status === "running") {
|
|
276
|
-
record.abortController?.abort();
|
|
277
|
-
record.status = "stopped";
|
|
278
|
-
record.completedAt = Date.now();
|
|
279
|
-
count++;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
this.fileLock.clear();
|
|
283
|
-
return count;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Wait for all agents. */
|
|
287
|
-
async waitForAll(): Promise<void> {
|
|
288
|
-
while (true) {
|
|
289
|
-
this.drainQueue();
|
|
290
|
-
const pending = [...this.agents.values()]
|
|
291
|
-
.filter((r) => r.status === "running" || r.status === "queued")
|
|
292
|
-
.map((r) => r.promise)
|
|
293
|
-
.filter(Boolean);
|
|
294
|
-
if (pending.length === 0) break;
|
|
295
|
-
await Promise.allSettled(pending);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** Whether any agents running or queued. */
|
|
300
|
-
hasRunning(): boolean {
|
|
301
|
-
return [...this.agents.values()].some((r) => r.status === "running" || r.status === "queued");
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** Remove completed records. */
|
|
305
|
-
clearCompleted(): void {
|
|
306
|
-
for (const [id, record] of this.agents) {
|
|
307
|
-
if (record.status === "running" || record.status === "queued") continue;
|
|
308
|
-
record.session?.dispose?.();
|
|
309
|
-
record.session = undefined;
|
|
310
|
-
this.agents.delete(id);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private cleanup() {
|
|
315
|
-
const cutoff = Date.now() - 10 * 60_000;
|
|
316
|
-
for (const [id, record] of this.agents) {
|
|
317
|
-
if (record.status === "running" || record.status === "queued") continue;
|
|
318
|
-
if ((record.completedAt ?? 0) >= cutoff) continue;
|
|
319
|
-
record.session?.dispose?.();
|
|
320
|
-
record.session = undefined;
|
|
321
|
-
this.agents.delete(id);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
dispose() {
|
|
326
|
-
clearInterval(this.cleanupInterval);
|
|
327
|
-
this.queue = [];
|
|
328
|
-
this.abortAll();
|
|
329
|
-
for (const record of this.agents.values()) {
|
|
330
|
-
record.session?.dispose();
|
|
331
|
-
}
|
|
332
|
-
this.agents.clear();
|
|
333
|
-
}
|
|
334
|
-
}
|