alvin-bot 4.5.0 → 4.6.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 +150 -0
- package/README.md +25 -2
- package/alvin-bot-4.5.1.tgz +0 -0
- package/bin/cli.js +246 -0
- package/dist/handlers/commands.js +461 -63
- package/dist/handlers/message.js +209 -14
- package/dist/i18n.js +470 -13
- package/dist/index.js +44 -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 +111 -0
- package/dist/services/subagents.js +341 -27
- 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/dist/tui/index.js +36 -30
- package/docs/HANDBOOK.md +819 -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 +169 -0
- package/test/subagents-commands.test.ts +64 -0
- package/test/subagents-config.test.ts +108 -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 +60 -0
- package/test/subagents-shutdown.test.ts +126 -0
- package/test/subagents-toolset.test.ts +51 -0
- package/vitest.config.ts +17 -0
package/dist/services/session.js
CHANGED
|
@@ -27,15 +27,20 @@ export function getSession(key) {
|
|
|
27
27
|
totalCost: 0,
|
|
28
28
|
costByProvider: {},
|
|
29
29
|
queriesByProvider: {},
|
|
30
|
-
effort: "
|
|
30
|
+
effort: "medium",
|
|
31
31
|
voiceReply: false,
|
|
32
32
|
messageCount: 0,
|
|
33
33
|
toolUseCount: 0,
|
|
34
34
|
totalInputTokens: 0,
|
|
35
35
|
totalOutputTokens: 0,
|
|
36
|
+
lastTurnInputTokens: 0,
|
|
37
|
+
compactionCount: 0,
|
|
38
|
+
checkpointHintsInjected: 0,
|
|
39
|
+
sdkSubTaskCount: 0,
|
|
36
40
|
history: [],
|
|
37
41
|
language: "en",
|
|
38
42
|
messageQueue: [],
|
|
43
|
+
lastSdkHistoryIndex: -1,
|
|
39
44
|
};
|
|
40
45
|
sessions.set(k, session);
|
|
41
46
|
}
|
|
@@ -56,7 +61,12 @@ export function resetSession(key) {
|
|
|
56
61
|
session.toolUseCount = 0;
|
|
57
62
|
session.totalInputTokens = 0;
|
|
58
63
|
session.totalOutputTokens = 0;
|
|
64
|
+
session.lastTurnInputTokens = 0;
|
|
65
|
+
session.compactionCount = 0;
|
|
66
|
+
session.checkpointHintsInjected = 0;
|
|
67
|
+
session.sdkSubTaskCount = 0;
|
|
59
68
|
session.history = [];
|
|
69
|
+
session.lastSdkHistoryIndex = -1;
|
|
60
70
|
session.startedAt = Date.now();
|
|
61
71
|
// Reset budget warning flags so the user gets fresh warnings in the new session.
|
|
62
72
|
session._budgetWarned80 = false;
|
|
@@ -138,13 +148,21 @@ export function stopSessionCleanup() {
|
|
|
138
148
|
cleanupTimer = null;
|
|
139
149
|
}
|
|
140
150
|
}
|
|
141
|
-
/** Add a message to conversation history
|
|
151
|
+
/** Add a message to conversation history. Unified across all provider types
|
|
152
|
+
* — SDK providers resume from their filesystem session but we still track the
|
|
153
|
+
* transcript here so failovers (and the B2 bridge-message) have context. */
|
|
142
154
|
export function addToHistory(key, message) {
|
|
143
155
|
const session = getSession(key);
|
|
144
156
|
session.history.push(message);
|
|
145
|
-
// Trim oldest messages if history gets too long
|
|
157
|
+
// Trim oldest messages if history gets too long. Adjust lastSdkHistoryIndex
|
|
158
|
+
// by the number of dropped entries so it keeps pointing at the correct
|
|
159
|
+
// (now shifted) assistant turn — or collapses to -1 if it falls off the front.
|
|
146
160
|
if (session.history.length > MAX_HISTORY) {
|
|
161
|
+
const dropped = session.history.length - MAX_HISTORY;
|
|
147
162
|
session.history = session.history.slice(-MAX_HISTORY);
|
|
163
|
+
if (session.lastSdkHistoryIndex >= 0) {
|
|
164
|
+
session.lastSdkHistoryIndex = Math.max(-1, session.lastSdkHistoryIndex - dropped);
|
|
165
|
+
}
|
|
148
166
|
}
|
|
149
167
|
}
|
|
150
168
|
/** Get all active sessions (for web UI session browser). */
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-Agent Delivery Router (I3) — context-aware rendering of sub-agent
|
|
3
|
+
* results into Telegram. Source decides the delivery path:
|
|
4
|
+
* - implicit → no-op (main stream already shows the Task-tool result)
|
|
5
|
+
* - user → banner+final as a new message in parentChatId
|
|
6
|
+
* - cron → banner+final in chatId from the CronJob target
|
|
7
|
+
*
|
|
8
|
+
* The caller is responsible for passing a correct `parentChatId` on the
|
|
9
|
+
* SubAgentInfo. Lookup of the bot API is lazy so we can unit-test the
|
|
10
|
+
* module with a fake bot via __setBotApiForTest.
|
|
11
|
+
*/
|
|
12
|
+
import { getVisibility } from "./subagents.js";
|
|
13
|
+
const MAX_TG_CHUNK = 3800; // below Telegram's 4096 limit with headroom
|
|
14
|
+
const FILE_UPLOAD_THRESHOLD = 20_000; // switch to .md file upload above this
|
|
15
|
+
let injectedApi = null;
|
|
16
|
+
let runtimeApi = null;
|
|
17
|
+
/** Test-only hook for injecting a fake bot API. Production code must NEVER call this. */
|
|
18
|
+
export function __setBotApiForTest(api) {
|
|
19
|
+
injectedApi = api;
|
|
20
|
+
}
|
|
21
|
+
/** Wire the grammy bot API once at startup (called from src/index.ts). */
|
|
22
|
+
export function attachBotApi(api) {
|
|
23
|
+
runtimeApi = api;
|
|
24
|
+
}
|
|
25
|
+
function getBotApi() {
|
|
26
|
+
return injectedApi ?? runtimeApi;
|
|
27
|
+
}
|
|
28
|
+
function formatTokens(n) {
|
|
29
|
+
if (n < 1000)
|
|
30
|
+
return `${n}`;
|
|
31
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
32
|
+
}
|
|
33
|
+
function formatDuration(ms) {
|
|
34
|
+
const s = Math.floor(ms / 1000);
|
|
35
|
+
if (s < 60)
|
|
36
|
+
return `${s}s`;
|
|
37
|
+
const m = Math.floor(s / 60);
|
|
38
|
+
const rem = s - m * 60;
|
|
39
|
+
return `${m}m ${rem}s`;
|
|
40
|
+
}
|
|
41
|
+
function statusIcon(status) {
|
|
42
|
+
switch (status) {
|
|
43
|
+
case "completed": return "✅";
|
|
44
|
+
case "timeout": return "⏱️";
|
|
45
|
+
case "cancelled": return "⚠️";
|
|
46
|
+
case "error": return "❌";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function buildBanner(info, result) {
|
|
50
|
+
const icon = statusIcon(result.status);
|
|
51
|
+
const dur = formatDuration(result.duration);
|
|
52
|
+
const ti = formatTokens(result.tokensUsed.input);
|
|
53
|
+
const to = formatTokens(result.tokensUsed.output);
|
|
54
|
+
return `${icon} *${info.name}* ${result.status} · ${dur} · ${ti} in / ${to} out`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Main delivery entry point. Resolves the effective visibility (override →
|
|
58
|
+
* config default), then dispatches to the source-specific renderer.
|
|
59
|
+
*
|
|
60
|
+
* Errors are logged but never thrown — delivery must not break the sub-agent
|
|
61
|
+
* lifecycle. A failed Telegram send falls through silently.
|
|
62
|
+
*/
|
|
63
|
+
export async function deliverSubAgentResult(info, result, opts = {}) {
|
|
64
|
+
// Implicit spawns: the Task-tool bridge in the main stream has already
|
|
65
|
+
// surfaced the output; extra delivery would be duplication.
|
|
66
|
+
if (info.source === "implicit")
|
|
67
|
+
return;
|
|
68
|
+
const effective = opts.visibility ?? getVisibility();
|
|
69
|
+
if (effective === "silent")
|
|
70
|
+
return;
|
|
71
|
+
const api = getBotApi();
|
|
72
|
+
if (!api) {
|
|
73
|
+
console.warn(`[subagent-delivery] no bot api available for ${info.name}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!info.parentChatId) {
|
|
77
|
+
console.warn(`[subagent-delivery] missing parentChatId for ${info.name} (source=${info.source})`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const banner = buildBanner(info, result);
|
|
81
|
+
const body = result.output?.trim() || `(empty output)`;
|
|
82
|
+
try {
|
|
83
|
+
// Case 1: very long output → file upload with a short banner
|
|
84
|
+
if (body.length > FILE_UPLOAD_THRESHOLD) {
|
|
85
|
+
await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
|
|
86
|
+
try {
|
|
87
|
+
const { InputFile } = await import("grammy");
|
|
88
|
+
const buf = Buffer.from(body, "utf-8");
|
|
89
|
+
await api.sendDocument(info.parentChatId, new InputFile(buf, `${info.name}.md`));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(`[subagent-delivery] file upload failed:`, err);
|
|
93
|
+
await api.sendMessage(info.parentChatId, body.slice(0, MAX_TG_CHUNK));
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Case 2: fits in a single message → banner + body joined
|
|
98
|
+
if (body.length + banner.length + 2 <= MAX_TG_CHUNK) {
|
|
99
|
+
await api.sendMessage(info.parentChatId, `${banner}\n\n${body}`, { parse_mode: "Markdown" });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Case 3: medium output → banner as its own message, body chunked
|
|
103
|
+
await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
|
|
104
|
+
for (let i = 0; i < body.length; i += MAX_TG_CHUNK) {
|
|
105
|
+
await api.sendMessage(info.parentChatId, body.slice(i, i + MAX_TG_CHUNK));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
console.error(`[subagent-delivery] send failed for ${info.name}:`, err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -6,25 +6,186 @@
|
|
|
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
|
+
let configCache = null;
|
|
19
|
+
function isValidVisibility(v) {
|
|
20
|
+
return v === "auto" || v === "banner" || v === "silent";
|
|
21
|
+
}
|
|
22
|
+
function loadSubAgentsConfig() {
|
|
23
|
+
if (configCache)
|
|
24
|
+
return configCache;
|
|
25
|
+
try {
|
|
26
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
configCache = {
|
|
29
|
+
maxParallel: typeof parsed.maxParallel === "number" ? parsed.maxParallel : 0,
|
|
30
|
+
visibility: isValidVisibility(parsed.visibility) ? parsed.visibility : "auto",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// File missing or invalid — seed from env var then default to auto
|
|
35
|
+
configCache = {
|
|
36
|
+
maxParallel: Number(process.env.MAX_SUBAGENTS) || 0,
|
|
37
|
+
visibility: "auto",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return configCache;
|
|
41
|
+
}
|
|
42
|
+
function saveSubAgentsConfig(cfg) {
|
|
43
|
+
try {
|
|
44
|
+
fs.mkdirSync(dirname(CONFIG_FILE), { recursive: true });
|
|
45
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
46
|
+
configCache = cfg;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
console.error("[subagents] failed to write config:", err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Resolves max parallel agents, interpreting 0 as "auto = cpu cores capped". */
|
|
53
|
+
export function getMaxParallelAgents() {
|
|
54
|
+
const cfg = loadSubAgentsConfig();
|
|
55
|
+
if (cfg.maxParallel === 0) {
|
|
56
|
+
return Math.min(os.cpus().length, ABSOLUTE_MAX_AGENTS);
|
|
57
|
+
}
|
|
58
|
+
return Math.min(Math.max(1, cfg.maxParallel), ABSOLUTE_MAX_AGENTS);
|
|
59
|
+
}
|
|
60
|
+
/** Returns the raw configured value (for display). 0 means "auto". */
|
|
61
|
+
export function getConfiguredMaxParallel() {
|
|
62
|
+
return loadSubAgentsConfig().maxParallel;
|
|
63
|
+
}
|
|
64
|
+
/** Sets max parallel agents. Value is clamped to [0, ABSOLUTE_MAX_AGENTS].
|
|
65
|
+
* Returns the resolved effective value (with auto-expansion if set to 0). */
|
|
66
|
+
export function setMaxParallelAgents(n) {
|
|
67
|
+
const clamped = Math.max(0, Math.min(Math.floor(n), ABSOLUTE_MAX_AGENTS));
|
|
68
|
+
const cfg = loadSubAgentsConfig();
|
|
69
|
+
saveSubAgentsConfig({ ...cfg, maxParallel: clamped });
|
|
70
|
+
return getMaxParallelAgents();
|
|
71
|
+
}
|
|
72
|
+
/** A4: Current default visibility mode for new spawns. */
|
|
73
|
+
export function getVisibility() {
|
|
74
|
+
return loadSubAgentsConfig().visibility;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A4: Set the default visibility mode. Throws if the value is invalid.
|
|
78
|
+
* Writes through to the on-disk config so restart-resilient.
|
|
79
|
+
*/
|
|
80
|
+
export function setVisibility(mode) {
|
|
81
|
+
if (!isValidVisibility(mode)) {
|
|
82
|
+
throw new Error(`Invalid visibility mode "${mode}". Expected: auto | banner | silent.`);
|
|
83
|
+
}
|
|
84
|
+
const cfg = loadSubAgentsConfig();
|
|
85
|
+
saveSubAgentsConfig({ ...cfg, visibility: mode });
|
|
86
|
+
}
|
|
11
87
|
// ── State ───────────────────────────────────────────────
|
|
12
88
|
const activeAgents = new Map();
|
|
89
|
+
// ── Name resolver (B2) ──────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Return all currently-tracked agents whose *base* name matches `base`.
|
|
92
|
+
* Base name = the part before any "#N" suffix.
|
|
93
|
+
*/
|
|
94
|
+
function agentsByBaseName(base) {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const entry of activeAgents.values()) {
|
|
97
|
+
const info = entry.info;
|
|
98
|
+
const entryBase = info.name.replace(/#\d+$/, "");
|
|
99
|
+
if (entryBase === base)
|
|
100
|
+
out.push(info);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Given a requested name, return a unique variant. If no collision exists,
|
|
106
|
+
* returns `requested` unchanged (with the base form). Otherwise returns
|
|
107
|
+
* `base#N` with the smallest free N ≥ 2.
|
|
108
|
+
*/
|
|
109
|
+
function resolveAgentName(requested) {
|
|
110
|
+
const base = requested.replace(/#\d+$/, "");
|
|
111
|
+
const siblings = agentsByBaseName(base);
|
|
112
|
+
if (siblings.length === 0)
|
|
113
|
+
return { name: base };
|
|
114
|
+
// Find the smallest free index ≥ 2. The bare base name counts as "#1".
|
|
115
|
+
const takenIndices = new Set();
|
|
116
|
+
for (const s of siblings) {
|
|
117
|
+
const m = s.name.match(/#(\d+)$/);
|
|
118
|
+
if (m)
|
|
119
|
+
takenIndices.add(parseInt(m[1], 10));
|
|
120
|
+
else
|
|
121
|
+
takenIndices.add(1);
|
|
122
|
+
}
|
|
123
|
+
let n = 2;
|
|
124
|
+
while (takenIndices.has(n))
|
|
125
|
+
n++;
|
|
126
|
+
return { name: `${base}#${n}`, index: n };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Public name-resolution API used by /sub-agents cancel / result.
|
|
130
|
+
* - Exact name match wins (e.g. "review#2" finds exactly that entry).
|
|
131
|
+
* - If only one agent matches the base name, returns that one.
|
|
132
|
+
* - If the caller opted into `ambiguousAsList`, returns a disambiguation
|
|
133
|
+
* marker with all candidates instead of a single result.
|
|
134
|
+
*/
|
|
135
|
+
export function findSubAgentByName(name, opts = {}) {
|
|
136
|
+
// An explicit "base#N" query must always resolve to that exact entry,
|
|
137
|
+
// even when the caller opted into ambiguity. Otherwise users who type
|
|
138
|
+
// out the disambiguated form get an unhelpful 'which one?' reply.
|
|
139
|
+
const hasExplicitSuffix = /#\d+$/.test(name);
|
|
140
|
+
if (hasExplicitSuffix) {
|
|
141
|
+
for (const entry of activeAgents.values()) {
|
|
142
|
+
if (entry.info.name === name)
|
|
143
|
+
return { ...entry.info };
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
// No explicit suffix → base-name query. Ambiguity detection runs here
|
|
148
|
+
// when the caller opted in and there are multiple siblings.
|
|
149
|
+
const siblings = agentsByBaseName(name);
|
|
150
|
+
if (siblings.length === 0)
|
|
151
|
+
return null;
|
|
152
|
+
if (opts.ambiguousAsList && siblings.length > 1) {
|
|
153
|
+
return {
|
|
154
|
+
ambiguous: true,
|
|
155
|
+
candidates: siblings.map((s) => ({ ...s })),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// Without ambiguity opt-in, prefer an exact name match over just the
|
|
159
|
+
// first sibling — the bare base name is itself a unique key.
|
|
160
|
+
for (const entry of activeAgents.values()) {
|
|
161
|
+
if (entry.info.name === name)
|
|
162
|
+
return { ...entry.info };
|
|
163
|
+
}
|
|
164
|
+
return { ...siblings[0] };
|
|
165
|
+
}
|
|
13
166
|
// ── Core execution ──────────────────────────────────────
|
|
14
|
-
async function runSubAgent(id, agentConfig, abort) {
|
|
167
|
+
async function runSubAgent(id, agentConfig, abort, resolvedName) {
|
|
15
168
|
const startTime = Date.now();
|
|
16
169
|
const entry = activeAgents.get(id);
|
|
17
170
|
try {
|
|
18
171
|
const { getRegistry } = await import("../engine.js");
|
|
19
172
|
const registry = getRegistry();
|
|
20
|
-
|
|
173
|
+
// C3: inheritCwd (default true) decides whether the parent's working
|
|
174
|
+
// dir flows through. When false, we fall back to the home directory —
|
|
175
|
+
// useful for cron jobs that must run in a well-known root regardless
|
|
176
|
+
// of what the caller was doing.
|
|
177
|
+
const inheritCwd = agentConfig.inheritCwd ?? true;
|
|
178
|
+
const effectiveCwd = inheritCwd
|
|
179
|
+
? agentConfig.workingDir || os.homedir()
|
|
180
|
+
: os.homedir();
|
|
181
|
+
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
182
|
let finalText = "";
|
|
22
183
|
let inputTokens = 0;
|
|
23
184
|
let outputTokens = 0;
|
|
24
185
|
for await (const chunk of registry.queryWithFallback({
|
|
25
186
|
prompt: agentConfig.prompt,
|
|
26
187
|
systemPrompt,
|
|
27
|
-
workingDir:
|
|
188
|
+
workingDir: effectiveCwd,
|
|
28
189
|
effort: "high",
|
|
29
190
|
abortSignal: abort.signal,
|
|
30
191
|
})) {
|
|
@@ -35,15 +196,37 @@ async function runSubAgent(id, agentConfig, abort) {
|
|
|
35
196
|
outputTokens = chunk.outputTokens || 0;
|
|
36
197
|
}
|
|
37
198
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
199
|
+
// If cancelAllSubAgents has already taken over (shutdown path), don't
|
|
200
|
+
// overwrite the cancelled result it synthesised. Also: if the generator
|
|
201
|
+
// exited gracefully but the abort signal fired mid-stream (e.g. the
|
|
202
|
+
// provider's queryWithFallback returned `type:error` and we fell out
|
|
203
|
+
// of the loop without throwing), mark the run as cancelled rather
|
|
204
|
+
// than completed — the result output is whatever we buffered.
|
|
205
|
+
if (entry.result && entry.result.status === "cancelled") {
|
|
206
|
+
// cancelAllSubAgents already set this; nothing to do.
|
|
207
|
+
}
|
|
208
|
+
else if (abort.signal.aborted) {
|
|
209
|
+
entry.result = {
|
|
210
|
+
id,
|
|
211
|
+
name: resolvedName,
|
|
212
|
+
status: "cancelled",
|
|
213
|
+
output: finalText,
|
|
214
|
+
tokensUsed: { input: inputTokens, output: outputTokens },
|
|
215
|
+
duration: Date.now() - startTime,
|
|
216
|
+
};
|
|
217
|
+
entry.info.status = "cancelled";
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
entry.result = {
|
|
221
|
+
id,
|
|
222
|
+
name: resolvedName,
|
|
223
|
+
status: "completed",
|
|
224
|
+
output: finalText,
|
|
225
|
+
tokensUsed: { input: inputTokens, output: outputTokens },
|
|
226
|
+
duration: Date.now() - startTime,
|
|
227
|
+
};
|
|
228
|
+
entry.info.status = "completed";
|
|
229
|
+
}
|
|
47
230
|
}
|
|
48
231
|
catch (err) {
|
|
49
232
|
const isAbort = err instanceof Error && err.message.includes("abort");
|
|
@@ -55,7 +238,7 @@ async function runSubAgent(id, agentConfig, abort) {
|
|
|
55
238
|
: "error";
|
|
56
239
|
entry.result = {
|
|
57
240
|
id,
|
|
58
|
-
name:
|
|
241
|
+
name: resolvedName,
|
|
59
242
|
status,
|
|
60
243
|
output: "",
|
|
61
244
|
tokensUsed: { input: 0, output: 0 },
|
|
@@ -71,11 +254,47 @@ async function runSubAgent(id, agentConfig, abort) {
|
|
|
71
254
|
* Returns the agent ID immediately (does NOT await completion).
|
|
72
255
|
*/
|
|
73
256
|
export function spawnSubAgent(agentConfig) {
|
|
74
|
-
//
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
return Promise.reject(new Error(`Sub-agent limit reached (${
|
|
257
|
+
// F2: enforce depth cap before touching any state.
|
|
258
|
+
const depth = agentConfig.depth ?? 0;
|
|
259
|
+
if (depth > MAX_SUBAGENT_DEPTH) {
|
|
260
|
+
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.`));
|
|
261
|
+
}
|
|
262
|
+
// G1: toolset preset. Only "full" is supported in Stufe 1. The literal
|
|
263
|
+
// type blocks wrong values at compile time; the runtime check catches
|
|
264
|
+
// callers that bypass TypeScript (e.g. plugin code loaded at runtime).
|
|
265
|
+
const toolset = agentConfig.toolset ?? "full";
|
|
266
|
+
if (toolset !== "full") {
|
|
267
|
+
return Promise.reject(new Error(`Invalid toolset "${toolset}". Only "full" is supported in this version.`));
|
|
268
|
+
}
|
|
269
|
+
// Check concurrency limit — now reads from the file-backed config so
|
|
270
|
+
// /sub-agents max <n> edits take effect immediately without a restart.
|
|
271
|
+
const running = [...activeAgents.values()].filter((a) => a.info.status === "running");
|
|
272
|
+
const maxParallel = getMaxParallelAgents();
|
|
273
|
+
if (running.length >= maxParallel) {
|
|
274
|
+
// D4: priority-aware reject messages — give callers context about
|
|
275
|
+
// WHO is holding the slots so they know whether to wait, cancel,
|
|
276
|
+
// or give up.
|
|
277
|
+
const source = agentConfig.source ?? "implicit";
|
|
278
|
+
const userSlots = running.filter((a) => a.info.source === "user").length;
|
|
279
|
+
const bgSlots = running.length - userSlots;
|
|
280
|
+
let message;
|
|
281
|
+
if (source === "user") {
|
|
282
|
+
if (bgSlots > 0) {
|
|
283
|
+
message = `Alle Slots belegt (${running.length}/${maxParallel}), davon ${bgSlots} cron/implicit im Hintergrund. /sub-agents list für Details oder /sub-agents cancel <name>.`;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
message = `Alle Slots belegt (${running.length}/${maxParallel}) mit eigenen user-Spawns. /sub-agents cancel <name> oder warten: /sub-agents list`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
message = `Sub-agent limit reached (${maxParallel}). Wait for a running agent to finish or cancel one.`;
|
|
291
|
+
}
|
|
292
|
+
return Promise.reject(new Error(message));
|
|
78
293
|
}
|
|
294
|
+
// B2: resolve the requested name to a unique variant. On collision,
|
|
295
|
+
// append #N where N is the smallest free index ≥ 2.
|
|
296
|
+
const resolved = resolveAgentName(agentConfig.name);
|
|
297
|
+
const resolvedName = resolved.name;
|
|
79
298
|
const id = crypto.randomUUID();
|
|
80
299
|
const timeout = agentConfig.timeout ?? config.subAgentTimeout;
|
|
81
300
|
const abort = new AbortController();
|
|
@@ -83,20 +302,51 @@ export function spawnSubAgent(agentConfig) {
|
|
|
83
302
|
const timeoutId = setTimeout(() => abort.abort(), timeout);
|
|
84
303
|
const info = {
|
|
85
304
|
id,
|
|
86
|
-
name:
|
|
305
|
+
name: resolvedName,
|
|
87
306
|
status: "running",
|
|
88
307
|
startedAt: Date.now(),
|
|
89
308
|
model: agentConfig.model,
|
|
309
|
+
source: agentConfig.source,
|
|
310
|
+
depth,
|
|
311
|
+
parentChatId: agentConfig.parentChatId,
|
|
312
|
+
nameIndex: resolved.index,
|
|
90
313
|
};
|
|
91
|
-
activeAgents.set(id, { info, abort });
|
|
314
|
+
activeAgents.set(id, { info, abort, delivered: false });
|
|
92
315
|
// Run in background — don't await
|
|
93
|
-
runSubAgent(id, agentConfig, abort)
|
|
316
|
+
runSubAgent(id, agentConfig, abort, resolvedName)
|
|
94
317
|
.finally(() => {
|
|
95
318
|
clearTimeout(timeoutId);
|
|
319
|
+
// Call the onComplete callback if the caller provided one. This is
|
|
320
|
+
// how cron.ts turns the fire-and-forget spawnSubAgent() into a
|
|
321
|
+
// Promise that resolves when the work finishes. The callback runs
|
|
322
|
+
// inside a try/catch so a throwing callback can't break cleanup.
|
|
323
|
+
const entry = activeAgents.get(id);
|
|
324
|
+
if (agentConfig.onComplete && entry?.result) {
|
|
325
|
+
try {
|
|
326
|
+
agentConfig.onComplete(entry.result);
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
console.error(`[subagent ${id}] onComplete callback threw:`, err);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// I3: fire delivery router (non-blocking, errors logged). Dynamic
|
|
333
|
+
// import keeps the module graph free of circular edges. Guarded by
|
|
334
|
+
// the `delivered` flag so cancelAllSubAgents (shutdown path) and
|
|
335
|
+
// this finally() can't both post the result.
|
|
336
|
+
if (entry?.result && !entry.delivered) {
|
|
337
|
+
entry.delivered = true;
|
|
338
|
+
const resultSnapshot = entry.result;
|
|
339
|
+
const infoSnapshot = entry.info;
|
|
340
|
+
import("./subagent-delivery.js")
|
|
341
|
+
.then(({ deliverSubAgentResult }) => deliverSubAgentResult(infoSnapshot, resultSnapshot, {
|
|
342
|
+
visibility: agentConfig.visibility,
|
|
343
|
+
}))
|
|
344
|
+
.catch((err) => console.error(`[subagent ${id}] delivery failed:`, err));
|
|
345
|
+
}
|
|
96
346
|
// Auto-cleanup: remove completed agents after 30 minutes
|
|
97
347
|
setTimeout(() => {
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
348
|
+
const e = activeAgents.get(id);
|
|
349
|
+
if (e && e.info.status !== "running") {
|
|
100
350
|
activeAgents.delete(id);
|
|
101
351
|
}
|
|
102
352
|
}, 30 * 60 * 1000);
|
|
@@ -129,14 +379,78 @@ export function getSubAgentResult(id) {
|
|
|
129
379
|
const entry = activeAgents.get(id);
|
|
130
380
|
return entry?.result ?? null;
|
|
131
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Cancel a sub-agent by name (or name#N). Returns true if a running agent
|
|
384
|
+
* was found and aborted. Uses findSubAgentByName for resolution; in an
|
|
385
|
+
* ambiguous case (multiple siblings under the same base name, caller did
|
|
386
|
+
* not disambiguate), cancels the first candidate.
|
|
387
|
+
*/
|
|
388
|
+
export function cancelSubAgentByName(name) {
|
|
389
|
+
const match = findSubAgentByName(name);
|
|
390
|
+
if (!match || "ambiguous" in match)
|
|
391
|
+
return false;
|
|
392
|
+
return cancelSubAgent(match.id);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Get a sub-agent's result by name. Returns null if no such agent, no
|
|
396
|
+
* result yet (still running), or the name is ambiguous without explicit
|
|
397
|
+
* disambiguation.
|
|
398
|
+
*/
|
|
399
|
+
export function getSubAgentResultByName(name) {
|
|
400
|
+
const match = findSubAgentByName(name);
|
|
401
|
+
if (!match || "ambiguous" in match)
|
|
402
|
+
return null;
|
|
403
|
+
return getSubAgentResult(match.id);
|
|
404
|
+
}
|
|
132
405
|
/**
|
|
133
406
|
* Cancel all active sub-agents. Used during shutdown.
|
|
407
|
+
*
|
|
408
|
+
* When notify=true (default), each running agent gets a Telegram
|
|
409
|
+
* delivery explaining that it was interrupted by a restart. Errors
|
|
410
|
+
* during delivery are logged but never block shutdown. The whole
|
|
411
|
+
* notify phase is capped at 5s so a hung Telegram send can't hold
|
|
412
|
+
* the process hostage.
|
|
134
413
|
*/
|
|
135
|
-
export function cancelAllSubAgents() {
|
|
414
|
+
export async function cancelAllSubAgents(notify = true) {
|
|
415
|
+
const deliveryPromises = [];
|
|
416
|
+
// Iterate once: for each running agent (1) abort the SDK stream,
|
|
417
|
+
// (2) synthesise and store a cancelled SubAgentResult, (3) mark
|
|
418
|
+
// delivered=true so runSubAgent.finally() can't fire a second
|
|
419
|
+
// delivery on the next microtask, (4) queue the I3 delivery.
|
|
420
|
+
const runningEntries = [];
|
|
136
421
|
for (const [id, entry] of activeAgents) {
|
|
137
|
-
if (entry.info.status
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
422
|
+
if (entry.info.status !== "running")
|
|
423
|
+
continue;
|
|
424
|
+
entry.abort.abort();
|
|
425
|
+
entry.info.status = "cancelled";
|
|
426
|
+
const cancelResult = {
|
|
427
|
+
id,
|
|
428
|
+
name: entry.info.name,
|
|
429
|
+
status: "cancelled",
|
|
430
|
+
output: "⚠️ Agent wurde durch Bot-Restart unterbrochen. Bitte neu triggern.",
|
|
431
|
+
tokensUsed: { input: 0, output: 0 },
|
|
432
|
+
duration: Date.now() - entry.info.startedAt,
|
|
433
|
+
};
|
|
434
|
+
entry.result = cancelResult;
|
|
435
|
+
entry.delivered = true;
|
|
436
|
+
runningEntries.push({ id, info: entry.info, cancelResult });
|
|
437
|
+
}
|
|
438
|
+
if (!notify || runningEntries.length === 0)
|
|
439
|
+
return;
|
|
440
|
+
// Import once, then reuse. Doing one dynamic import per running agent
|
|
441
|
+
// races with Vitest's mock-resolution in tests and can occasionally
|
|
442
|
+
// resolve to the real module instead of the mock for later calls.
|
|
443
|
+
const { deliverSubAgentResult } = await import("./subagent-delivery.js");
|
|
444
|
+
for (const { id, info, cancelResult } of runningEntries) {
|
|
445
|
+
const p = Promise.resolve(deliverSubAgentResult(info, cancelResult)).catch((err) => {
|
|
446
|
+
console.error(`[subagents] shutdown-notify failed for ${id}:`, err);
|
|
447
|
+
});
|
|
448
|
+
deliveryPromises.push(p);
|
|
141
449
|
}
|
|
450
|
+
// Wait up to 5s total — long enough for real Telegram sends, short
|
|
451
|
+
// enough that shutdown isn't held hostage by a hang.
|
|
452
|
+
await Promise.race([
|
|
453
|
+
Promise.all(deliveryPromises),
|
|
454
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
455
|
+
]);
|
|
142
456
|
}
|
|
@@ -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;
|