alvin-bot 4.5.1 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +278 -0
- package/README.md +25 -2
- package/bin/cli.js +325 -26
- package/dist/handlers/commands.js +505 -63
- package/dist/handlers/message.js +209 -14
- package/dist/i18n.js +470 -13
- package/dist/index.js +45 -5
- package/dist/providers/claude-sdk-provider.js +106 -14
- package/dist/providers/ollama-provider.js +32 -0
- package/dist/providers/openai-compatible.js +10 -1
- package/dist/providers/registry.js +112 -17
- package/dist/providers/types.js +25 -3
- package/dist/services/compaction.js +2 -0
- package/dist/services/cron.js +53 -42
- package/dist/services/heartbeat.js +41 -7
- package/dist/services/language-detect.js +12 -2
- package/dist/services/ollama-manager.js +339 -0
- package/dist/services/personality.js +20 -14
- package/dist/services/session.js +21 -3
- package/dist/services/subagent-delivery.js +266 -0
- package/dist/services/subagent-stats.js +123 -0
- package/dist/services/subagents.js +509 -42
- package/dist/services/telegram.js +28 -1
- package/dist/services/updater.js +158 -0
- package/dist/services/usage-tracker.js +11 -4
- package/dist/services/users.js +2 -1
- package/docs/HANDBOOK.md +856 -0
- package/package.json +7 -2
- package/test/claude-sdk-provider.test.ts +69 -0
- package/test/i18n.test.ts +108 -0
- package/test/registry.test.ts +201 -0
- package/test/subagent-delivery.test.ts +273 -0
- package/test/subagent-stats.test.ts +119 -0
- package/test/subagents-commands.test.ts +64 -0
- package/test/subagents-config.test.ts +114 -0
- package/test/subagents-depth.test.ts +58 -0
- package/test/subagents-inheritance.test.ts +67 -0
- package/test/subagents-name-resolver.test.ts +122 -0
- package/test/subagents-priority-reject.test.ts +88 -0
- package/test/subagents-queue.test.ts +127 -0
- package/test/subagents-shutdown.test.ts +126 -0
- package/test/subagents-toolset.test.ts +51 -0
- package/vitest.config.ts +17 -0
|
@@ -6,44 +6,286 @@
|
|
|
6
6
|
* Results are stored and can be retrieved by the caller.
|
|
7
7
|
*/
|
|
8
8
|
import os from "os";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import { resolve, dirname } from "path";
|
|
9
11
|
import crypto from "crypto";
|
|
10
12
|
import { config } from "../config.js";
|
|
13
|
+
// ── File-based config (persistent, runtime-editable) ───────────────────
|
|
14
|
+
const DATA_DIR = process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot");
|
|
15
|
+
const CONFIG_FILE = resolve(DATA_DIR, "sub-agents.json");
|
|
16
|
+
const ABSOLUTE_MAX_AGENTS = 16; // Hard cap no matter what
|
|
17
|
+
const MAX_SUBAGENT_DEPTH = 2; // F2: hard cap on nested spawning
|
|
18
|
+
const DEFAULT_QUEUE_CAP = 20; // D3: default bounded-queue size
|
|
19
|
+
const ABSOLUTE_MAX_QUEUE = 200; // D3: absolute ceiling on queue length
|
|
20
|
+
let configCache = null;
|
|
21
|
+
function isValidVisibility(v) {
|
|
22
|
+
return v === "auto" || v === "banner" || v === "silent" || v === "live";
|
|
23
|
+
}
|
|
24
|
+
function loadSubAgentsConfig() {
|
|
25
|
+
if (configCache)
|
|
26
|
+
return configCache;
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
configCache = {
|
|
31
|
+
maxParallel: typeof parsed.maxParallel === "number" ? parsed.maxParallel : 0,
|
|
32
|
+
visibility: isValidVisibility(parsed.visibility) ? parsed.visibility : "auto",
|
|
33
|
+
queueCap: typeof parsed.queueCap === "number"
|
|
34
|
+
? Math.max(0, Math.min(Math.floor(parsed.queueCap), ABSOLUTE_MAX_QUEUE))
|
|
35
|
+
: DEFAULT_QUEUE_CAP,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// File missing or invalid — seed from env var then default to auto
|
|
40
|
+
configCache = {
|
|
41
|
+
maxParallel: Number(process.env.MAX_SUBAGENTS) || 0,
|
|
42
|
+
visibility: "auto",
|
|
43
|
+
queueCap: DEFAULT_QUEUE_CAP,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return configCache;
|
|
47
|
+
}
|
|
48
|
+
function saveSubAgentsConfig(cfg) {
|
|
49
|
+
try {
|
|
50
|
+
fs.mkdirSync(dirname(CONFIG_FILE), { recursive: true });
|
|
51
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
52
|
+
configCache = cfg;
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
console.error("[subagents] failed to write config:", err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Resolves max parallel agents, interpreting 0 as "auto = cpu cores capped". */
|
|
59
|
+
export function getMaxParallelAgents() {
|
|
60
|
+
const cfg = loadSubAgentsConfig();
|
|
61
|
+
if (cfg.maxParallel === 0) {
|
|
62
|
+
return Math.min(os.cpus().length, ABSOLUTE_MAX_AGENTS);
|
|
63
|
+
}
|
|
64
|
+
return Math.min(Math.max(1, cfg.maxParallel), ABSOLUTE_MAX_AGENTS);
|
|
65
|
+
}
|
|
66
|
+
/** Returns the raw configured value (for display). 0 means "auto". */
|
|
67
|
+
export function getConfiguredMaxParallel() {
|
|
68
|
+
return loadSubAgentsConfig().maxParallel;
|
|
69
|
+
}
|
|
70
|
+
/** Sets max parallel agents. Value is clamped to [0, ABSOLUTE_MAX_AGENTS].
|
|
71
|
+
* Returns the resolved effective value (with auto-expansion if set to 0). */
|
|
72
|
+
export function setMaxParallelAgents(n) {
|
|
73
|
+
const clamped = Math.max(0, Math.min(Math.floor(n), ABSOLUTE_MAX_AGENTS));
|
|
74
|
+
const cfg = loadSubAgentsConfig();
|
|
75
|
+
saveSubAgentsConfig({ ...cfg, maxParallel: clamped });
|
|
76
|
+
return getMaxParallelAgents();
|
|
77
|
+
}
|
|
78
|
+
/** A4: Current default visibility mode for new spawns. */
|
|
79
|
+
export function getVisibility() {
|
|
80
|
+
return loadSubAgentsConfig().visibility;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A4: Set the default visibility mode. Throws if the value is invalid.
|
|
84
|
+
* Writes through to the on-disk config so restart-resilient.
|
|
85
|
+
*/
|
|
86
|
+
export function setVisibility(mode) {
|
|
87
|
+
if (!isValidVisibility(mode)) {
|
|
88
|
+
throw new Error(`Invalid visibility mode "${mode}". Expected: auto | banner | silent | live.`);
|
|
89
|
+
}
|
|
90
|
+
const cfg = loadSubAgentsConfig();
|
|
91
|
+
saveSubAgentsConfig({ ...cfg, visibility: mode });
|
|
92
|
+
}
|
|
93
|
+
/** D3: Current bounded-queue cap. 0 = queue disabled (reject on full pool). */
|
|
94
|
+
export function getQueueCap() {
|
|
95
|
+
return loadSubAgentsConfig().queueCap;
|
|
96
|
+
}
|
|
97
|
+
/** D3: Set the queue cap. Clamped to [0, ABSOLUTE_MAX_QUEUE].
|
|
98
|
+
* Returns the effective value after clamping. */
|
|
99
|
+
export function setQueueCap(n) {
|
|
100
|
+
const clamped = Math.max(0, Math.min(Math.floor(n), ABSOLUTE_MAX_QUEUE));
|
|
101
|
+
const cfg = loadSubAgentsConfig();
|
|
102
|
+
saveSubAgentsConfig({ ...cfg, queueCap: clamped });
|
|
103
|
+
return clamped;
|
|
104
|
+
}
|
|
11
105
|
// ── State ───────────────────────────────────────────────
|
|
12
106
|
const activeAgents = new Map();
|
|
107
|
+
// ── Name resolver (B2) ──────────────────────────────────
|
|
108
|
+
/**
|
|
109
|
+
* Return all currently-tracked agents whose *base* name matches `base`.
|
|
110
|
+
* Base name = the part before any "#N" suffix.
|
|
111
|
+
*/
|
|
112
|
+
function agentsByBaseName(base) {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const entry of activeAgents.values()) {
|
|
115
|
+
const info = entry.info;
|
|
116
|
+
const entryBase = info.name.replace(/#\d+$/, "");
|
|
117
|
+
if (entryBase === base)
|
|
118
|
+
out.push(info);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Given a requested name, return a unique variant. If no collision exists,
|
|
124
|
+
* returns `requested` unchanged (with the base form). Otherwise returns
|
|
125
|
+
* `base#N` with the smallest free N ≥ 2.
|
|
126
|
+
*/
|
|
127
|
+
function resolveAgentName(requested) {
|
|
128
|
+
const base = requested.replace(/#\d+$/, "");
|
|
129
|
+
const siblings = agentsByBaseName(base);
|
|
130
|
+
if (siblings.length === 0)
|
|
131
|
+
return { name: base };
|
|
132
|
+
// Find the smallest free index ≥ 2. The bare base name counts as "#1".
|
|
133
|
+
const takenIndices = new Set();
|
|
134
|
+
for (const s of siblings) {
|
|
135
|
+
const m = s.name.match(/#(\d+)$/);
|
|
136
|
+
if (m)
|
|
137
|
+
takenIndices.add(parseInt(m[1], 10));
|
|
138
|
+
else
|
|
139
|
+
takenIndices.add(1);
|
|
140
|
+
}
|
|
141
|
+
let n = 2;
|
|
142
|
+
while (takenIndices.has(n))
|
|
143
|
+
n++;
|
|
144
|
+
return { name: `${base}#${n}`, index: n };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Public name-resolution API used by /sub-agents cancel / result.
|
|
148
|
+
* - Exact name match wins (e.g. "review#2" finds exactly that entry).
|
|
149
|
+
* - If only one agent matches the base name, returns that one.
|
|
150
|
+
* - If the caller opted into `ambiguousAsList`, returns a disambiguation
|
|
151
|
+
* marker with all candidates instead of a single result.
|
|
152
|
+
*/
|
|
153
|
+
export function findSubAgentByName(name, opts = {}) {
|
|
154
|
+
// An explicit "base#N" query must always resolve to that exact entry,
|
|
155
|
+
// even when the caller opted into ambiguity. Otherwise users who type
|
|
156
|
+
// out the disambiguated form get an unhelpful 'which one?' reply.
|
|
157
|
+
const hasExplicitSuffix = /#\d+$/.test(name);
|
|
158
|
+
if (hasExplicitSuffix) {
|
|
159
|
+
for (const entry of activeAgents.values()) {
|
|
160
|
+
if (entry.info.name === name)
|
|
161
|
+
return { ...entry.info };
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
// No explicit suffix → base-name query. Ambiguity detection runs here
|
|
166
|
+
// when the caller opted in and there are multiple siblings.
|
|
167
|
+
const siblings = agentsByBaseName(name);
|
|
168
|
+
if (siblings.length === 0)
|
|
169
|
+
return null;
|
|
170
|
+
if (opts.ambiguousAsList && siblings.length > 1) {
|
|
171
|
+
return {
|
|
172
|
+
ambiguous: true,
|
|
173
|
+
candidates: siblings.map((s) => ({ ...s })),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// Without ambiguity opt-in, prefer an exact name match over just the
|
|
177
|
+
// first sibling — the bare base name is itself a unique key.
|
|
178
|
+
for (const entry of activeAgents.values()) {
|
|
179
|
+
if (entry.info.name === name)
|
|
180
|
+
return { ...entry.info };
|
|
181
|
+
}
|
|
182
|
+
return { ...siblings[0] };
|
|
183
|
+
}
|
|
13
184
|
// ── Core execution ──────────────────────────────────────
|
|
14
|
-
async function runSubAgent(id, agentConfig, abort) {
|
|
185
|
+
async function runSubAgent(id, agentConfig, abort, resolvedName) {
|
|
15
186
|
const startTime = Date.now();
|
|
16
187
|
const entry = activeAgents.get(id);
|
|
188
|
+
// A4 live-stream state — set up if the effective visibility is "live"
|
|
189
|
+
// AND this is a user spawn with a parent chat. Cron and implicit spawns
|
|
190
|
+
// don't get live-streaming (cron because there's no interactive watcher,
|
|
191
|
+
// implicit because the parent Claude stream already shows everything).
|
|
192
|
+
let liveStream = null;
|
|
193
|
+
const effectiveVisibility = agentConfig.visibility ?? loadSubAgentsConfig().visibility;
|
|
194
|
+
if (effectiveVisibility === "live" &&
|
|
195
|
+
agentConfig.source === "user" &&
|
|
196
|
+
typeof agentConfig.parentChatId === "number") {
|
|
197
|
+
try {
|
|
198
|
+
const { createLiveStream } = await import("./subagent-delivery.js");
|
|
199
|
+
const stream = createLiveStream(agentConfig.parentChatId, resolvedName);
|
|
200
|
+
if (stream) {
|
|
201
|
+
await stream.start();
|
|
202
|
+
if (!stream.failed)
|
|
203
|
+
liveStream = stream;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
console.error(`[subagent ${id}] live-stream init failed:`, err);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
17
210
|
try {
|
|
18
211
|
const { getRegistry } = await import("../engine.js");
|
|
19
212
|
const registry = getRegistry();
|
|
20
|
-
|
|
213
|
+
// C3: inheritCwd (default true) decides whether the parent's working
|
|
214
|
+
// dir flows through. When false, we fall back to the home directory —
|
|
215
|
+
// useful for cron jobs that must run in a well-known root regardless
|
|
216
|
+
// of what the caller was doing.
|
|
217
|
+
const inheritCwd = agentConfig.inheritCwd ?? true;
|
|
218
|
+
const effectiveCwd = inheritCwd
|
|
219
|
+
? agentConfig.workingDir || os.homedir()
|
|
220
|
+
: os.homedir();
|
|
221
|
+
const systemPrompt = `You are a sub-agent named "${resolvedName}". Complete the following task autonomously and report your results clearly when done. Working directory: ${effectiveCwd}`;
|
|
21
222
|
let finalText = "";
|
|
22
223
|
let inputTokens = 0;
|
|
23
224
|
let outputTokens = 0;
|
|
24
225
|
for await (const chunk of registry.queryWithFallback({
|
|
25
226
|
prompt: agentConfig.prompt,
|
|
26
227
|
systemPrompt,
|
|
27
|
-
workingDir:
|
|
228
|
+
workingDir: effectiveCwd,
|
|
28
229
|
effort: "high",
|
|
29
230
|
abortSignal: abort.signal,
|
|
30
231
|
})) {
|
|
31
|
-
if (chunk.type === "text")
|
|
232
|
+
if (chunk.type === "text") {
|
|
32
233
|
finalText = chunk.text || "";
|
|
234
|
+
// A4: push text updates into the throttled live-stream
|
|
235
|
+
if (liveStream && !liveStream.failed) {
|
|
236
|
+
liveStream.update(finalText);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
33
239
|
if (chunk.type === "done") {
|
|
34
240
|
inputTokens = chunk.inputTokens || 0;
|
|
35
241
|
outputTokens = chunk.outputTokens || 0;
|
|
36
242
|
}
|
|
37
243
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
244
|
+
// If cancelAllSubAgents has already taken over (shutdown path), don't
|
|
245
|
+
// overwrite the cancelled result it synthesised. Also: if the generator
|
|
246
|
+
// exited gracefully but the abort signal fired mid-stream (e.g. the
|
|
247
|
+
// provider's queryWithFallback returned `type:error` and we fell out
|
|
248
|
+
// of the loop without throwing), mark the run as cancelled rather
|
|
249
|
+
// than completed — the result output is whatever we buffered.
|
|
250
|
+
if (entry.result && entry.result.status === "cancelled") {
|
|
251
|
+
// cancelAllSubAgents already set this; nothing to do.
|
|
252
|
+
}
|
|
253
|
+
else if (abort.signal.aborted) {
|
|
254
|
+
entry.result = {
|
|
255
|
+
id,
|
|
256
|
+
name: resolvedName,
|
|
257
|
+
status: "cancelled",
|
|
258
|
+
output: finalText,
|
|
259
|
+
tokensUsed: { input: inputTokens, output: outputTokens },
|
|
260
|
+
duration: Date.now() - startTime,
|
|
261
|
+
};
|
|
262
|
+
entry.info.status = "cancelled";
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
entry.result = {
|
|
266
|
+
id,
|
|
267
|
+
name: resolvedName,
|
|
268
|
+
status: "completed",
|
|
269
|
+
output: finalText,
|
|
270
|
+
tokensUsed: { input: inputTokens, output: outputTokens },
|
|
271
|
+
duration: Date.now() - startTime,
|
|
272
|
+
};
|
|
273
|
+
entry.info.status = "completed";
|
|
274
|
+
}
|
|
275
|
+
// A4: finalize the live-stream if we had one. On success, mark the
|
|
276
|
+
// entry as delivered so spawnSubAgent.finally() skips the normal
|
|
277
|
+
// deliverSubAgentResult path — the live stream already posted the
|
|
278
|
+
// body, and finalize() already posted the banner.
|
|
279
|
+
if (liveStream && !liveStream.failed && entry.result) {
|
|
280
|
+
try {
|
|
281
|
+
await liveStream.finalize(entry.info, entry.result);
|
|
282
|
+
entry.delivered = true;
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
console.error(`[subagent ${id}] live-stream finalize failed:`, err);
|
|
286
|
+
// Let the normal delivery path fire as a fallback.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
47
289
|
}
|
|
48
290
|
catch (err) {
|
|
49
291
|
const isAbort = err instanceof Error && err.message.includes("abort");
|
|
@@ -55,7 +297,7 @@ async function runSubAgent(id, agentConfig, abort) {
|
|
|
55
297
|
: "error";
|
|
56
298
|
entry.result = {
|
|
57
299
|
id,
|
|
58
|
-
name:
|
|
300
|
+
name: resolvedName,
|
|
59
301
|
status,
|
|
60
302
|
output: "",
|
|
61
303
|
tokensUsed: { input: 0, output: 0 },
|
|
@@ -65,42 +307,179 @@ async function runSubAgent(id, agentConfig, abort) {
|
|
|
65
307
|
entry.info.status = status;
|
|
66
308
|
}
|
|
67
309
|
}
|
|
68
|
-
|
|
310
|
+
const pendingQueue = [];
|
|
311
|
+
/** Priority order used when draining the queue — higher index = lower priority. */
|
|
312
|
+
const SOURCE_PRIORITY = ["user", "cron", "implicit"];
|
|
313
|
+
function sourceOf(cfg) {
|
|
314
|
+
return cfg.source ?? "implicit";
|
|
315
|
+
}
|
|
316
|
+
/** Count how many agents are currently running. */
|
|
317
|
+
function runningCount() {
|
|
318
|
+
return [...activeAgents.values()].filter((a) => a.info.status === "running").length;
|
|
319
|
+
}
|
|
69
320
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
321
|
+
* Pop the next queued spawn according to priority (user > cron > implicit)
|
|
322
|
+
* and within each priority in FIFO order. Returns null if the queue is empty.
|
|
72
323
|
*/
|
|
324
|
+
function popHighestPriorityQueued() {
|
|
325
|
+
for (const priority of SOURCE_PRIORITY) {
|
|
326
|
+
const idx = pendingQueue.findIndex((q) => sourceOf(q.agentConfig) === priority);
|
|
327
|
+
if (idx >= 0) {
|
|
328
|
+
const [entry] = pendingQueue.splice(idx, 1);
|
|
329
|
+
return entry;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Recalculate queuePosition for every entry still in the queue. Called
|
|
336
|
+
* after a pop or a cancel so /subagents list reflects the current state.
|
|
337
|
+
*/
|
|
338
|
+
function reindexQueue() {
|
|
339
|
+
for (let i = 0; i < pendingQueue.length; i++) {
|
|
340
|
+
const q = pendingQueue[i];
|
|
341
|
+
const entry = activeAgents.get(q.id);
|
|
342
|
+
if (entry)
|
|
343
|
+
entry.info.queuePosition = i + 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** Drain as many queued spawns as fit into the current free slots. */
|
|
347
|
+
function drainQueue() {
|
|
348
|
+
const maxParallel = getMaxParallelAgents();
|
|
349
|
+
while (pendingQueue.length > 0 && runningCount() < maxParallel) {
|
|
350
|
+
const next = popHighestPriorityQueued();
|
|
351
|
+
if (!next)
|
|
352
|
+
break;
|
|
353
|
+
const entry = activeAgents.get(next.id);
|
|
354
|
+
if (!entry)
|
|
355
|
+
continue; // was cancelled while queued
|
|
356
|
+
reindexQueue();
|
|
357
|
+
// Transition to running
|
|
358
|
+
entry.info.status = "running";
|
|
359
|
+
entry.info.startedAt = Date.now();
|
|
360
|
+
entry.info.queuePosition = undefined;
|
|
361
|
+
startRun(next);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ── Spawn pipeline ──────────────────────────────────────────
|
|
365
|
+
function startRun(q) {
|
|
366
|
+
const { id, resolvedName, agentConfig, timeoutId } = q;
|
|
367
|
+
const entry = activeAgents.get(id);
|
|
368
|
+
if (!entry)
|
|
369
|
+
return;
|
|
370
|
+
// Run in background — don't await
|
|
371
|
+
runSubAgent(id, agentConfig, entry.abort, resolvedName)
|
|
372
|
+
.finally(() => {
|
|
373
|
+
if (timeoutId)
|
|
374
|
+
clearTimeout(timeoutId);
|
|
375
|
+
const currentEntry = activeAgents.get(id);
|
|
376
|
+
if (agentConfig.onComplete && currentEntry?.result) {
|
|
377
|
+
try {
|
|
378
|
+
agentConfig.onComplete(currentEntry.result);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
console.error(`[subagent ${id}] onComplete callback threw:`, err);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// I3: fire delivery router (non-blocking, errors logged). Guarded
|
|
385
|
+
// by the `delivered` flag.
|
|
386
|
+
if (currentEntry?.result && !currentEntry.delivered) {
|
|
387
|
+
currentEntry.delivered = true;
|
|
388
|
+
const resultSnapshot = currentEntry.result;
|
|
389
|
+
const infoSnapshot = currentEntry.info;
|
|
390
|
+
import("./subagent-delivery.js")
|
|
391
|
+
.then(({ deliverSubAgentResult }) => deliverSubAgentResult(infoSnapshot, resultSnapshot, {
|
|
392
|
+
visibility: agentConfig.visibility,
|
|
393
|
+
}))
|
|
394
|
+
.catch((err) => console.error(`[subagent ${id}] delivery failed:`, err));
|
|
395
|
+
}
|
|
396
|
+
// H3: record this run in the rolling 24h stats (non-blocking).
|
|
397
|
+
if (currentEntry?.result) {
|
|
398
|
+
const resultSnapshot = currentEntry.result;
|
|
399
|
+
const infoSnapshot = currentEntry.info;
|
|
400
|
+
import("./subagent-stats.js")
|
|
401
|
+
.then(({ recordSubAgentRun }) => recordSubAgentRun(infoSnapshot, resultSnapshot))
|
|
402
|
+
.catch((err) => console.error(`[subagent ${id}] stats recording failed:`, err));
|
|
403
|
+
}
|
|
404
|
+
// D3: drain the queue now that a slot has freed up
|
|
405
|
+
drainQueue();
|
|
406
|
+
// Auto-cleanup: remove completed agents after 30 minutes
|
|
407
|
+
setTimeout(() => {
|
|
408
|
+
const e = activeAgents.get(id);
|
|
409
|
+
if (e && e.info.status !== "running" && e.info.status !== "queued") {
|
|
410
|
+
activeAgents.delete(id);
|
|
411
|
+
}
|
|
412
|
+
}, 30 * 60 * 1000);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
73
415
|
export function spawnSubAgent(agentConfig) {
|
|
74
|
-
//
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
return Promise.reject(new Error(`Sub-agent limit reached (${
|
|
416
|
+
// F2: enforce depth cap before touching any state.
|
|
417
|
+
const depth = agentConfig.depth ?? 0;
|
|
418
|
+
if (depth > MAX_SUBAGENT_DEPTH) {
|
|
419
|
+
return Promise.reject(new Error(`Sub-agent depth limit reached (${MAX_SUBAGENT_DEPTH}). Agents can only spawn ${MAX_SUBAGENT_DEPTH} level(s) of nested agents.`));
|
|
78
420
|
}
|
|
421
|
+
// G1: toolset preset. Only "full" is supported. The literal type blocks
|
|
422
|
+
// wrong values at compile time; the runtime check catches callers that
|
|
423
|
+
// bypass TypeScript (e.g. plugin code loaded at runtime).
|
|
424
|
+
const toolset = agentConfig.toolset ?? "full";
|
|
425
|
+
if (toolset !== "full") {
|
|
426
|
+
return Promise.reject(new Error(`Invalid toolset "${toolset}". Only "full" is supported in this version.`));
|
|
427
|
+
}
|
|
428
|
+
const maxParallel = getMaxParallelAgents();
|
|
429
|
+
const queueCap = getQueueCap();
|
|
430
|
+
const running = runningCount();
|
|
431
|
+
const queuedLen = pendingQueue.length;
|
|
432
|
+
// B2: resolve the requested name to a unique variant.
|
|
433
|
+
const resolved = resolveAgentName(agentConfig.name);
|
|
434
|
+
const resolvedName = resolved.name;
|
|
79
435
|
const id = crypto.randomUUID();
|
|
80
436
|
const timeout = agentConfig.timeout ?? config.subAgentTimeout;
|
|
81
437
|
const abort = new AbortController();
|
|
82
|
-
// Set up timeout
|
|
83
438
|
const timeoutId = setTimeout(() => abort.abort(), timeout);
|
|
439
|
+
const willRunImmediately = running < maxParallel;
|
|
440
|
+
const canQueue = !willRunImmediately && queueCap > 0 && queuedLen < queueCap;
|
|
441
|
+
if (!willRunImmediately && !canQueue) {
|
|
442
|
+
// No slot, no queue room → priority-aware reject
|
|
443
|
+
clearTimeout(timeoutId);
|
|
444
|
+
const source = sourceOf(agentConfig);
|
|
445
|
+
const runningAgents = [...activeAgents.values()].filter((a) => a.info.status === "running");
|
|
446
|
+
const userSlots = runningAgents.filter((a) => a.info.source === "user").length;
|
|
447
|
+
const bgSlots = runningAgents.length - userSlots;
|
|
448
|
+
let message;
|
|
449
|
+
if (source === "user") {
|
|
450
|
+
if (bgSlots > 0) {
|
|
451
|
+
message = `Alle Slots belegt (${running}/${maxParallel}), davon ${bgSlots} cron/implicit im Hintergrund. Queue voll (${queuedLen}/${queueCap}). /subagents list für Details oder /subagents cancel <name>.`;
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
message = `Alle Slots belegt (${running}/${maxParallel}) mit eigenen user-Spawns. Queue voll (${queuedLen}/${queueCap}). /subagents cancel <name> oder warten.`;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
message = `Sub-agent limit reached (${maxParallel} running, ${queuedLen}/${queueCap} queued). Wait for a running agent to finish or cancel one.`;
|
|
459
|
+
}
|
|
460
|
+
return Promise.reject(new Error(message));
|
|
461
|
+
}
|
|
84
462
|
const info = {
|
|
85
463
|
id,
|
|
86
|
-
name:
|
|
87
|
-
status: "running",
|
|
464
|
+
name: resolvedName,
|
|
465
|
+
status: willRunImmediately ? "running" : "queued",
|
|
88
466
|
startedAt: Date.now(),
|
|
89
467
|
model: agentConfig.model,
|
|
468
|
+
source: agentConfig.source,
|
|
469
|
+
depth,
|
|
470
|
+
parentChatId: agentConfig.parentChatId,
|
|
471
|
+
nameIndex: resolved.index,
|
|
472
|
+
queuePosition: willRunImmediately ? undefined : queuedLen + 1,
|
|
90
473
|
};
|
|
91
|
-
activeAgents.set(id, { info, abort });
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
activeAgents.delete(id);
|
|
101
|
-
}
|
|
102
|
-
}, 30 * 60 * 1000);
|
|
103
|
-
});
|
|
474
|
+
activeAgents.set(id, { info, abort, delivered: false });
|
|
475
|
+
const queuedSpawn = { id, resolvedName, agentConfig, depth, timeoutId };
|
|
476
|
+
if (willRunImmediately) {
|
|
477
|
+
startRun(queuedSpawn);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
pendingQueue.push(queuedSpawn);
|
|
481
|
+
reindexQueue();
|
|
482
|
+
}
|
|
104
483
|
return Promise.resolve(id);
|
|
105
484
|
}
|
|
106
485
|
/**
|
|
@@ -115,7 +494,21 @@ export function listSubAgents() {
|
|
|
115
494
|
*/
|
|
116
495
|
export function cancelSubAgent(id) {
|
|
117
496
|
const entry = activeAgents.get(id);
|
|
118
|
-
if (!entry
|
|
497
|
+
if (!entry)
|
|
498
|
+
return false;
|
|
499
|
+
if (entry.info.status === "queued") {
|
|
500
|
+
// D3: remove from the pending queue, reindex, mark cancelled.
|
|
501
|
+
const idx = pendingQueue.findIndex((q) => q.id === id);
|
|
502
|
+
if (idx >= 0) {
|
|
503
|
+
const [removed] = pendingQueue.splice(idx, 1);
|
|
504
|
+
if (removed.timeoutId)
|
|
505
|
+
clearTimeout(removed.timeoutId);
|
|
506
|
+
reindexQueue();
|
|
507
|
+
}
|
|
508
|
+
entry.info.status = "cancelled";
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
if (entry.info.status !== "running")
|
|
119
512
|
return false;
|
|
120
513
|
entry.abort.abort();
|
|
121
514
|
entry.info.status = "cancelled";
|
|
@@ -129,14 +522,88 @@ export function getSubAgentResult(id) {
|
|
|
129
522
|
const entry = activeAgents.get(id);
|
|
130
523
|
return entry?.result ?? null;
|
|
131
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Cancel a sub-agent by name (or name#N). Returns true if a running agent
|
|
527
|
+
* was found and aborted. Uses findSubAgentByName for resolution; in an
|
|
528
|
+
* ambiguous case (multiple siblings under the same base name, caller did
|
|
529
|
+
* not disambiguate), cancels the first candidate.
|
|
530
|
+
*/
|
|
531
|
+
export function cancelSubAgentByName(name) {
|
|
532
|
+
const match = findSubAgentByName(name);
|
|
533
|
+
if (!match || "ambiguous" in match)
|
|
534
|
+
return false;
|
|
535
|
+
return cancelSubAgent(match.id);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Get a sub-agent's result by name. Returns null if no such agent, no
|
|
539
|
+
* result yet (still running), or the name is ambiguous without explicit
|
|
540
|
+
* disambiguation.
|
|
541
|
+
*/
|
|
542
|
+
export function getSubAgentResultByName(name) {
|
|
543
|
+
const match = findSubAgentByName(name);
|
|
544
|
+
if (!match || "ambiguous" in match)
|
|
545
|
+
return null;
|
|
546
|
+
return getSubAgentResult(match.id);
|
|
547
|
+
}
|
|
132
548
|
/**
|
|
133
549
|
* Cancel all active sub-agents. Used during shutdown.
|
|
550
|
+
*
|
|
551
|
+
* When notify=true (default), each running agent gets a Telegram
|
|
552
|
+
* delivery explaining that it was interrupted by a restart. Errors
|
|
553
|
+
* during delivery are logged but never block shutdown. The whole
|
|
554
|
+
* notify phase is capped at 5s so a hung Telegram send can't hold
|
|
555
|
+
* the process hostage.
|
|
134
556
|
*/
|
|
135
|
-
export function cancelAllSubAgents() {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
557
|
+
export async function cancelAllSubAgents(notify = true) {
|
|
558
|
+
const deliveryPromises = [];
|
|
559
|
+
// Iterate once: for each running agent (1) abort the SDK stream,
|
|
560
|
+
// (2) synthesise and store a cancelled SubAgentResult, (3) mark
|
|
561
|
+
// delivered=true so runSubAgent.finally() can't fire a second
|
|
562
|
+
// delivery on the next microtask, (4) queue the I3 delivery.
|
|
563
|
+
const runningEntries = [];
|
|
564
|
+
// D3: clear the pending queue first so no entry starts during shutdown.
|
|
565
|
+
for (const q of pendingQueue.splice(0)) {
|
|
566
|
+
if (q.timeoutId)
|
|
567
|
+
clearTimeout(q.timeoutId);
|
|
568
|
+
const entry = activeAgents.get(q.id);
|
|
569
|
+
if (entry) {
|
|
139
570
|
entry.info.status = "cancelled";
|
|
571
|
+
entry.delivered = true; // no delivery for queued-never-ran agents
|
|
140
572
|
}
|
|
141
573
|
}
|
|
574
|
+
for (const [id, entry] of activeAgents) {
|
|
575
|
+
if (entry.info.status !== "running")
|
|
576
|
+
continue;
|
|
577
|
+
entry.abort.abort();
|
|
578
|
+
entry.info.status = "cancelled";
|
|
579
|
+
const cancelResult = {
|
|
580
|
+
id,
|
|
581
|
+
name: entry.info.name,
|
|
582
|
+
status: "cancelled",
|
|
583
|
+
output: "⚠️ Agent wurde durch Bot-Restart unterbrochen. Bitte neu triggern.",
|
|
584
|
+
tokensUsed: { input: 0, output: 0 },
|
|
585
|
+
duration: Date.now() - entry.info.startedAt,
|
|
586
|
+
};
|
|
587
|
+
entry.result = cancelResult;
|
|
588
|
+
entry.delivered = true;
|
|
589
|
+
runningEntries.push({ id, info: entry.info, cancelResult });
|
|
590
|
+
}
|
|
591
|
+
if (!notify || runningEntries.length === 0)
|
|
592
|
+
return;
|
|
593
|
+
// Import once, then reuse. Doing one dynamic import per running agent
|
|
594
|
+
// races with Vitest's mock-resolution in tests and can occasionally
|
|
595
|
+
// resolve to the real module instead of the mock for later calls.
|
|
596
|
+
const { deliverSubAgentResult } = await import("./subagent-delivery.js");
|
|
597
|
+
for (const { id, info, cancelResult } of runningEntries) {
|
|
598
|
+
const p = Promise.resolve(deliverSubAgentResult(info, cancelResult)).catch((err) => {
|
|
599
|
+
console.error(`[subagents] shutdown-notify failed for ${id}:`, err);
|
|
600
|
+
});
|
|
601
|
+
deliveryPromises.push(p);
|
|
602
|
+
}
|
|
603
|
+
// Wait up to 5s total — long enough for real Telegram sends, short
|
|
604
|
+
// enough that shutdown isn't held hostage by a hang.
|
|
605
|
+
await Promise.race([
|
|
606
|
+
Promise.all(deliveryPromises),
|
|
607
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
608
|
+
]);
|
|
142
609
|
}
|
|
@@ -9,13 +9,38 @@ export class TelegramStreamer {
|
|
|
9
9
|
pendingText = null;
|
|
10
10
|
editTimer = null;
|
|
11
11
|
lastSentText = "";
|
|
12
|
+
currentStatus = null;
|
|
13
|
+
lastFullText = "";
|
|
12
14
|
constructor(chatId, api, replyToMessageId) {
|
|
13
15
|
this.chatId = chatId;
|
|
14
16
|
this.api = api;
|
|
15
17
|
this.replyTo = replyToMessageId;
|
|
16
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Set a transient status line (e.g. "📖 Read file.html…") that gets
|
|
21
|
+
* appended to the current accumulated text. Passing null clears it.
|
|
22
|
+
* Used to surface tool-use activity so users see real progress instead
|
|
23
|
+
* of an endless typing indicator.
|
|
24
|
+
*/
|
|
25
|
+
setStatus(status) {
|
|
26
|
+
if (this.currentStatus === status)
|
|
27
|
+
return;
|
|
28
|
+
this.currentStatus = status;
|
|
29
|
+
// Re-render with the previously accumulated text so the new status
|
|
30
|
+
// becomes visible immediately (throttled by the existing flush timer).
|
|
31
|
+
void this.update(this.lastFullText);
|
|
32
|
+
}
|
|
33
|
+
renderWithStatus(fullText) {
|
|
34
|
+
const truncated = this.truncate(fullText);
|
|
35
|
+
const hasBody = truncated.length > 0;
|
|
36
|
+
const body = hasBody ? truncated : "…";
|
|
37
|
+
if (!this.currentStatus)
|
|
38
|
+
return body;
|
|
39
|
+
return hasBody ? `${body}\n\n_${this.currentStatus}_` : `_${this.currentStatus}_`;
|
|
40
|
+
}
|
|
17
41
|
async update(fullText) {
|
|
18
|
-
|
|
42
|
+
this.lastFullText = fullText;
|
|
43
|
+
const displayText = sanitizeTelegramMarkdown(this.renderWithStatus(fullText));
|
|
19
44
|
if (!this.messageId) {
|
|
20
45
|
const opts = { parse_mode: "Markdown" };
|
|
21
46
|
if (this.replyTo)
|
|
@@ -52,6 +77,8 @@ export class TelegramStreamer {
|
|
|
52
77
|
}
|
|
53
78
|
}
|
|
54
79
|
async finalize(fullText) {
|
|
80
|
+
// Drop any transient status line — final message should be clean text.
|
|
81
|
+
this.currentStatus = null;
|
|
55
82
|
if (this.editTimer) {
|
|
56
83
|
clearTimeout(this.editTimer);
|
|
57
84
|
this.editTimer = null;
|