arisa 5.1.68 → 5.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +46 -6
- package/src/core/agent/agent-session-lifecycle.js +80 -3
- package/src/core/agent/core-tools.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/agent/runtime-context.js +1 -1
- package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
- package/src/core/artifacts/artifact-store.js +1 -1
- package/src/core/capabilities/capability-service.js +1 -1
- package/src/core/config/config-defaults.js +30 -1
- package/src/core/config/config-store.js +1 -1
- package/src/core/conversation/session-seed-store.js +1 -1
- package/src/core/tasks/task-store.js +1 -1
- package/src/core/tools/daemon-client.js +180 -0
- package/src/core/tools/daemon-processes.js +19 -3
- package/src/core/tools/daemon-protocol.js +72 -0
- package/src/core/tools/daemon-runtime.js +13 -490
- package/src/core/tools/daemon-worker.js +310 -0
- package/src/core/tools/ipc-client.js +2 -2
- package/src/core/tools/memory-pressure.js +56 -0
- package/src/core/tools/official-tool-installer.js +1 -1
- package/src/core/tools/tool-config.js +1 -1
- package/src/core/tools/tool-process-output.js +100 -0
- package/src/core/tools/tool-process-runner.js +175 -0
- package/src/core/tools/tool-registry.js +99 -187
- package/src/core/tools/tool-resource-note-store.js +1 -1
- package/src/core/tools/tool-usage-store.js +1 -1
- package/src/core/tools/weighted-resource-governor.js +188 -38
- package/src/index.js +14 -2
- package/src/official-tools.lock.json +424 -50
- package/src/platform/paths.js +152 -0
- package/src/runtime/bootstrap-cli.js +121 -0
- package/src/runtime/bootstrap-config.js +97 -0
- package/src/runtime/bootstrap-telegram.js +325 -0
- package/src/runtime/bootstrap.js +6 -543
- package/src/runtime/doctor.js +6 -3
- package/src/runtime/flush.js +1 -1
- package/src/runtime/ipc/ipc-server.js +1 -1
- package/src/runtime/log-viewer.js +1 -1
- package/src/runtime/oom-protection.js +20 -0
- package/src/runtime/paths.js +3 -151
- package/src/runtime/restart-receipt.js +1 -1
- package/src/runtime/service-manager.js +1 -1
- package/src/runtime/service-supervisor.js +14 -0
- package/src/runtime/slave-cli.js +1 -1
- package/src/runtime/tool-process-supervisor.js +1 -1
- package/src/runtime/tui.js +200 -0
- package/src/runtime/update-manager.js +1 -1
- package/src/runtime/worker-recovery-report.js +142 -0
- package/src/transport/telegram/bot.js +42 -320
- package/src/transport/telegram/prompt-builders.js +8 -3
- package/src/transport/telegram/telegram-prompt-controller.js +346 -0
- package/src/transport/telegram/workspace-topic-store.js +1 -1
- package/test/agent-session-lifecycle.test.js +92 -0
- package/test/architecture-boundaries.test.js +29 -0
- package/test/bootstrap.test.js +65 -0
- package/test/daemon-process-invocation.test.js +27 -0
- package/test/daemon-runtime.test.js +36 -1
- package/test/doctor.test.js +22 -0
- package/test/memory-pressure.test.js +36 -0
- package/test/model-selection.test.js +11 -1
- package/test/official-tool-dependencies.test.js +1 -1
- package/test/official-tool-installer.test.js +18 -1
- package/test/oom-protection.test.js +32 -0
- package/test/paths.test.js +7 -0
- package/test/pi-compaction.test.js +9 -0
- package/test/service-manager.test.js +6 -1
- package/test/telegram-prompt-controller.test.js +81 -0
- package/test/telegram-text-artifact.test.js +30 -0
- package/test/tool-registry-run.test.js +108 -4
- package/test/tui.test.js +41 -0
- package/test/weighted-resource-governor.test.js +97 -5
- package/test/worker-heap-circuit-breaker.test.js +79 -0
- package/test/worker-recovery-report.test.js +69 -0
- package/test-fixtures/fake-daemon.js +5 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { memoryPressureReason, readMemoryPressure } from "../src/core/tools/memory-pressure.js";
|
|
4
|
+
|
|
5
|
+
test("reads Linux available memory, swap pressure, and worker RSS", async () => {
|
|
6
|
+
const snapshot = await readMemoryPressure({
|
|
7
|
+
platform: "linux",
|
|
8
|
+
readMemInfo: async () => [
|
|
9
|
+
"MemTotal: 1000000 kB",
|
|
10
|
+
"MemAvailable: 200000 kB",
|
|
11
|
+
"SwapTotal: 500000 kB",
|
|
12
|
+
"SwapFree: 100000 kB"
|
|
13
|
+
].join("\n"),
|
|
14
|
+
freeMemory: () => 1,
|
|
15
|
+
totalMemory: () => 2,
|
|
16
|
+
processMemory: () => ({ rss: 123 * 1024 * 1024 })
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(snapshot.availableBytes, 200_000 * 1024);
|
|
20
|
+
assert.equal(snapshot.swapUsedPercent, 80);
|
|
21
|
+
assert.equal(snapshot.workerRssBytes, 123 * 1024 * 1024);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("classifies each configured memory pressure boundary", () => {
|
|
25
|
+
const policy = { maxWorkerRssMb: 384, maxSwapUsedPercent: 95 };
|
|
26
|
+
assert.match(memoryPressureReason({
|
|
27
|
+
workerRssBytes: 400 * 1024 * 1024,
|
|
28
|
+
swapTotalBytes: 0,
|
|
29
|
+
swapUsedPercent: 0
|
|
30
|
+
}, policy), /worker RSS/);
|
|
31
|
+
assert.match(memoryPressureReason({
|
|
32
|
+
workerRssBytes: 100 * 1024 * 1024,
|
|
33
|
+
swapTotalBytes: 100,
|
|
34
|
+
swapUsedPercent: 96
|
|
35
|
+
}, policy), /swap use/);
|
|
36
|
+
});
|
|
@@ -302,7 +302,17 @@ test("centralizes Telegram and Pi defaults in config", () => {
|
|
|
302
302
|
assert.equal(config.telegram.busyMessageMode, "steer");
|
|
303
303
|
assert.equal(config.toolExecution.defaultCapacity, toolExecutionConfigDefaults.defaultCapacity);
|
|
304
304
|
assert.equal(config.toolExecution.maxQueuedPerClass, 100);
|
|
305
|
-
assert.
|
|
305
|
+
assert.equal(config.toolExecution.maxWorkerRssMb, 384);
|
|
306
|
+
assert.equal(config.toolExecution.maxSwapUsedPercent, 95);
|
|
307
|
+
assert.equal(config.toolExecution.initialToolMemoryMb, 384);
|
|
308
|
+
assert.equal(config.toolExecution.minimumToolMemoryMb, 128);
|
|
309
|
+
assert.equal(config.toolExecution.maximumToolMemoryMb, 4096);
|
|
310
|
+
assert.equal(config.toolExecution.systemReserveMb, 128);
|
|
311
|
+
assert.equal(config.toolExecution.coreReserveMb, 384);
|
|
312
|
+
assert.equal(config.toolExecution.toolHeapPercent, 65);
|
|
313
|
+
assert.equal(config.toolExecution.toolMemoryHighPercent, 85);
|
|
314
|
+
assert.equal(config.toolExecution.toolSwapMaxMb, 128);
|
|
315
|
+
assert.deepEqual(config.toolExecution.capacities, { browser: 1, orchestrator: 1 });
|
|
306
316
|
assert.equal(config.pi.thinkingLevel, piConfigDefaults.thinkingLevel);
|
|
307
317
|
assert.equal(config.pi.speed, piConfigDefaults.speed);
|
|
308
318
|
});
|
|
@@ -12,7 +12,7 @@ test("official orchestrators declare their hard tool dependencies", async () =>
|
|
|
12
12
|
"pr-campaign": "^0.1.0",
|
|
13
13
|
"gmail-workspace": "^0.1.0"
|
|
14
14
|
});
|
|
15
|
-
assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.
|
|
15
|
+
assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.4.0" });
|
|
16
16
|
assert.deepEqual((await manifest("x-dm")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
|
|
17
17
|
assert.deepEqual((await manifest("x-session-reader")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
|
|
18
18
|
assert.deepEqual((await manifest("official-tool-sync")).toolDependencies, { trash: "^1.0.0" });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -60,6 +60,23 @@ test("verifies the exact file set and digests", async (t) => {
|
|
|
60
60
|
await assert.rejects(() => verifyOfficialToolTree(source, files), /unexpected=extra.js/);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("every catalog tool is represented in the bundled lock", async () => {
|
|
64
|
+
const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
|
|
65
|
+
const toolsDir = fileURLToPath(new URL("../../tools/", import.meta.url));
|
|
66
|
+
const entries = await readdir(toolsDir, { withFileTypes: true });
|
|
67
|
+
const catalogNames = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (!entry.isDirectory()) continue;
|
|
70
|
+
try {
|
|
71
|
+
await readFile(path.join(toolsDir, entry.name, "tool.manifest.json"), "utf8");
|
|
72
|
+
catalogNames.push(entry.name);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.code !== "ENOENT") throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
assert.deepEqual(Object.keys(lock.tools).sort(), catalogNames.sort());
|
|
78
|
+
});
|
|
79
|
+
|
|
63
80
|
test("every bundled official tool lock matches the catalog source", async () => {
|
|
64
81
|
const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
|
|
65
82
|
for (const [name, entry] of Object.entries(lock.tools)) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { protectCoreFromOom } from "../src/runtime/oom-protection.js";
|
|
4
|
+
|
|
5
|
+
test("lowers Linux core OOM priority when permitted", async () => {
|
|
6
|
+
const writes = [];
|
|
7
|
+
assert.equal(await protectCoreFromOom({
|
|
8
|
+
platform: "linux",
|
|
9
|
+
score: -900,
|
|
10
|
+
writeScore: async (value) => writes.push(value)
|
|
11
|
+
}), true);
|
|
12
|
+
assert.deepEqual(writes, [-900]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("keeps running when core OOM priority cannot be changed", async () => {
|
|
16
|
+
const logs = [];
|
|
17
|
+
assert.equal(await protectCoreFromOom({
|
|
18
|
+
platform: "linux",
|
|
19
|
+
writeScore: async () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); },
|
|
20
|
+
logger: { log: (...parts) => logs.push(parts.join(" ")) }
|
|
21
|
+
}), false);
|
|
22
|
+
assert.match(logs.join("\n"), /could not be lowered: EACCES/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("does not touch OOM controls outside Linux", async () => {
|
|
26
|
+
let called = false;
|
|
27
|
+
assert.equal(await protectCoreFromOom({
|
|
28
|
+
platform: "darwin",
|
|
29
|
+
writeScore: async () => { called = true; }
|
|
30
|
+
}), false);
|
|
31
|
+
assert.equal(called, false);
|
|
32
|
+
});
|
package/test/paths.test.js
CHANGED
|
@@ -17,9 +17,16 @@ import {
|
|
|
17
17
|
getToolStateDir,
|
|
18
18
|
stateDir
|
|
19
19
|
} from "../src/runtime/paths.js";
|
|
20
|
+
import * as publicPaths from "../src/runtime/paths.js";
|
|
21
|
+
import * as platformPaths from "../src/platform/paths.js";
|
|
20
22
|
|
|
21
23
|
const execFileAsync = promisify(execFile);
|
|
22
24
|
|
|
25
|
+
test("runtime paths remains an exact compatibility facade for platform paths", () => {
|
|
26
|
+
assert.deepEqual(Object.keys(publicPaths).sort(), Object.keys(platformPaths).sort());
|
|
27
|
+
for (const name of Object.keys(platformPaths)) assert.equal(publicPaths[name], platformPaths[name]);
|
|
28
|
+
});
|
|
29
|
+
|
|
23
30
|
test("keeps chat artifact paths scoped below the chat directory", () => {
|
|
24
31
|
const artifactsDir = getChatArtifactsDir("chat-1");
|
|
25
32
|
|
|
@@ -14,6 +14,15 @@ test("provides Pi compaction defaults through Arisa config", () => {
|
|
|
14
14
|
assert.deepEqual(config.pi.compaction, piConfigDefaults.compaction);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
test("merges partial resident session cache overrides with defaults", () => {
|
|
18
|
+
const config = applyConfigDefaults({ pi: { sessionCache: { maxSessions: 2 } } });
|
|
19
|
+
|
|
20
|
+
assert.deepEqual(config.pi.sessionCache, {
|
|
21
|
+
maxSessions: 2,
|
|
22
|
+
maxPersistedBytes: 48 * 1024 * 1024
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
17
26
|
test("merges partial Pi compaction overrides with defaults", () => {
|
|
18
27
|
const config = applyConfigDefaults({
|
|
19
28
|
pi: { compaction: { reserveTokens: 8_192 } }
|
|
@@ -243,6 +243,7 @@ test("accepts restart handoff from a worker owned by the active supervisor", asy
|
|
|
243
243
|
test("supervisor restarts an unexpectedly exited worker and forwards shutdown", async () => {
|
|
244
244
|
const children = [];
|
|
245
245
|
const delays = [];
|
|
246
|
+
const reports = [];
|
|
246
247
|
const spawnProcess = () => {
|
|
247
248
|
const child = new EventEmitter();
|
|
248
249
|
child.pid = 100 + children.length;
|
|
@@ -261,13 +262,17 @@ test("supervisor restarts an unexpectedly exited worker and forwards shutdown",
|
|
|
261
262
|
restartBackoffMaxMs: 20,
|
|
262
263
|
stableRuntimeMs: 60_000,
|
|
263
264
|
spawnProcess,
|
|
264
|
-
wait: async (ms) => { delays.push(ms); }
|
|
265
|
+
wait: async (ms) => { delays.push(ms); },
|
|
266
|
+
onUnexpectedExit: async (report) => { reports.push(report); }
|
|
265
267
|
});
|
|
266
268
|
const running = supervisor.start();
|
|
267
269
|
children[0].emit("exit", 1, null);
|
|
268
270
|
await new Promise((resolve) => setImmediate(resolve));
|
|
269
271
|
assert.equal(children.length, 2);
|
|
270
272
|
assert.deepEqual(delays, [5]);
|
|
273
|
+
assert.equal(reports.length, 1);
|
|
274
|
+
assert.equal(reports[0].code, 1);
|
|
275
|
+
assert.equal(reports[0].restartDelayMs, 5);
|
|
271
276
|
await supervisor.stop();
|
|
272
277
|
await running;
|
|
273
278
|
assert.equal(children[1].killedWith, "SIGTERM");
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createChatStateStore } from "../src/transport/telegram/chat-queue.js";
|
|
4
|
+
import { createTelegramPromptController } from "../src/transport/telegram/telegram-prompt-controller.js";
|
|
5
|
+
|
|
6
|
+
function createController(overrides = {}) {
|
|
7
|
+
const stateStore = createChatStateStore();
|
|
8
|
+
const calls = { cleared: [], reset: [], steered: [] };
|
|
9
|
+
const controller = createTelegramPromptController({
|
|
10
|
+
config: { pi: { chatModels: {} }, telegram: {} },
|
|
11
|
+
api: {},
|
|
12
|
+
artifactStore: {},
|
|
13
|
+
toolRegistry: {},
|
|
14
|
+
agentManager: {
|
|
15
|
+
resetSession: (...args) => calls.reset.push(args)
|
|
16
|
+
},
|
|
17
|
+
sessionSeeds: {
|
|
18
|
+
clear: async (chatId) => calls.cleared.push(chatId)
|
|
19
|
+
},
|
|
20
|
+
workspaceTopics: {},
|
|
21
|
+
contextRoute: (ctx) => ctx.route,
|
|
22
|
+
getChatState: (chatId) => stateStore.get(chatId),
|
|
23
|
+
createTelegramSessionBridge: () => ({}),
|
|
24
|
+
createWorkspaceAccessGuard: () => async () => {},
|
|
25
|
+
sendTextReply: async () => {},
|
|
26
|
+
authController: { notifyIssueIfNeeded: async () => false },
|
|
27
|
+
ensureWorkspaceTopicModelSelection: async () => {},
|
|
28
|
+
ensureQueuedTyping: async () => {},
|
|
29
|
+
withTyping: async (_ctx, work) => work(),
|
|
30
|
+
resolveBusyMessageMode: () => "steer",
|
|
31
|
+
...overrides
|
|
32
|
+
});
|
|
33
|
+
return { controller, stateStore, calls };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("busy /new resets only the active session and replaces its queued prompt", async () => {
|
|
37
|
+
const { controller, stateStore, calls } = createController();
|
|
38
|
+
const route = {
|
|
39
|
+
workspace: true,
|
|
40
|
+
sessionId: "topic-87",
|
|
41
|
+
scopeChatId: "owner",
|
|
42
|
+
transportChatId: "group",
|
|
43
|
+
threadId: 87
|
|
44
|
+
};
|
|
45
|
+
const state = stateStore.get(route.sessionId);
|
|
46
|
+
state.processing = true;
|
|
47
|
+
state.pendingPrompts.push("stale prompt");
|
|
48
|
+
|
|
49
|
+
await controller.handleNewCommand({ route, from: { language_code: "es" } });
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(calls.cleared, [route.sessionId]);
|
|
52
|
+
assert.deepEqual(calls.reset, [[route.sessionId]]);
|
|
53
|
+
assert.equal(state.pendingPrompts.length, 1);
|
|
54
|
+
assert.match(state.pendingPrompts[0], /System event: \/new requested/);
|
|
55
|
+
assert.equal(state.continueAfterClose, true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a busy prompt for another topic queues instead of steering the active session", async () => {
|
|
59
|
+
const { controller, stateStore, calls } = createController();
|
|
60
|
+
const state = stateStore.get("topic-session");
|
|
61
|
+
state.processing = true;
|
|
62
|
+
state.activeRoute = { transportChatId: "group", threadId: 87 };
|
|
63
|
+
state.activeSession = {
|
|
64
|
+
isStreaming: true,
|
|
65
|
+
steer: async (prompt) => calls.steered.push(prompt)
|
|
66
|
+
};
|
|
67
|
+
const ctx = {
|
|
68
|
+
route: { transportChatId: "group", threadId: 114 }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await controller.enqueuePrompt({
|
|
72
|
+
chatId: "topic-session",
|
|
73
|
+
prompt: "different destination",
|
|
74
|
+
label: "cross-topic prompt",
|
|
75
|
+
ctx,
|
|
76
|
+
busyMessageMode: "steer"
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(calls.steered, []);
|
|
80
|
+
assert.deepEqual(state.pendingPrompts, ["different destination"]);
|
|
81
|
+
});
|
|
@@ -110,6 +110,36 @@ test("surfaces Telegram forwarding provenance in the prompt", () => {
|
|
|
110
110
|
assert.match(prompt, /forwardedAt: 2026-/);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test("surfaces Telegram selected quote text when the replied message has no body", () => {
|
|
114
|
+
const ctx = createTextContext("update that too");
|
|
115
|
+
ctx.message.reply_to_message = {
|
|
116
|
+
message_id: 824,
|
|
117
|
+
from: { username: "ArisaWaybot" }
|
|
118
|
+
};
|
|
119
|
+
ctx.message.quote = { text: "master-slave 0.1.9" };
|
|
120
|
+
|
|
121
|
+
const prompt = buildPrompt({ ctx });
|
|
122
|
+
|
|
123
|
+
assert.match(prompt, /quotedMessageId: 824/);
|
|
124
|
+
assert.match(prompt, /quotedSelection: master-slave 0\.1\.9/);
|
|
125
|
+
assert.doesNotMatch(prompt, /no textual body available/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("surfaces quoted Telegram forum topic metadata", () => {
|
|
129
|
+
const ctx = createTextContext("continue here");
|
|
130
|
+
ctx.message.reply_to_message = {
|
|
131
|
+
message_id: 824,
|
|
132
|
+
from: { username: "ArisaWaybot" },
|
|
133
|
+
forum_topic_created: { name: "storybot" }
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const prompt = buildPrompt({ ctx });
|
|
137
|
+
|
|
138
|
+
assert.match(prompt, /quotedKind: forum_topic_created/);
|
|
139
|
+
assert.match(prompt, /quotedTopicName: storybot/);
|
|
140
|
+
assert.doesNotMatch(prompt, /no textual body available/);
|
|
141
|
+
});
|
|
142
|
+
|
|
113
143
|
test("formats Telegram reaction changes as lightweight feedback", () => {
|
|
114
144
|
const prompt = buildReactionPrompt({
|
|
115
145
|
reaction: {
|
|
@@ -8,7 +8,9 @@ const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-tool-registry-home-"
|
|
|
8
8
|
process.env.HOME = homeDir;
|
|
9
9
|
process.env.USERPROFILE = homeDir;
|
|
10
10
|
|
|
11
|
-
const { ToolRegistry, createToolOutputParser } = await import("../src/core/tools/tool-registry.js");
|
|
11
|
+
const { ToolRegistry, createToolOutputParser, isolatedToolProcessInvocation } = await import("../src/core/tools/tool-registry.js");
|
|
12
|
+
const { createToolOutputParser: directToolOutputParser } = await import("../src/core/tools/tool-process-output.js");
|
|
13
|
+
const { isolatedToolProcessInvocation: directToolProcessInvocation } = await import("../src/core/tools/tool-process-runner.js");
|
|
12
14
|
const {
|
|
13
15
|
arisaHomeDir,
|
|
14
16
|
arisaPackageDir,
|
|
@@ -98,6 +100,33 @@ setInterval(() => {}, 1_000);
|
|
|
98
100
|
return dir;
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
test("preserves process helpers through the ToolRegistry compatibility facade", () => {
|
|
104
|
+
assert.equal(createToolOutputParser, directToolOutputParser);
|
|
105
|
+
assert.equal(isolatedToolProcessInvocation, directToolProcessInvocation);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("wraps declared Linux tool processes in a memory-limited cgroup", () => {
|
|
109
|
+
assert.deepEqual(isolatedToolProcessInvocation(
|
|
110
|
+
["--max-old-space-size=192", "/tool/index.js", "run"],
|
|
111
|
+
{ maxMemoryMb: 384 },
|
|
112
|
+
{ platform: "linux", systemdAvailable: true, oomAdjustAvailable: true }
|
|
113
|
+
), {
|
|
114
|
+
command: "systemd-run",
|
|
115
|
+
args: [
|
|
116
|
+
"--scope", "--quiet", "--collect", "--slice=arisa-tools.slice",
|
|
117
|
+
"-p", "MemoryHigh=326M",
|
|
118
|
+
"-p", "MemoryMax=384M",
|
|
119
|
+
"-p", "MemorySwapMax=128M",
|
|
120
|
+
"--", "choom", "-n", "500", "--", "node", "--max-old-space-size=192", "/tool/index.js", "run"
|
|
121
|
+
],
|
|
122
|
+
isolated: true
|
|
123
|
+
});
|
|
124
|
+
assert.equal(isolatedToolProcessInvocation(["tool.js"], { maxMemoryMb: 384 }, {
|
|
125
|
+
platform: "darwin",
|
|
126
|
+
systemdAvailable: false
|
|
127
|
+
}).isolated, false);
|
|
128
|
+
});
|
|
129
|
+
|
|
101
130
|
test("loads and lists installed tools from the user tools directory", async () => {
|
|
102
131
|
await resetHome();
|
|
103
132
|
await createFakeTool("fake-tool", {
|
|
@@ -172,7 +201,10 @@ test("loads weighted execution metadata from the tool manifest", async () => {
|
|
|
172
201
|
assert.deepEqual(registry.get("heavy-tool").execution, {
|
|
173
202
|
resourceClass: "browser",
|
|
174
203
|
weight: 2,
|
|
175
|
-
deduplicateConcurrent: false
|
|
204
|
+
deduplicateConcurrent: false,
|
|
205
|
+
maxHeapMb: 4096,
|
|
206
|
+
maxMemoryMb: 16_384,
|
|
207
|
+
maxOutputBytes: 1_048_576
|
|
176
208
|
});
|
|
177
209
|
});
|
|
178
210
|
|
|
@@ -232,7 +264,14 @@ test("wraps declared tool runs in the shared execution governor", async () => {
|
|
|
232
264
|
assert.deepEqual(calls, [
|
|
233
265
|
{
|
|
234
266
|
type: "acquire",
|
|
235
|
-
execution: {
|
|
267
|
+
execution: {
|
|
268
|
+
resourceClass: "browser",
|
|
269
|
+
weight: 1,
|
|
270
|
+
deduplicateConcurrent: false,
|
|
271
|
+
maxHeapMb: 4096,
|
|
272
|
+
maxMemoryMb: 16_384,
|
|
273
|
+
maxOutputBytes: 1_048_576
|
|
274
|
+
},
|
|
236
275
|
label: "heavy-tool"
|
|
237
276
|
},
|
|
238
277
|
{ type: "release", label: "heavy-tool" }
|
|
@@ -252,7 +291,13 @@ await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
252
291
|
process.stdout.write(JSON.stringify({ ok: true, output: { text: request.args.value } }));
|
|
253
292
|
`, "utf8");
|
|
254
293
|
const counterFile = path.join(homeDir, "single-flight-count.txt");
|
|
255
|
-
const registry = new ToolRegistry({
|
|
294
|
+
const registry = new ToolRegistry({
|
|
295
|
+
executionPolicy: {
|
|
296
|
+
capacities: { orchestrator: 1 },
|
|
297
|
+
maxWorkerRssMb: 4096,
|
|
298
|
+
maxSwapUsedPercent: 100
|
|
299
|
+
}
|
|
300
|
+
});
|
|
256
301
|
await registry.load();
|
|
257
302
|
const invocation = {
|
|
258
303
|
name: "single-flight-tool",
|
|
@@ -398,6 +443,57 @@ test("parses fragmented NDJSON incrementally and keeps stderr diagnostic-only",
|
|
|
398
443
|
assert.doesNotMatch(JSON.stringify(events), /stream diagnostic/);
|
|
399
444
|
});
|
|
400
445
|
|
|
446
|
+
test("contains an isolated tool heap failure and keeps the registry alive", async () => {
|
|
447
|
+
await resetHome();
|
|
448
|
+
const dir = await createFakeTool("heap-bomb-tool", {
|
|
449
|
+
execution: {
|
|
450
|
+
resourceClass: "browser",
|
|
451
|
+
weight: 1,
|
|
452
|
+
maxHeapMb: 64,
|
|
453
|
+
maxMemoryMb: 128,
|
|
454
|
+
maxOutputBytes: 65_536
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
await writeFile(path.join(dir, "index.js"), `
|
|
458
|
+
const retained = [];
|
|
459
|
+
while (true) retained.push(new Array(1_000_000).fill("heap-pressure"));
|
|
460
|
+
`, "utf8");
|
|
461
|
+
await createFakeTool("healthy-after-oom");
|
|
462
|
+
const registry = new ToolRegistry({
|
|
463
|
+
runTimeoutMs: 30_000,
|
|
464
|
+
executionPolicy: { maxWorkerRssMb: 4096, maxSwapUsedPercent: 100 }
|
|
465
|
+
});
|
|
466
|
+
await registry.load();
|
|
467
|
+
|
|
468
|
+
const failed = await registry.run({ name: "heap-bomb-tool", chatId: "chat-1", request: { args: {} } });
|
|
469
|
+
assert.equal(failed.ok, false);
|
|
470
|
+
assert.equal(failed.status, "outcome_uncertain");
|
|
471
|
+
|
|
472
|
+
const healthy = await registry.run({ name: "healthy-after-oom", chatId: "chat-1", request: { args: {} } });
|
|
473
|
+
assert.equal(healthy.ok, true);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("terminates oversized isolated tool output without taking down the registry", async () => {
|
|
477
|
+
await resetHome();
|
|
478
|
+
const dir = await createFakeTool("oversized-tool", {
|
|
479
|
+
execution: { resourceClass: "browser", weight: 1, maxOutputBytes: 65_536 }
|
|
480
|
+
});
|
|
481
|
+
await writeFile(path.join(dir, "index.js"), `process.stdout.write("x".repeat(70_000));`, "utf8");
|
|
482
|
+
await createFakeTool("healthy-tool");
|
|
483
|
+
const registry = new ToolRegistry({
|
|
484
|
+
executionPolicy: { maxWorkerRssMb: 4096, maxSwapUsedPercent: 100 }
|
|
485
|
+
});
|
|
486
|
+
await registry.load();
|
|
487
|
+
|
|
488
|
+
const oversized = await registry.run({ name: "oversized-tool", chatId: "chat-1", request: { args: {} } });
|
|
489
|
+
assert.equal(oversized.ok, false);
|
|
490
|
+
assert.equal(oversized.status, "outcome_uncertain");
|
|
491
|
+
assert.match(oversized.error, /exceeds 65536 bytes/);
|
|
492
|
+
|
|
493
|
+
const healthy = await registry.run({ name: "healthy-tool", chatId: "chat-1", request: { args: {} } });
|
|
494
|
+
assert.equal(healthy.ok, true);
|
|
495
|
+
});
|
|
496
|
+
|
|
401
497
|
test("rejects invalid NDJSON sequences and a second terminal event", async () => {
|
|
402
498
|
const invalidSequence = createToolOutputParser("sequence-tool");
|
|
403
499
|
await invalidSequence.push(`${JSON.stringify({ version: 1, jobId: "job", type: "accepted", sequence: 1, payload: {} })}\n`);
|
|
@@ -414,6 +510,14 @@ test("rejects invalid NDJSON sequences and a second terminal event", async () =>
|
|
|
414
510
|
);
|
|
415
511
|
});
|
|
416
512
|
|
|
513
|
+
test("bounds accumulated legacy output before parsing", async () => {
|
|
514
|
+
const parser = createToolOutputParser("legacy-tool", { maxOutputBytes: 10 });
|
|
515
|
+
await assert.rejects(
|
|
516
|
+
() => parser.push("12345678901"),
|
|
517
|
+
(error) => error.code === "TOOL_OUTPUT_LIMIT"
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
|
|
417
521
|
test("preserves pretty-printed single JSON tool responses", async () => {
|
|
418
522
|
const parser = createToolOutputParser("legacy-tool");
|
|
419
523
|
const output = JSON.stringify({ ok: true, output: { text: "legacy" } }, null, 2);
|
package/test/tui.test.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createTuiCapabilityTools, resolveTuiChatId } from "../src/runtime/tui.js";
|
|
4
|
+
|
|
5
|
+
test("uses the first authorized owner scope for TUI capabilities", () => {
|
|
6
|
+
assert.equal(resolveTuiChatId({ telegram: { authorizedChatIds: [879964957, 2] } }), 879964957);
|
|
7
|
+
assert.throws(() => resolveTuiChatId({ telegram: { authorizedChatIds: [] } }), /authorized chat/);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("adapts Pi TUI tools to the running Arisa IPC service", async () => {
|
|
11
|
+
const calls = [];
|
|
12
|
+
const client = {
|
|
13
|
+
tools: {
|
|
14
|
+
list: async (params) => { calls.push(["list", params]); return { tools: [] }; },
|
|
15
|
+
help: async () => "help",
|
|
16
|
+
skills: async () => [],
|
|
17
|
+
setConfig: async () => ({ ok: true }),
|
|
18
|
+
run: async () => ({ ok: true })
|
|
19
|
+
},
|
|
20
|
+
tasks: {
|
|
21
|
+
list: async () => [],
|
|
22
|
+
cancel: async () => ({ ok: true }),
|
|
23
|
+
cancelAll: async () => ({ ok: true })
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const tools = createTuiCapabilityTools(client);
|
|
27
|
+
assert.deepEqual(tools.map((tool) => tool.name), [
|
|
28
|
+
"list_tools",
|
|
29
|
+
"tool_help",
|
|
30
|
+
"tool_skills",
|
|
31
|
+
"set_tool_config",
|
|
32
|
+
"run_tool",
|
|
33
|
+
"list_scheduled_tasks",
|
|
34
|
+
"cancel_scheduled_task",
|
|
35
|
+
"cancel_all_scheduled_tasks"
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const result = await tools[0].execute("call", { query: "email" });
|
|
39
|
+
assert.deepEqual(calls, [["list", { query: "email" }]]);
|
|
40
|
+
assert.match(result.content[0].text, /"tools"/);
|
|
41
|
+
});
|
|
@@ -12,11 +12,24 @@ function deferred() {
|
|
|
12
12
|
return { promise, resolve };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const safeMemoryPressure = async () => ({
|
|
16
|
+
availableBytes: 512 * 1024 * 1024,
|
|
17
|
+
totalBytes: 4 * 1024 * 1024 * 1024,
|
|
18
|
+
workerRssBytes: 100 * 1024 * 1024,
|
|
19
|
+
swapTotalBytes: 1024,
|
|
20
|
+
swapUsedPercent: 50
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
|
24
|
+
|
|
15
25
|
test("normalizes manifest weights and configurable class capacities", () => {
|
|
16
26
|
assert.deepEqual(normalizeToolExecution({ resourceClass: "browser", weight: 2 }), {
|
|
17
27
|
resourceClass: "browser",
|
|
18
28
|
weight: 2,
|
|
19
|
-
deduplicateConcurrent: false
|
|
29
|
+
deduplicateConcurrent: false,
|
|
30
|
+
maxHeapMb: 4096,
|
|
31
|
+
maxMemoryMb: 16_384,
|
|
32
|
+
maxOutputBytes: 1_048_576
|
|
20
33
|
});
|
|
21
34
|
assert.equal(normalizeToolExecution({
|
|
22
35
|
resourceClass: "orchestrator",
|
|
@@ -33,6 +46,16 @@ test("normalizes manifest weights and configurable class capacities", () => {
|
|
|
33
46
|
}), {
|
|
34
47
|
defaultCapacity: 3,
|
|
35
48
|
maxQueuedPerClass: 12,
|
|
49
|
+
maxWorkerRssMb: 384,
|
|
50
|
+
maxSwapUsedPercent: 95,
|
|
51
|
+
initialToolMemoryMb: 384,
|
|
52
|
+
minimumToolMemoryMb: 128,
|
|
53
|
+
maximumToolMemoryMb: 4096,
|
|
54
|
+
systemReserveMb: 128,
|
|
55
|
+
coreReserveMb: 384,
|
|
56
|
+
toolHeapPercent: 65,
|
|
57
|
+
toolMemoryHighPercent: 85,
|
|
58
|
+
toolSwapMaxMb: 128,
|
|
36
59
|
capacities: { browser: 4 }
|
|
37
60
|
});
|
|
38
61
|
});
|
|
@@ -42,13 +65,15 @@ test("queues weighted work fairly within one resource class", async () => {
|
|
|
42
65
|
const governor = new WeightedResourceGovernor({
|
|
43
66
|
policy: { defaultCapacity: 2 },
|
|
44
67
|
now: () => time,
|
|
45
|
-
memoryUsage: () => ({ rss: 100 * 1024 * 1024 })
|
|
68
|
+
memoryUsage: () => ({ rss: 100 * 1024 * 1024 }),
|
|
69
|
+
memoryPressure: safeMemoryPressure
|
|
46
70
|
});
|
|
47
71
|
const execution = { resourceClass: "browser", weight: 1 };
|
|
48
72
|
const first = await governor.acquire(execution, "first");
|
|
49
73
|
const second = await governor.acquire(execution, "second");
|
|
50
74
|
const thirdLease = governor.acquire(execution, "third");
|
|
51
75
|
const fourthLease = governor.acquire(execution, "fourth");
|
|
76
|
+
await settle();
|
|
52
77
|
|
|
53
78
|
assert.deepEqual(governor.snapshot().resources.browser, {
|
|
54
79
|
capacity: 2,
|
|
@@ -72,26 +97,52 @@ test("queues weighted work fairly within one resource class", async () => {
|
|
|
72
97
|
assert.equal(governor.snapshot().resources.browser.activeWeight, 0);
|
|
73
98
|
});
|
|
74
99
|
|
|
75
|
-
test("undeclared
|
|
76
|
-
const governor = new WeightedResourceGovernor({ policy: { defaultCapacity: 1 } });
|
|
100
|
+
test("undeclared work bypasses the optional resource governor", async () => {
|
|
101
|
+
const governor = new WeightedResourceGovernor({ policy: { defaultCapacity: 1 }, memoryPressure: safeMemoryPressure });
|
|
77
102
|
const heavy = await governor.acquire({ resourceClass: "browser", weight: 1 }, "heavy");
|
|
78
103
|
const queued = governor.acquire({ resourceClass: "browser", weight: 1 }, "queued");
|
|
104
|
+
await settle();
|
|
79
105
|
const light = await governor.acquire(null, "light");
|
|
80
106
|
assert.equal(light.waitedMs, 0);
|
|
107
|
+
assert.equal(light.memoryLimitMb, undefined);
|
|
108
|
+
assert.equal(light.heapLimitMb, undefined);
|
|
81
109
|
assert.equal(governor.snapshot().resources.browser.queued, 1);
|
|
82
110
|
light.release();
|
|
83
111
|
heavy.release();
|
|
84
112
|
(await queued).release();
|
|
85
113
|
});
|
|
86
114
|
|
|
115
|
+
test("rejects declared heavy tools before spawn when memory pressure is unsafe", async () => {
|
|
116
|
+
const logs = [];
|
|
117
|
+
const governor = new WeightedResourceGovernor({
|
|
118
|
+
policy: { maxWorkerRssMb: 384, maxSwapUsedPercent: 95 },
|
|
119
|
+
memoryPressure: async () => ({
|
|
120
|
+
availableBytes: 80 * 1024 * 1024,
|
|
121
|
+
workerRssBytes: 400 * 1024 * 1024,
|
|
122
|
+
swapTotalBytes: 1024,
|
|
123
|
+
swapUsedPercent: 50
|
|
124
|
+
}),
|
|
125
|
+
logger: { log: (...parts) => logs.push(parts.join(" ")) }
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
() => governor.acquire({ resourceClass: "browser", weight: 1 }, "web-browser"),
|
|
130
|
+
(error) => error.code === "TOOL_RESOURCE_PRESSURE" && /was not started/.test(error.message)
|
|
131
|
+
);
|
|
132
|
+
assert.match(logs.join("\n"), /web-browser rejected for browser/);
|
|
133
|
+
assert.equal(governor.snapshot().resources.browser.activeWeight, 0);
|
|
134
|
+
});
|
|
135
|
+
|
|
87
136
|
test("larger weights consume shared capacity and worker RSS peaks are retained", async () => {
|
|
88
137
|
let rss = 120 * 1024 * 1024;
|
|
89
138
|
const governor = new WeightedResourceGovernor({
|
|
90
139
|
policy: { defaultCapacity: 3 },
|
|
91
|
-
memoryUsage: () => ({ rss })
|
|
140
|
+
memoryUsage: () => ({ rss }),
|
|
141
|
+
memoryPressure: safeMemoryPressure
|
|
92
142
|
});
|
|
93
143
|
const large = await governor.acquire({ resourceClass: "browser", weight: 2 }, "large");
|
|
94
144
|
const waiting = governor.acquire({ resourceClass: "browser", weight: 2 }, "waiting");
|
|
145
|
+
await settle();
|
|
95
146
|
assert.equal(governor.snapshot().resources.browser.queued, 1);
|
|
96
147
|
rss = 180 * 1024 * 1024;
|
|
97
148
|
large.release();
|
|
@@ -99,3 +150,44 @@ test("larger weights consume shared capacity and worker RSS peaks are retained",
|
|
|
99
150
|
assert.equal(governor.snapshot().peakRssBytes, rss);
|
|
100
151
|
next.release();
|
|
101
152
|
});
|
|
153
|
+
|
|
154
|
+
test("shares one host memory budget across independent resource classes", async () => {
|
|
155
|
+
const pressure = async () => ({
|
|
156
|
+
availableBytes: 512 * 1024 * 1024,
|
|
157
|
+
totalBytes: 1024 * 1024 * 1024,
|
|
158
|
+
workerRssBytes: 100 * 1024 * 1024,
|
|
159
|
+
swapTotalBytes: 0,
|
|
160
|
+
swapUsedPercent: 0
|
|
161
|
+
});
|
|
162
|
+
const governor = new WeightedResourceGovernor({
|
|
163
|
+
policy: { systemReserveMb: 128, coreReserveMb: 384, initialToolMemoryMb: 384 },
|
|
164
|
+
memoryPressure: pressure
|
|
165
|
+
});
|
|
166
|
+
const browser = await governor.acquire({ resourceClass: "browser" }, "browser");
|
|
167
|
+
const queued = governor.acquire({ resourceClass: "orchestrator" }, "orchestrator");
|
|
168
|
+
await settle();
|
|
169
|
+
|
|
170
|
+
assert.equal(governor.snapshot().memory.budgetMb, 512);
|
|
171
|
+
assert.equal(governor.snapshot().memory.activeMb, 384);
|
|
172
|
+
assert.equal(governor.snapshot().resources.orchestrator.queued, 1);
|
|
173
|
+
|
|
174
|
+
browser.release({ success: true });
|
|
175
|
+
const orchestrator = await queued;
|
|
176
|
+
assert.equal(orchestrator.memoryLimitMb, 384);
|
|
177
|
+
orchestrator.release({ success: true });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("raises a tool memory recommendation after an isolated limit failure", async () => {
|
|
181
|
+
const governor = new WeightedResourceGovernor({
|
|
182
|
+
policy: { initialToolMemoryMb: 256 },
|
|
183
|
+
memoryPressure: safeMemoryPressure
|
|
184
|
+
});
|
|
185
|
+
const first = await governor.acquire({}, "growing-tool");
|
|
186
|
+
assert.equal(first.memoryLimitMb, 256);
|
|
187
|
+
first.release({ memoryLimited: true });
|
|
188
|
+
|
|
189
|
+
const second = await governor.acquire({}, "growing-tool");
|
|
190
|
+
assert.equal(second.memoryLimitMb, 384);
|
|
191
|
+
assert.equal(governor.snapshot().memory.profiles["growing-tool"].memoryLimitFailures, 1);
|
|
192
|
+
second.release({ success: true });
|
|
193
|
+
});
|