arisa 5.1.13 → 5.1.49
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/AGENTS.md +4 -0
- package/README.md +2 -0
- package/package.json +8 -10
- package/src/core/agent/agent-manager.js +132 -70
- package/src/core/agent/pi-runtime.js +0 -8
- package/src/core/agent/system-shell-tool.js +13 -2
- package/src/core/artifacts/artifact-store.js +17 -18
- package/src/core/config/config-defaults.js +2 -2
- package/src/core/conversation/session-seed-store.js +85 -0
- package/src/core/tools/ipc-client.js +0 -2
- package/src/core/tools/official-tool-installer.js +78 -6
- package/src/core/tools/tool-dependencies.js +99 -0
- package/src/core/tools/tool-output-materializer.js +41 -0
- package/src/core/tools/tool-registry.js +145 -28
- package/src/official-tools.lock.json +209 -5
- package/src/runtime/arisa-capabilities.js +12 -1
- package/src/runtime/create-app.js +1 -0
- package/src/runtime/doctor.js +72 -23
- package/src/runtime/headless-tool-executor.js +2 -32
- package/src/runtime/paths.js +7 -1
- package/src/runtime/restart-receipt.js +90 -0
- package/src/runtime/tool-usage-report.js +25 -10
- package/src/transport/telegram/bot.js +403 -1015
- package/src/transport/telegram/chat-queue.js +132 -0
- package/src/transport/telegram/media.js +2 -2
- package/src/transport/telegram/model-callback.js +211 -0
- package/src/transport/telegram/model-controls.js +164 -0
- package/src/transport/telegram/prompt-builders.js +372 -0
- package/src/transport/telegram/task-dispatcher.js +94 -0
- package/src/transport/telegram/update-command.js +1 -1
- package/src/transport/telegram/workspace-group.js +83 -0
- package/test/agent-tool-policy.test.js +7 -1
- package/test/capabilities-security.test.js +21 -0
- package/test/context-and-task-bounds.test.js +33 -5
- package/test/doctor.test.js +57 -4
- package/test/model-selection.test.js +47 -1
- package/test/official-tool-dependencies.test.js +25 -0
- package/test/official-tool-installer.test.js +37 -9
- package/test/paths.test.js +4 -4
- package/test/restart-receipt.test.js +39 -0
- package/test/session-start-operational-notes.test.js +47 -0
- package/test/telegram-prompt-builders.test.js +33 -0
- package/test/telegram-task-dispatcher.test.js +102 -0
- package/test/telegram-workspace-group.test.js +76 -0
- package/test/tool-dependencies.test.js +53 -0
- package/test/tool-registry-run.test.js +81 -1
- package/test/tool-usage.test.js +26 -4
- package/test/topic-initialization.test.js +66 -0
- package/src/core/conversation/conversation-history-store.js +0 -142
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { getToolDir } from "../../runtime/paths.js";
|
|
17
|
+
import { normalizeToolDependencies, resolveToolDependencyPlan, satisfiesToolVersion } from "./tool-dependencies.js";
|
|
17
18
|
|
|
18
19
|
const bundledLockFile = new URL("../../official-tools.lock.json", import.meta.url);
|
|
19
20
|
|
|
@@ -46,7 +47,8 @@ export function validateOfficialToolLock(lock, toolName) {
|
|
|
46
47
|
if (!COMMIT_PATTERN.test(String(lock.commit || ""))) {
|
|
47
48
|
throw new Error("Official tool lock requires an immutable 40-character commit");
|
|
48
49
|
}
|
|
49
|
-
const
|
|
50
|
+
const entry = lock.tools?.[toolName];
|
|
51
|
+
const files = entry?.files;
|
|
50
52
|
if (!files || typeof files !== "object" || Array.isArray(files) || !Object.keys(files).length) {
|
|
51
53
|
throw new Error(`Official tool lock has no files for ${toolName}`);
|
|
52
54
|
}
|
|
@@ -56,7 +58,12 @@ export function validateOfficialToolLock(lock, toolName) {
|
|
|
56
58
|
throw new Error(`Invalid SHA-256 digest for ${toolName}/${file}`);
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
|
-
|
|
61
|
+
const toolDependencies = normalizeToolDependencies(entry.toolDependencies);
|
|
62
|
+
const toolVersion = entry.version == null ? null : String(entry.version);
|
|
63
|
+
if (toolVersion && !satisfiesToolVersion(toolVersion, toolVersion)) {
|
|
64
|
+
throw new Error(`Invalid locked tool version for ${toolName}: ${toolVersion}`);
|
|
65
|
+
}
|
|
66
|
+
return { repository: lock.repository, commit: lock.commit, files, toolVersion, toolDependencies };
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
async function walkFiles(root, relative = "") {
|
|
@@ -133,11 +140,36 @@ async function checkoutRepository({ repository, commit, checkoutDir }) {
|
|
|
133
140
|
if (resolved !== commit) throw new Error(`Official tool checkout resolved ${resolved}, expected ${commit}`);
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
async function
|
|
143
|
+
async function installPackageDependencies(toolDir) {
|
|
144
|
+
let packageJson;
|
|
145
|
+
try {
|
|
146
|
+
packageJson = JSON.parse(await readFile(path.join(toolDir, "package.json"), "utf8"));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error?.code === "ENOENT") return { installed: false };
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
const dependencyCount = Object.keys(packageJson.dependencies || {}).length + Object.keys(packageJson.optionalDependencies || {}).length;
|
|
152
|
+
if (!dependencyCount) return { installed: false };
|
|
153
|
+
if (await exists(path.join(toolDir, "package-lock.json"))) {
|
|
154
|
+
await runCommand("npm", ["ci", "--omit=dev"], { cwd: toolDir });
|
|
155
|
+
} else {
|
|
156
|
+
await runCommand("npm", ["install", "--omit=dev"], { cwd: toolDir });
|
|
157
|
+
}
|
|
158
|
+
return { installed: true, dependencyCount };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function validateEntrypoint(toolDir, toolName, locked) {
|
|
137
162
|
const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
|
|
138
163
|
if (manifest.name !== toolName) {
|
|
139
164
|
throw new Error(`Official tool manifest mismatch: expected ${toolName}, got ${manifest.name || "missing"}`);
|
|
140
165
|
}
|
|
166
|
+
if (locked.toolVersion && manifest.version !== locked.toolVersion) {
|
|
167
|
+
throw new Error(`Official tool version mismatch: expected ${locked.toolVersion}, got ${manifest.version || "missing"}`);
|
|
168
|
+
}
|
|
169
|
+
const manifestDependencies = normalizeToolDependencies(manifest.toolDependencies);
|
|
170
|
+
if (JSON.stringify(manifestDependencies) !== JSON.stringify(locked.toolDependencies)) {
|
|
171
|
+
throw new Error(`Official tool dependency metadata mismatch: ${toolName}`);
|
|
172
|
+
}
|
|
141
173
|
const entry = manifest.entry || "index.js";
|
|
142
174
|
const entryPath = path.join(toolDir, entry);
|
|
143
175
|
if (!(await exists(entryPath))) throw new Error(`Official tool entry does not exist: ${entry}`);
|
|
@@ -151,6 +183,7 @@ export async function installLockedOfficialTool({
|
|
|
151
183
|
destination,
|
|
152
184
|
scratchRoot = os.tmpdir(),
|
|
153
185
|
checkout = checkoutRepository,
|
|
186
|
+
installDependencies = installPackageDependencies,
|
|
154
187
|
validate = validateEntrypoint
|
|
155
188
|
}) {
|
|
156
189
|
const locked = validateOfficialToolLock(lock, toolName);
|
|
@@ -165,7 +198,8 @@ export async function installLockedOfficialTool({
|
|
|
165
198
|
const sourceDir = path.join(checkoutDir, "tools", toolName);
|
|
166
199
|
await verifyOfficialToolTree(sourceDir, locked.files);
|
|
167
200
|
await cp(sourceDir, stageDir, { recursive: true, errorOnExist: true, force: false });
|
|
168
|
-
await
|
|
201
|
+
await installDependencies(stageDir, toolName);
|
|
202
|
+
await validate(stageDir, toolName, locked);
|
|
169
203
|
await mkdir(path.dirname(destination), { recursive: true });
|
|
170
204
|
await rename(stageDir, destination);
|
|
171
205
|
return { toolName, destination, commit: locked.commit, files: Object.keys(locked.files).length };
|
|
@@ -174,10 +208,48 @@ export async function installLockedOfficialTool({
|
|
|
174
208
|
}
|
|
175
209
|
}
|
|
176
210
|
|
|
211
|
+
async function installedToolVersion(toolName) {
|
|
212
|
+
try {
|
|
213
|
+
const manifest = JSON.parse(await readFile(path.join(getToolDir(toolName), "tool.manifest.json"), "utf8"));
|
|
214
|
+
return typeof manifest.version === "string" ? manifest.version : null;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error?.code === "ENOENT") return undefined;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
177
221
|
export async function installBundledOfficialTool(toolName, {
|
|
178
222
|
lockFile = bundledLockFile,
|
|
179
|
-
install = installLockedOfficialTool
|
|
223
|
+
install = installLockedOfficialTool,
|
|
224
|
+
resolveInstalledVersion = installedToolVersion
|
|
180
225
|
} = {}) {
|
|
181
226
|
const lock = JSON.parse(await readFile(lockFile, "utf8"));
|
|
182
|
-
|
|
227
|
+
const entries = new Map(Object.entries(lock.tools || {}).map(([name, entry]) => [name, {
|
|
228
|
+
version: entry.version || null,
|
|
229
|
+
toolDependencies: normalizeToolDependencies(entry.toolDependencies)
|
|
230
|
+
}]));
|
|
231
|
+
const plan = resolveToolDependencyPlan(entries, toolName);
|
|
232
|
+
const dependencies = [];
|
|
233
|
+
let result = null;
|
|
234
|
+
for (const name of plan) {
|
|
235
|
+
const locked = validateOfficialToolLock(lock, name);
|
|
236
|
+
if (name !== toolName) {
|
|
237
|
+
const installedVersion = await resolveInstalledVersion(name);
|
|
238
|
+
if (installedVersion !== undefined) {
|
|
239
|
+
const requiredRanges = plan
|
|
240
|
+
.map((dependentName) => entries.get(dependentName)?.toolDependencies?.[name])
|
|
241
|
+
.filter(Boolean);
|
|
242
|
+
const incompatibleRange = requiredRanges.find((range) => !satisfiesToolVersion(installedVersion, range));
|
|
243
|
+
if (incompatibleRange || (!requiredRanges.length && locked.toolVersion !== installedVersion)) {
|
|
244
|
+
throw new Error(`Installed tool dependency is incompatible: ${name}@${installedVersion} does not satisfy ${incompatibleRange || locked.toolVersion || "locked version"}`);
|
|
245
|
+
}
|
|
246
|
+
dependencies.push({ name, version: installedVersion, status: "already-installed" });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const installed = await install({ toolName: name, lock, destination: getToolDir(name) });
|
|
251
|
+
if (name === toolName) result = installed;
|
|
252
|
+
else dependencies.push({ name, version: locked.toolVersion, status: "installed" });
|
|
253
|
+
}
|
|
254
|
+
return { ...result, dependencies };
|
|
183
255
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const TOOL_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2
|
+
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
3
|
+
const RANGE_PATTERN = /^(\^)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
4
|
+
|
|
5
|
+
function versionParts(version) {
|
|
6
|
+
const match = VERSION_PATTERN.exec(String(version || ""));
|
|
7
|
+
return match ? match.slice(1).map(Number) : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function compareVersions(left, right) {
|
|
11
|
+
for (let index = 0; index < 3; index += 1) {
|
|
12
|
+
if (left[index] !== right[index]) return left[index] - right[index];
|
|
13
|
+
}
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeToolDependencies(value) {
|
|
18
|
+
if (value == null) return {};
|
|
19
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new Error("toolDependencies must be an object");
|
|
20
|
+
const normalized = {};
|
|
21
|
+
for (const [name, range] of Object.entries(value)) {
|
|
22
|
+
if (!TOOL_NAME_PATTERN.test(name)) throw new Error(`Invalid tool dependency name: ${name}`);
|
|
23
|
+
if (!RANGE_PATTERN.test(String(range || ""))) throw new Error(`Unsupported tool dependency range for ${name}: ${range || "empty"}`);
|
|
24
|
+
normalized[name] = String(range);
|
|
25
|
+
}
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function satisfiesToolVersion(version, range) {
|
|
30
|
+
const actual = versionParts(version);
|
|
31
|
+
const match = RANGE_PATTERN.exec(String(range || ""));
|
|
32
|
+
if (!actual || !match) return false;
|
|
33
|
+
const expected = match.slice(2).map(Number);
|
|
34
|
+
if (!match[1]) return compareVersions(actual, expected) === 0;
|
|
35
|
+
if (compareVersions(actual, expected) < 0) return false;
|
|
36
|
+
if (expected[0] > 0) return actual[0] === expected[0];
|
|
37
|
+
if (expected[1] > 0) return actual[0] === 0 && actual[1] === expected[1];
|
|
38
|
+
return actual[0] === 0 && actual[1] === 0 && actual[2] === expected[2];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function inspectToolDependencies(tools, rootName = null) {
|
|
42
|
+
const issues = [];
|
|
43
|
+
const visiting = new Set();
|
|
44
|
+
const visited = new Set();
|
|
45
|
+
const visit = (name, chain = []) => {
|
|
46
|
+
if (visiting.has(name)) {
|
|
47
|
+
issues.push({ tool: name, type: "cycle", dependency: name, chain: [...chain, name] });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (visited.has(name)) return;
|
|
51
|
+
const tool = tools.get(name);
|
|
52
|
+
if (!tool) return;
|
|
53
|
+
visiting.add(name);
|
|
54
|
+
for (const [dependency, range] of Object.entries(normalizeToolDependencies(tool.toolDependencies))) {
|
|
55
|
+
const installed = tools.get(dependency);
|
|
56
|
+
if (!installed) {
|
|
57
|
+
issues.push({ tool: name, type: "missing", dependency, range });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!satisfiesToolVersion(installed.version, range)) {
|
|
61
|
+
issues.push({ tool: name, type: "incompatible", dependency, range, installedVersion: installed.version || null });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
visit(dependency, [...chain, name]);
|
|
65
|
+
}
|
|
66
|
+
visiting.delete(name);
|
|
67
|
+
visited.add(name);
|
|
68
|
+
};
|
|
69
|
+
if (rootName) visit(rootName);
|
|
70
|
+
else for (const name of tools.keys()) visit(name);
|
|
71
|
+
return issues;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveToolDependencyPlan(entries, rootName) {
|
|
75
|
+
const tools = entries instanceof Map ? entries : new Map(Object.entries(entries || {}));
|
|
76
|
+
const order = [];
|
|
77
|
+
const visiting = new Set();
|
|
78
|
+
const visited = new Set();
|
|
79
|
+
const visit = (name, chain = []) => {
|
|
80
|
+
const tool = tools.get(name);
|
|
81
|
+
if (!tool) throw new Error(`Official tool dependency is not locked: ${name}`);
|
|
82
|
+
if (visiting.has(name)) throw new Error(`Circular official tool dependency: ${[...chain, name].join(" -> ")}`);
|
|
83
|
+
if (visited.has(name)) return;
|
|
84
|
+
visiting.add(name);
|
|
85
|
+
for (const [dependency, range] of Object.entries(normalizeToolDependencies(tool.toolDependencies))) {
|
|
86
|
+
const lockedDependency = tools.get(dependency);
|
|
87
|
+
if (!lockedDependency) throw new Error(`Official tool dependency is not locked: ${name} -> ${dependency}`);
|
|
88
|
+
if (!satisfiesToolVersion(lockedDependency.version, range)) {
|
|
89
|
+
throw new Error(`Locked tool dependency is incompatible: ${name} requires ${dependency}@${range}, lock has ${lockedDependency.version || "no version"}`);
|
|
90
|
+
}
|
|
91
|
+
visit(dependency, [...chain, name]);
|
|
92
|
+
}
|
|
93
|
+
visiting.delete(name);
|
|
94
|
+
visited.add(name);
|
|
95
|
+
order.push(name);
|
|
96
|
+
};
|
|
97
|
+
visit(rootName);
|
|
98
|
+
return order;
|
|
99
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export async function materializeToolOutput({ result, name, chatId, artifactStore, taskStore, taskContext = null }) {
|
|
5
|
+
const chatArtifactStore = artifactStore.forChat(chatId);
|
|
6
|
+
|
|
7
|
+
if (result.output?.text) {
|
|
8
|
+
const artifact = await chatArtifactStore.createText({
|
|
9
|
+
text: result.output.text,
|
|
10
|
+
source: { type: "tool", toolName: name },
|
|
11
|
+
metadata: { tool: name }
|
|
12
|
+
});
|
|
13
|
+
result.output.artifactId = artifact.id;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (result.output?.filePath) {
|
|
17
|
+
const generated = await chatArtifactStore.createFromFile({
|
|
18
|
+
originalPath: result.output.filePath,
|
|
19
|
+
fileName: result.output.fileName || path.basename(result.output.filePath),
|
|
20
|
+
kind: result.output.kind || "file",
|
|
21
|
+
mimeType: result.output.mimeType || "application/octet-stream",
|
|
22
|
+
source: { type: "tool", toolName: name },
|
|
23
|
+
metadata: { tool: name, delivery: result.output.delivery }
|
|
24
|
+
});
|
|
25
|
+
result.output.artifactId = generated.id;
|
|
26
|
+
await unlink(result.output.filePath).catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (result.asyncTask || result.asyncTasks?.length) {
|
|
30
|
+
result.asyncTasks = await taskStore.addMany(result.asyncTasks || [result.asyncTask], {
|
|
31
|
+
payload: {
|
|
32
|
+
chatId,
|
|
33
|
+
...(taskContext ? { telegramContext: taskContext } : {})
|
|
34
|
+
},
|
|
35
|
+
source: { type: "tool", toolName: name, chatId }
|
|
36
|
+
});
|
|
37
|
+
delete result.asyncTask;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from "node:fs/prom
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
|
|
5
|
+
import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolStateDir, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
|
|
6
6
|
import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
|
|
7
7
|
import { normalizeToolResult } from "./tool-result.js";
|
|
8
8
|
import { readDaemonDiagnostic } from "./daemon-processes.js";
|
|
@@ -10,22 +10,59 @@ import { createDaemonRuntime, DAEMON_EVENT_TYPES, DAEMON_PROTOCOL_VERSION } from
|
|
|
10
10
|
import { daemonConfigDefaults } from "../config/config-defaults.js";
|
|
11
11
|
import { SkillRegistry } from "../skills/skill-registry.js";
|
|
12
12
|
import { ToolUsageStore } from "./tool-usage-store.js";
|
|
13
|
+
import { inspectToolDependencies, normalizeToolDependencies } from "./tool-dependencies.js";
|
|
13
14
|
|
|
14
15
|
function toolEnv() {
|
|
15
16
|
return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir, ARISA_IPC_SOCKET: arisaIpcSocketFile };
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
const defaultToolHelpTimeoutMs = 10_000;
|
|
20
|
+
const defaultToolRunTimeoutMs = 30 * 60_000;
|
|
21
|
+
const defaultToolKillGraceMs = 2_000;
|
|
22
|
+
|
|
23
|
+
function positiveDuration(value, fallback) {
|
|
24
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function waitForToolProcess(child, { timeoutMs, killGraceMs, label }) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
let timedOut = false;
|
|
30
|
+
let forceTimer = null;
|
|
31
|
+
const timeout = setTimeout(() => {
|
|
32
|
+
timedOut = true;
|
|
33
|
+
child.kill("SIGTERM");
|
|
34
|
+
forceTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
|
|
35
|
+
}, timeoutMs);
|
|
36
|
+
|
|
37
|
+
const finish = (callback, value) => {
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
clearTimeout(forceTimer);
|
|
40
|
+
callback(value);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
child.once("error", (error) => finish(reject, error));
|
|
44
|
+
child.once("close", (code) => {
|
|
45
|
+
if (!timedOut) {
|
|
46
|
+
finish(resolve, code);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
|
|
50
|
+
error.code = "TOOL_PROCESS_TIMEOUT";
|
|
51
|
+
finish(reject, error);
|
|
52
|
+
});
|
|
26
53
|
});
|
|
27
54
|
}
|
|
28
55
|
|
|
56
|
+
async function runProcess(command, args, { timeoutMs, killGraceMs, label, ...options } = {}) {
|
|
57
|
+
const child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
58
|
+
let stdout = "";
|
|
59
|
+
let stderr = "";
|
|
60
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
61
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
62
|
+
const code = await waitForToolProcess(child, { timeoutMs, killGraceMs, label });
|
|
63
|
+
return { code, stdout, stderr };
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
function requirementNames(requirements) {
|
|
30
67
|
if (Array.isArray(requirements)) {
|
|
31
68
|
return requirements.map((item) => typeof item === "string" ? item : item?.name).filter(Boolean);
|
|
@@ -120,7 +157,7 @@ export function createToolOutputParser(name, { onEvent, maxFrameBytes = 1_048_57
|
|
|
120
157
|
};
|
|
121
158
|
}
|
|
122
159
|
|
|
123
|
-
async function runToolProcess(command, args, { onEvent, maxFrameBytes, ...options } = {}) {
|
|
160
|
+
async function runToolProcess(command, args, { onEvent, maxFrameBytes, timeoutMs, killGraceMs, label, ...options } = {}) {
|
|
124
161
|
const child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
125
162
|
const parser = createToolOutputParser(path.basename(args[0] || command), { onEvent, maxFrameBytes });
|
|
126
163
|
const stderrChunks = [];
|
|
@@ -138,13 +175,15 @@ async function runToolProcess(command, args, { onEvent, maxFrameBytes, ...option
|
|
|
138
175
|
}
|
|
139
176
|
return Buffer.concat(stderrChunks).toString("utf8");
|
|
140
177
|
})();
|
|
141
|
-
const exitPromise = new Promise((resolve, reject) => {
|
|
142
|
-
child.once("error", reject);
|
|
143
|
-
child.once("close", resolve);
|
|
144
|
-
});
|
|
145
178
|
child.stdout.resume();
|
|
146
179
|
child.stderr.resume();
|
|
147
|
-
|
|
180
|
+
let code;
|
|
181
|
+
try {
|
|
182
|
+
code = await waitForToolProcess(child, { timeoutMs, killGraceMs, label });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
await Promise.allSettled([stdoutTask, stderrTask]);
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
148
187
|
const [parsed, stderr] = await Promise.all([stdoutTask, stderrTask]);
|
|
149
188
|
return { code, parsed, stderr };
|
|
150
189
|
}
|
|
@@ -203,17 +242,50 @@ function formatSemanticMetadata(tool) {
|
|
|
203
242
|
].join("\n");
|
|
204
243
|
}
|
|
205
244
|
|
|
245
|
+
function formatToolDependencies(tool, tools) {
|
|
246
|
+
const dependencies = Object.entries(tool.toolDependencies || {});
|
|
247
|
+
if (!dependencies.length) return null;
|
|
248
|
+
const lines = dependencies.map(([name, range]) => {
|
|
249
|
+
const issue = inspectToolDependencies(tools, tool.name).find((item) => item.dependency === name);
|
|
250
|
+
return `- ${name}@${range}: ${issue ? issue.type : "ready"}`;
|
|
251
|
+
});
|
|
252
|
+
return `Tool dependencies:\n${lines.join("\n")}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function readOfficialToolNames() {
|
|
256
|
+
const baselinesDir = path.join(getToolStateDir("official-tool-sync"), "baselines");
|
|
257
|
+
try {
|
|
258
|
+
const entries = await readdir(baselinesDir, { withFileTypes: true });
|
|
259
|
+
return new Set(entries
|
|
260
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
261
|
+
.map((entry) => entry.name.slice(0, -5)));
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code === "ENOENT") return new Set();
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
206
268
|
export class ToolRegistry {
|
|
207
|
-
constructor({
|
|
269
|
+
constructor({
|
|
270
|
+
logger,
|
|
271
|
+
usageStore = new ToolUsageStore(),
|
|
272
|
+
resolveOfficialToolNames = readOfficialToolNames,
|
|
273
|
+
helpTimeoutMs = defaultToolHelpTimeoutMs,
|
|
274
|
+
runTimeoutMs = defaultToolRunTimeoutMs,
|
|
275
|
+
killGraceMs = defaultToolKillGraceMs
|
|
276
|
+
} = {}) {
|
|
208
277
|
this.logger = logger;
|
|
278
|
+
this.helpTimeoutMs = positiveDuration(helpTimeoutMs, defaultToolHelpTimeoutMs);
|
|
279
|
+
this.runTimeoutMs = positiveDuration(runTimeoutMs, defaultToolRunTimeoutMs);
|
|
280
|
+
this.killGraceMs = positiveDuration(killGraceMs, defaultToolKillGraceMs);
|
|
209
281
|
this.tools = new Map();
|
|
210
282
|
this.skillRegistry = new SkillRegistry();
|
|
211
283
|
this.usageStore = usageStore;
|
|
284
|
+
this.resolveOfficialToolNames = resolveOfficialToolNames;
|
|
212
285
|
}
|
|
213
286
|
|
|
214
|
-
async
|
|
215
|
-
|
|
216
|
-
|
|
287
|
+
async buildSnapshot() {
|
|
288
|
+
const snapshot = new Map();
|
|
217
289
|
let entries = [];
|
|
218
290
|
try {
|
|
219
291
|
entries = await readdir(userToolsRoot, { withFileTypes: true });
|
|
@@ -228,13 +300,14 @@ export class ToolRegistry {
|
|
|
228
300
|
const configPath = path.join(toolDir, "config.js");
|
|
229
301
|
try {
|
|
230
302
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
231
|
-
if (
|
|
303
|
+
if (snapshot.has(manifest.name)) continue;
|
|
232
304
|
const configSource = await readFile(configPath, "utf8");
|
|
233
305
|
const defaults = parseConfigModule(configSource);
|
|
234
306
|
const config = await loadToolConfig(manifest.name, defaults);
|
|
235
307
|
const skillHints = this.skillRegistry.normalizeHints(manifest);
|
|
236
|
-
|
|
308
|
+
snapshot.set(manifest.name, {
|
|
237
309
|
...manifest,
|
|
310
|
+
toolDependencies: normalizeToolDependencies(manifest.toolDependencies),
|
|
238
311
|
category: normalizeCategory(manifest.category),
|
|
239
312
|
keywords: normalizeKeywords(manifest.keywords),
|
|
240
313
|
skillHints,
|
|
@@ -249,8 +322,13 @@ export class ToolRegistry {
|
|
|
249
322
|
// ignore invalid tool dirs in v1
|
|
250
323
|
}
|
|
251
324
|
}
|
|
325
|
+
return snapshot;
|
|
326
|
+
}
|
|
252
327
|
|
|
253
|
-
|
|
328
|
+
async load() {
|
|
329
|
+
const snapshot = await this.buildSnapshot();
|
|
330
|
+
this.tools = snapshot;
|
|
331
|
+
this.logger?.log("tools", `loaded ${snapshot.size} tool(s)`);
|
|
254
332
|
}
|
|
255
333
|
|
|
256
334
|
list() {
|
|
@@ -259,6 +337,7 @@ export class ToolRegistry {
|
|
|
259
337
|
version: typeof tool.version === "string" ? tool.version : null,
|
|
260
338
|
packageDigest: typeof tool.packageDigest === "string" ? tool.packageDigest : null,
|
|
261
339
|
requirements: requirementNames(tool.requirements),
|
|
340
|
+
toolDependencies: tool.toolDependencies || {},
|
|
262
341
|
description: tool.description,
|
|
263
342
|
input: tool.input,
|
|
264
343
|
output: tool.output,
|
|
@@ -306,12 +385,19 @@ export class ToolRegistry {
|
|
|
306
385
|
async help(name) {
|
|
307
386
|
const tool = this.get(name);
|
|
308
387
|
if (!tool) throw new Error(`Tool not found: ${name}`);
|
|
309
|
-
const result = await runProcess("node", [tool.entry, "--help"], {
|
|
388
|
+
const result = await runProcess("node", [tool.entry, "--help"], {
|
|
389
|
+
cwd: tool.dir,
|
|
390
|
+
env: toolEnv(),
|
|
391
|
+
timeoutMs: this.helpTimeoutMs,
|
|
392
|
+
killGraceMs: this.killGraceMs,
|
|
393
|
+
label: `Tool help for ${name}`
|
|
394
|
+
});
|
|
310
395
|
const help = result.stdout || result.stderr;
|
|
311
396
|
const skills = await this.resolveSkills(name);
|
|
312
397
|
const sections = [
|
|
313
398
|
help.trimEnd(),
|
|
314
|
-
formatSemanticMetadata(tool)
|
|
399
|
+
formatSemanticMetadata(tool),
|
|
400
|
+
formatToolDependencies(tool, this.tools)
|
|
315
401
|
];
|
|
316
402
|
if (skills.length) {
|
|
317
403
|
const skillHelp = skills.map((item) => [
|
|
@@ -360,16 +446,32 @@ export class ToolRegistry {
|
|
|
360
446
|
return { ok: true, tool: name, field, configPath };
|
|
361
447
|
}
|
|
362
448
|
|
|
449
|
+
dependencyIssues(name = null) {
|
|
450
|
+
return inspectToolDependencies(this.tools, name);
|
|
451
|
+
}
|
|
452
|
+
|
|
363
453
|
async usage(chatId) {
|
|
364
|
-
const counts = await
|
|
365
|
-
|
|
366
|
-
.
|
|
454
|
+
const [counts, officialNames] = await Promise.all([
|
|
455
|
+
this.usageStore.counts(chatId),
|
|
456
|
+
this.resolveOfficialToolNames()
|
|
457
|
+
]);
|
|
458
|
+
const names = new Set([
|
|
459
|
+
...this.list().map((tool) => tool.name),
|
|
460
|
+
...Object.keys(counts)
|
|
461
|
+
]);
|
|
462
|
+
return [...names]
|
|
463
|
+
.map((name) => ({ name, count: counts[name] || 0, official: officialNames.has(name) }))
|
|
367
464
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
368
465
|
}
|
|
369
466
|
|
|
370
467
|
async run({ name, request, chatId = null, onEvent = null }) {
|
|
371
468
|
const tool = this.get(name);
|
|
372
469
|
if (!tool) throw new Error(`Tool not found: ${name}`);
|
|
470
|
+
const dependencyIssue = this.dependencyIssues(name)[0];
|
|
471
|
+
if (dependencyIssue) {
|
|
472
|
+
const version = dependencyIssue.installedVersion ? `; installed ${dependencyIssue.installedVersion}` : "";
|
|
473
|
+
throw new Error(`Tool dependency ${dependencyIssue.type}: ${dependencyIssue.tool} requires ${dependencyIssue.dependency}@${dependencyIssue.range || "valid"}${version}`);
|
|
474
|
+
}
|
|
373
475
|
await this.usageStore.record(chatId, name).catch((error) => {
|
|
374
476
|
this.logger?.error("tools", `could not record ${name} usage: ${error?.message || String(error)}`);
|
|
375
477
|
});
|
|
@@ -399,7 +501,10 @@ export class ToolRegistry {
|
|
|
399
501
|
cwd: tool.dir,
|
|
400
502
|
env: toolEnv(),
|
|
401
503
|
onEvent,
|
|
402
|
-
maxFrameBytes: daemonConfigDefaults.ipcFrameBytes
|
|
504
|
+
maxFrameBytes: daemonConfigDefaults.ipcFrameBytes,
|
|
505
|
+
timeoutMs: this.runTimeoutMs,
|
|
506
|
+
killGraceMs: this.killGraceMs,
|
|
507
|
+
label: `Tool run for ${name}`
|
|
403
508
|
});
|
|
404
509
|
if (processResult.stderr.trim()) {
|
|
405
510
|
this.logger?.log("tools", `${name} stderr: ${processResult.stderr.trim()}`);
|
|
@@ -416,6 +521,18 @@ export class ToolRegistry {
|
|
|
416
521
|
}
|
|
417
522
|
return normalized;
|
|
418
523
|
} catch (error) {
|
|
524
|
+
if (error?.code === "TOOL_PROCESS_TIMEOUT") {
|
|
525
|
+
return normalizeToolResult(name, {
|
|
526
|
+
ok: false,
|
|
527
|
+
status: "outcome_uncertain",
|
|
528
|
+
error: error.message,
|
|
529
|
+
resolution: {
|
|
530
|
+
type: "status_check_required",
|
|
531
|
+
retry: false,
|
|
532
|
+
message: "The tool process was terminated after timing out. Check external state before retrying."
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
419
536
|
return normalizeToolResult(name, {
|
|
420
537
|
ok: false,
|
|
421
538
|
error: error?.message || `Invalid tool response for ${name}`
|