arisa 5.2.7 → 5.2.17
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 +21 -2
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +79 -4
- package/src/core/agent/agent-session-lifecycle.js +29 -6
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/pi-capability-tools.js +7 -4
- package/src/core/agent/session-history-reader.js +168 -0
- package/src/core/agent/session-preload-migration.js +162 -0
- package/src/core/agent/session-rotation.js +29 -0
- package/src/core/agent/worker-tool-fanout.js +117 -0
- package/src/core/capabilities/capability-service.js +3 -2
- package/src/core/config/config-defaults.js +29 -0
- package/src/core/tasks/task-runner.js +8 -4
- package/src/core/tasks/task-store.js +47 -4
- package/src/core/tools/daemon-processes.js +7 -0
- package/src/core/tools/memory-pressure.js +2 -2
- package/src/core/tools/tool-registry.js +35 -7
- package/src/core/tools/weighted-resource-governor.js +7 -6
- package/src/official-tools.lock.json +121 -47
- package/src/runtime/doctor.js +20 -73
- package/src/runtime/obsolete-daemon-reaper.js +43 -0
- package/src/runtime/process-inspection.js +78 -0
- package/src/runtime/tool-process-supervisor.js +35 -8
- package/src/runtime/tui.js +1 -1
- package/src/transport/telegram/bot.js +3 -1
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/task-dispatcher.js +36 -11
- package/src/transport/telegram/telegram-prompt-controller.js +17 -4
- package/test/agent-turn-coordinator.test.js +45 -0
- package/test/context-and-task-bounds.test.js +2 -1
- package/test/daemon-runtime.test.js +2 -4
- package/test/doctor.test.js +19 -0
- package/test/memory-pressure.test.js +7 -2
- package/test/obsolete-daemon-reaper.test.js +61 -0
- package/test/official-tool-dependencies.test.js +7 -2
- package/test/pi-compaction.test.js +21 -0
- package/test/session-history-reader.test.js +84 -0
- package/test/session-preload-migration.test.js +120 -0
- package/test/session-rotation.test.js +110 -0
- package/test/task-store.test.js +32 -0
- package/test/telegram-prompt-controller.test.js +2 -1
- package/test/telegram-task-dispatcher.test.js +66 -5
- package/test/tool-registry-run.test.js +10 -1
- package/test/weighted-resource-governor.test.js +28 -0
- package/test/worker-tool-fanout.test.js +79 -0
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Per chat (`~/.arisa/chats/<chatId>/`):
|
|
|
108
108
|
- chat-scoped daemon infrastructure lives in `state/tools/<tool>/daemon/`; persistent tool data stays beside it
|
|
109
109
|
- ephemeral scratch lives under `tmp/`
|
|
110
110
|
|
|
111
|
-
Managed daemons become ready only after their tool-defined health operation succeeds through the normal command queue. Arisa records heartbeats, successful jobs, errors, and standard lifecycle states, then retries recovery or recreates an unhealthy process with its persisted scope and startup context.
|
|
111
|
+
Managed daemons become ready only after their tool-defined health operation succeeds through the normal command queue. Arisa records heartbeats, successful jobs, errors, and standard lifecycle states, then retries recovery or recreates an unhealthy process with its persisted scope and startup context. The supervisor automatically removes registrations and daemon runtime directories that no longer match an installed daemon tool. A live process is terminated only when its command line matches the registered entry and daemon invocation; unverifiable PIDs are left untouched and reported for attention.
|
|
112
112
|
|
|
113
113
|
Daemon tools may opt into the `arisa-daemon-v1` local protocol for immediate
|
|
114
114
|
multiplexed jobs and incremental NDJSON events over a capability-protected local
|
|
@@ -151,12 +151,31 @@ Automatic context compaction uses Pi's native implementation and can be tuned in
|
|
|
151
151
|
"enabled": true,
|
|
152
152
|
"reserveTokens": 120000,
|
|
153
153
|
"keepRecentTokens": 20000
|
|
154
|
+
},
|
|
155
|
+
"sessionRotation": {
|
|
156
|
+
"enabled": true,
|
|
157
|
+
"compactAtPersistedBytes": 25165824,
|
|
158
|
+
"maxPersistedBytes": 33554432
|
|
159
|
+
},
|
|
160
|
+
"toolFanout": {
|
|
161
|
+
"enabled": true,
|
|
162
|
+
"maxConcurrent": 2,
|
|
163
|
+
"pressureConcurrent": 1,
|
|
164
|
+
"serializePercent": 60
|
|
165
|
+
},
|
|
166
|
+
"turnCoordinator": {
|
|
167
|
+
"enabled": true,
|
|
168
|
+
"backgroundQueueTtlMs": 600000,
|
|
169
|
+
"interactiveQueueTtlMs": 0,
|
|
170
|
+
"maxQueued": 100
|
|
154
171
|
}
|
|
155
172
|
}
|
|
156
173
|
}
|
|
157
174
|
```
|
|
158
175
|
|
|
159
|
-
Pi compacts when the context exceeds the model's context window minus `reserveTokens`.
|
|
176
|
+
Pi compacts when the context exceeds the model's context window minus `reserveTokens`. Arisa also requests compaction when persisted history exceeds `compactAtPersistedBytes`, then rotates to a fresh JSONL using the latest summary and retained active context. Before Pi loads a recent session above `maxPersistedBytes`, Arisa discovers the last valid active-branch compaction by streaming, atomically creates a compact child with `parentSession`, and leaves the historical JSONL intact. An unsafe oversized session is rejected rather than loaded into an OOM-prone worker. Same-turn `run_tool` fan-out is capped at two, becomes sequential when heap usage reaches `serializePercent`, and uses the heap circuit breaker before every admission. Across chats and topics, `turnCoordinator` admits only one Pi turn or headless poll at a time, prioritizes interactive messages and events over queued background work, and safely expires stale background admissions before execution. Set a smaller token reserve when using models with substantially smaller context windows. Arisa adds no Telegram commands or compaction notifications.
|
|
177
|
+
|
|
178
|
+
Browser and checkout tools report lifecycle states separately: profile connection is not a session share, a session share is not target validation, wallet enrollment is not checkout submission, and only explicit target or merchant evidence can confirm the final objective.
|
|
160
179
|
|
|
161
180
|
## Install globally
|
|
162
181
|
|
package/package.json
CHANGED
|
@@ -14,6 +14,9 @@ import { createPiCapabilityTools } from "./pi-capability-tools.js";
|
|
|
14
14
|
import { ToolResourceNoteStore } from "../tools/tool-resource-note-store.js";
|
|
15
15
|
import { materializeToolOutput } from "../tools/tool-output-materializer.js";
|
|
16
16
|
import { WorkerHeapCircuitBreaker } from "./worker-heap-circuit-breaker.js";
|
|
17
|
+
import { WorkerToolFanoutController } from "./worker-tool-fanout.js";
|
|
18
|
+
import { compactionRotationRequest, normalizeSessionRotationPolicy } from "./session-rotation.js";
|
|
19
|
+
import { AgentTurnCoordinator } from "./agent-turn-coordinator.js";
|
|
17
20
|
|
|
18
21
|
const piValidationTimeoutMs = 60_000;
|
|
19
22
|
const arisaToolNames = [
|
|
@@ -156,13 +159,23 @@ export class AgentManager {
|
|
|
156
159
|
this.sessionLifecycle = new AgentSessionLifecycle({
|
|
157
160
|
logger,
|
|
158
161
|
summarizeContext: summarizeRetainedContext,
|
|
159
|
-
cachePolicy: config.pi.sessionCache
|
|
162
|
+
cachePolicy: config.pi.sessionCache,
|
|
163
|
+
sessionRotationPolicy: config.pi.sessionRotation
|
|
160
164
|
});
|
|
161
165
|
this.heapCircuitBreaker = new WorkerHeapCircuitBreaker({
|
|
162
166
|
lifecycle: this.sessionLifecycle,
|
|
163
167
|
logger,
|
|
164
168
|
config: config.pi.heapCircuitBreaker
|
|
165
169
|
});
|
|
170
|
+
this.toolFanout = new WorkerToolFanoutController({
|
|
171
|
+
heapCircuitBreaker: this.heapCircuitBreaker,
|
|
172
|
+
logger,
|
|
173
|
+
config: config.pi.toolFanout
|
|
174
|
+
});
|
|
175
|
+
this.turnCoordinator = new AgentTurnCoordinator({
|
|
176
|
+
logger,
|
|
177
|
+
config: config.pi.turnCoordinator
|
|
178
|
+
});
|
|
166
179
|
this.sessions = this.sessionLifecycle.sessions;
|
|
167
180
|
this.pendingNewSessions = this.sessionLifecycle.pendingNewSessions;
|
|
168
181
|
this.pendingSessionHandoffs = this.sessionLifecycle.pendingSessionHandoffs;
|
|
@@ -196,7 +209,10 @@ export class AgentManager {
|
|
|
196
209
|
setConfig(config) {
|
|
197
210
|
this.sessionLifecycle.resetConfigState();
|
|
198
211
|
this.sessionLifecycle.setCachePolicy(config.pi.sessionCache);
|
|
212
|
+
this.sessionLifecycle.setSessionRotationPolicy(config.pi.sessionRotation);
|
|
199
213
|
this.heapCircuitBreaker.setConfig(config.pi.heapCircuitBreaker);
|
|
214
|
+
this.toolFanout.setConfig(config.pi.toolFanout);
|
|
215
|
+
this.turnCoordinator.setConfig(config.pi.turnCoordinator);
|
|
200
216
|
this.config = config;
|
|
201
217
|
}
|
|
202
218
|
|
|
@@ -222,7 +238,9 @@ export class AgentManager {
|
|
|
222
238
|
const diagnostic = await this.sessionLifecycle.getDiagnostic();
|
|
223
239
|
return {
|
|
224
240
|
...diagnostic,
|
|
225
|
-
heapCircuitBreaker: this.heapCircuitBreaker.getDiagnostic()
|
|
241
|
+
heapCircuitBreaker: this.heapCircuitBreaker.getDiagnostic(),
|
|
242
|
+
toolFanout: this.toolFanout.getDiagnostic(),
|
|
243
|
+
turnCoordinator: this.turnCoordinator.diagnostic()
|
|
226
244
|
};
|
|
227
245
|
}
|
|
228
246
|
|
|
@@ -249,13 +267,60 @@ export class AgentManager {
|
|
|
249
267
|
return context;
|
|
250
268
|
}
|
|
251
269
|
|
|
270
|
+
async compactPersistedSessionIfNeeded(sessionKey, context, persistedBytes) {
|
|
271
|
+
const policy = normalizeSessionRotationPolicy(this.config.pi.sessionRotation);
|
|
272
|
+
if (!policy.enabled
|
|
273
|
+
|| persistedBytes <= policy.compactAtPersistedBytes
|
|
274
|
+
|| context.rotationRequest
|
|
275
|
+
|| typeof context.session?.compact !== "function") return;
|
|
276
|
+
try {
|
|
277
|
+
this.logger?.log("agent", `compacting ${Math.ceil(persistedBytes / 1024 / 1024)} MiB Pi session for chat ${sessionKey} before rotation`);
|
|
278
|
+
await context.session.compact();
|
|
279
|
+
await context.rotationCheckPromise;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
this.logger?.error?.("agent", `persisted-size compaction failed for chat ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
252
285
|
async releaseSessionContext(sessionKey, context) {
|
|
253
286
|
if (this.sessions.get(String(sessionKey)) !== context) return;
|
|
287
|
+
await context.rotationCheckPromise;
|
|
254
288
|
const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
|
|
289
|
+
if ((context.activeUsers || 0) <= 1) {
|
|
290
|
+
await this.compactPersistedSessionIfNeeded(sessionKey, context, persistedBytes);
|
|
291
|
+
}
|
|
255
292
|
this.sessionLifecycle.releaseCached(sessionKey, persistedBytes);
|
|
293
|
+
if ((context.activeUsers || 0) === 0 && context.rotationRequest) {
|
|
294
|
+
const request = context.rotationRequest;
|
|
295
|
+
const parentSession = context.session.sessionFile || "";
|
|
296
|
+
this.logger?.log("agent", `rotating ${Math.ceil(request.persistedBytes / 1024 / 1024)} MiB Pi session for chat ${sessionKey} after compaction`);
|
|
297
|
+
this.sessionLifecycle.resetSession(sessionKey, {
|
|
298
|
+
handoff: request.handoff,
|
|
299
|
+
parentSession,
|
|
300
|
+
source: "compaction-rotation"
|
|
301
|
+
});
|
|
302
|
+
await this.sessionLifecycle.waitForClose(sessionKey);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
256
305
|
await this.sessionLifecycle.enforceCachePolicy();
|
|
257
306
|
}
|
|
258
307
|
|
|
308
|
+
scheduleCompactionRotationCheck(sessionKey, context, event) {
|
|
309
|
+
if (event?.type !== "compaction_end") return;
|
|
310
|
+
const previous = context.rotationCheckPromise || Promise.resolve();
|
|
311
|
+
context.rotationCheckPromise = previous
|
|
312
|
+
.catch(() => {})
|
|
313
|
+
.then(async () => {
|
|
314
|
+
if (this.sessions.get(String(sessionKey)) !== context) return;
|
|
315
|
+
const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
|
|
316
|
+
const request = compactionRotationRequest(event, persistedBytes, this.config.pi.sessionRotation);
|
|
317
|
+
if (request) context.rotationRequest = request;
|
|
318
|
+
})
|
|
319
|
+
.catch((error) => {
|
|
320
|
+
this.logger?.error?.("agent", `session rotation check failed for chat ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
259
324
|
async validatePiAgent(config = this.config) {
|
|
260
325
|
this.logger?.log("agent", "validating Pi session");
|
|
261
326
|
const { authStorage, modelRegistry } = createPiRuntime({
|
|
@@ -402,8 +467,11 @@ export class AgentManager {
|
|
|
402
467
|
modelKey: effectiveModelKey,
|
|
403
468
|
speedController,
|
|
404
469
|
telegramTarget,
|
|
405
|
-
accessGuardTarget
|
|
470
|
+
accessGuardTarget,
|
|
471
|
+
rotationCheckPromise: Promise.resolve(),
|
|
472
|
+
rotationRequest: null
|
|
406
473
|
};
|
|
474
|
+
session.subscribe((event) => this.scheduleCompactionRotationCheck(sessionKey, ctx, event));
|
|
407
475
|
this.sessions.set(sessionKey, ctx);
|
|
408
476
|
if (isNewSession) this.sessionLifecycle.completeNewSession(sessionKey);
|
|
409
477
|
return this.acquireSessionContext(sessionKey, ctx);
|
|
@@ -424,9 +492,15 @@ export class AgentManager {
|
|
|
424
492
|
}
|
|
425
493
|
|
|
426
494
|
async close() {
|
|
495
|
+
this.turnCoordinator.close();
|
|
427
496
|
await this.sessionLifecycle.closeAll();
|
|
428
497
|
}
|
|
429
498
|
|
|
499
|
+
async runTurn(options, work) {
|
|
500
|
+
if (typeof work !== "function") throw new Error("Agent turn work is required");
|
|
501
|
+
return this.turnCoordinator.run(options, work);
|
|
502
|
+
}
|
|
503
|
+
|
|
430
504
|
async runTool({ name, request, chatId, taskContext = null }) {
|
|
431
505
|
await this.toolRegistry.load();
|
|
432
506
|
this.logger?.log("agent", `run_tool ${name}`);
|
|
@@ -453,7 +527,8 @@ export class AgentManager {
|
|
|
453
527
|
telegram,
|
|
454
528
|
chatId,
|
|
455
529
|
policy,
|
|
456
|
-
logger: this.logger
|
|
530
|
+
logger: this.logger,
|
|
531
|
+
toolFanout: this.toolFanout
|
|
457
532
|
});
|
|
458
533
|
}
|
|
459
534
|
|
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { getChatPiSessionsDir, sessionStartOperationalNotesFile } from "../../platform/paths.js";
|
|
4
4
|
import { arisaInstallDir } from "./runtime-context.js";
|
|
5
|
+
import { migrateRecentSessionBeforeLoad } from "./session-preload-migration.js";
|
|
5
6
|
|
|
6
7
|
const operationalNoteMaxChars = 220;
|
|
7
8
|
|
|
@@ -26,7 +27,7 @@ export function loadSessionStartOperationalNotes() {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function formatSessionStartOperationalNotes(notes) {
|
|
30
|
+
export function formatSessionStartOperationalNotes(notes) {
|
|
30
31
|
if (!notes.length) return "";
|
|
31
32
|
return [
|
|
32
33
|
"Durable operating notes for this Arisa session:",
|
|
@@ -41,7 +42,7 @@ function closeAgentSession(session) {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export class AgentSessionLifecycle {
|
|
44
|
-
constructor({ logger, summarizeContext, cachePolicy = {} }) {
|
|
45
|
+
constructor({ logger, summarizeContext, cachePolicy = {}, sessionRotationPolicy = {} }) {
|
|
45
46
|
this.logger = logger;
|
|
46
47
|
this.summarizeContext = summarizeContext;
|
|
47
48
|
this.sessions = new Map();
|
|
@@ -49,6 +50,7 @@ export class AgentSessionLifecycle {
|
|
|
49
50
|
this.pendingSessionHandoffs = new Map();
|
|
50
51
|
this.sessionClosePromises = new Map();
|
|
51
52
|
this.setCachePolicy(cachePolicy);
|
|
53
|
+
this.setSessionRotationPolicy(sessionRotationPolicy);
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
setCachePolicy(cachePolicy = {}) {
|
|
@@ -58,6 +60,10 @@ export class AgentSessionLifecycle {
|
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
setSessionRotationPolicy(policy = {}) {
|
|
64
|
+
this.sessionRotationPolicy = { ...policy };
|
|
65
|
+
}
|
|
66
|
+
|
|
61
67
|
acquireCached(sessionKey, persistedBytes = 0) {
|
|
62
68
|
const context = this.sessions.get(String(sessionKey));
|
|
63
69
|
if (!context) return;
|
|
@@ -161,14 +167,19 @@ export class AgentSessionLifecycle {
|
|
|
161
167
|
this.pendingSessionHandoffs.clear();
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
resetSession(chatId, { handoff = "", parentSession = "" } = {}) {
|
|
170
|
+
resetSession(chatId, { handoff = "", parentSession = "", source = "" } = {}) {
|
|
165
171
|
const sessionKey = String(chatId);
|
|
166
172
|
this.closeCached(sessionKey);
|
|
167
173
|
this.pendingNewSessions.add(sessionKey);
|
|
168
174
|
const text = String(handoff || "").trim();
|
|
169
175
|
const parent = String(parentSession || "").trim();
|
|
176
|
+
const handoffSource = String(source || "").trim();
|
|
170
177
|
if (text || parent) {
|
|
171
|
-
this.pendingSessionHandoffs.set(sessionKey, {
|
|
178
|
+
this.pendingSessionHandoffs.set(sessionKey, {
|
|
179
|
+
text,
|
|
180
|
+
parentSession: parent,
|
|
181
|
+
...(handoffSource ? { source: handoffSource } : {})
|
|
182
|
+
});
|
|
172
183
|
} else {
|
|
173
184
|
this.pendingSessionHandoffs.delete(sessionKey);
|
|
174
185
|
}
|
|
@@ -177,6 +188,7 @@ export class AgentSessionLifecycle {
|
|
|
177
188
|
createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
|
|
178
189
|
const sessionKey = String(chatId);
|
|
179
190
|
const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
|
|
191
|
+
const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
|
|
180
192
|
if (this.pendingNewSessions.has(sessionKey)) {
|
|
181
193
|
this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
|
|
182
194
|
const handoff = this.pendingSessionHandoffs.get(sessionKey);
|
|
@@ -185,7 +197,6 @@ export class AgentSessionLifecycle {
|
|
|
185
197
|
sessionDir,
|
|
186
198
|
handoff?.parentSession ? { parentSession: handoff.parentSession } : undefined
|
|
187
199
|
);
|
|
188
|
-
const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
|
|
189
200
|
if (operationalNotes) {
|
|
190
201
|
sessionManager.appendCustomMessageEntry(
|
|
191
202
|
"arisa-operational-notes",
|
|
@@ -199,11 +210,23 @@ export class AgentSessionLifecycle {
|
|
|
199
210
|
"arisa-session-handoff",
|
|
200
211
|
handoff.text,
|
|
201
212
|
false,
|
|
202
|
-
{ source: "telegram-new" }
|
|
213
|
+
{ source: handoff.source || "telegram-new" }
|
|
203
214
|
);
|
|
204
215
|
}
|
|
205
216
|
return { sessionManager, isNewSession: true };
|
|
206
217
|
}
|
|
218
|
+
const migration = migrateRecentSessionBeforeLoad({
|
|
219
|
+
sessionDir,
|
|
220
|
+
cwd: workspaceDir,
|
|
221
|
+
policy: this.sessionRotationPolicy,
|
|
222
|
+
operationalNotes
|
|
223
|
+
});
|
|
224
|
+
if (migration) {
|
|
225
|
+
this.logger?.log(
|
|
226
|
+
"agent",
|
|
227
|
+
`migrated oversized Pi session for chat ${sessionKey} before loading (${Math.ceil(migration.sourceBytes / 1024 / 1024)} MiB -> ${Math.ceil(migration.targetBytes / 1024 / 1024)} MiB)`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
207
230
|
this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
|
|
208
231
|
return {
|
|
209
232
|
sessionManager: SessionManager.continueRecent(workspaceDir, sessionDir),
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
function normalizedPriority(value) {
|
|
2
|
+
if (value === "interactive") return 10;
|
|
3
|
+
if (value === "background") return 0;
|
|
4
|
+
const parsed = Number(value);
|
|
5
|
+
return Number.isFinite(parsed) ? Math.max(-100, Math.min(100, parsed)) : 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function positiveInteger(value, fallback, max) {
|
|
9
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
10
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.min(max, parsed) : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function queueExpiredError(label) {
|
|
14
|
+
const error = new Error(`${label || "Agent turn"} expired while waiting for exclusive execution.`);
|
|
15
|
+
error.code = "AGENT_TURN_QUEUE_EXPIRED";
|
|
16
|
+
error.retryable = true;
|
|
17
|
+
error.outcomeUncertain = false;
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class AgentTurnCoordinator {
|
|
22
|
+
constructor({ config = {}, logger = null, now = Date.now } = {}) {
|
|
23
|
+
this.logger = logger;
|
|
24
|
+
this.now = now;
|
|
25
|
+
this.queue = [];
|
|
26
|
+
this.active = null;
|
|
27
|
+
this.sequence = 0;
|
|
28
|
+
this.closed = false;
|
|
29
|
+
this.completed = 0;
|
|
30
|
+
this.expired = 0;
|
|
31
|
+
this.maxObservedQueue = 0;
|
|
32
|
+
this.totalWaitMs = 0;
|
|
33
|
+
this.setConfig(config);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setConfig(config = {}) {
|
|
37
|
+
this.config = {
|
|
38
|
+
enabled: config.enabled !== false,
|
|
39
|
+
backgroundQueueTtlMs: positiveInteger(config.backgroundQueueTtlMs, 10 * 60_000, 60 * 60_000),
|
|
40
|
+
interactiveQueueTtlMs: positiveInteger(config.interactiveQueueTtlMs, 0, 60 * 60_000),
|
|
41
|
+
maxQueued: Math.max(1, positiveInteger(config.maxQueued, 100, 1_000))
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
queueTtlMs(priority, override) {
|
|
46
|
+
if (override != null) return positiveInteger(override, 0, 60 * 60_000);
|
|
47
|
+
return priority === "interactive" ? this.config.interactiveQueueTtlMs : this.config.backgroundQueueTtlMs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describeActive(entry) {
|
|
51
|
+
if (!entry) return null;
|
|
52
|
+
return { label: entry.label, priority: entry.priorityName, startedAt: entry.startedAt };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
diagnostic() {
|
|
56
|
+
return {
|
|
57
|
+
enabled: this.config.enabled,
|
|
58
|
+
active: this.describeActive(this.active),
|
|
59
|
+
queued: this.queue.length,
|
|
60
|
+
maxObservedQueue: this.maxObservedQueue,
|
|
61
|
+
completed: this.completed,
|
|
62
|
+
expired: this.expired,
|
|
63
|
+
averageWaitMs: this.completed ? Math.round(this.totalWaitMs / this.completed) : 0
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
remove(entry) {
|
|
68
|
+
const index = this.queue.indexOf(entry);
|
|
69
|
+
if (index >= 0) this.queue.splice(index, 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
next() {
|
|
73
|
+
if (this.active || this.closed || !this.queue.length) return;
|
|
74
|
+
this.queue.sort((left, right) => right.priority - left.priority || left.sequence - right.sequence);
|
|
75
|
+
const entry = this.queue.shift();
|
|
76
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
77
|
+
entry.startedAt = new Date(this.now()).toISOString();
|
|
78
|
+
this.active = entry;
|
|
79
|
+
const waitMs = Math.max(0, this.now() - entry.queuedAt);
|
|
80
|
+
this.totalWaitMs += waitMs;
|
|
81
|
+
if (waitMs > 0) this.logger?.log("agent", `${entry.label} waited ${waitMs}ms for exclusive agent execution`);
|
|
82
|
+
let released = false;
|
|
83
|
+
entry.resolve(() => {
|
|
84
|
+
if (released) return;
|
|
85
|
+
released = true;
|
|
86
|
+
if (this.active === entry) this.active = null;
|
|
87
|
+
this.completed += 1;
|
|
88
|
+
queueMicrotask(() => this.next());
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
acquire({ priority: priorityName = "background", label = "Agent turn", queueTtlMs } = {}) {
|
|
93
|
+
if (!this.config.enabled) return Promise.resolve(() => {});
|
|
94
|
+
if (this.closed) return Promise.reject(Object.assign(new Error("Agent turn coordinator is closed."), { retryable: true }));
|
|
95
|
+
if (this.queue.length >= this.config.maxQueued) {
|
|
96
|
+
return Promise.reject(Object.assign(new Error("Agent turn queue is full."), { code: "AGENT_TURN_QUEUE_FULL", retryable: true }));
|
|
97
|
+
}
|
|
98
|
+
const priority = normalizedPriority(priorityName);
|
|
99
|
+
const ttlMs = this.queueTtlMs(priorityName, queueTtlMs);
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const entry = {
|
|
102
|
+
label: String(label || "Agent turn").slice(0, 160),
|
|
103
|
+
priorityName: priorityName === "interactive" ? "interactive" : "background",
|
|
104
|
+
priority,
|
|
105
|
+
sequence: this.sequence += 1,
|
|
106
|
+
queuedAt: this.now(),
|
|
107
|
+
startedAt: null,
|
|
108
|
+
resolve,
|
|
109
|
+
reject,
|
|
110
|
+
timer: null
|
|
111
|
+
};
|
|
112
|
+
if (ttlMs > 0) {
|
|
113
|
+
entry.timer = setTimeout(() => {
|
|
114
|
+
if (this.active === entry) return;
|
|
115
|
+
this.remove(entry);
|
|
116
|
+
this.expired += 1;
|
|
117
|
+
reject(queueExpiredError(entry.label));
|
|
118
|
+
}, ttlMs);
|
|
119
|
+
entry.timer.unref?.();
|
|
120
|
+
}
|
|
121
|
+
this.queue.push(entry);
|
|
122
|
+
this.maxObservedQueue = Math.max(this.maxObservedQueue, this.queue.length);
|
|
123
|
+
this.next();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async run(options, work) {
|
|
128
|
+
const release = await this.acquire(options);
|
|
129
|
+
try {
|
|
130
|
+
return await work();
|
|
131
|
+
} finally {
|
|
132
|
+
release();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
close() {
|
|
137
|
+
this.closed = true;
|
|
138
|
+
for (const entry of this.queue.splice(0)) {
|
|
139
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
140
|
+
entry.reject(Object.assign(new Error("Agent turn coordinator closed before execution."), { retryable: true }));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -18,7 +18,7 @@ function nativeTools(policy) {
|
|
|
18
18
|
}];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger }) {
|
|
21
|
+
export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger, toolFanout }) {
|
|
22
22
|
if (!capabilityService?.execute) throw new Error("Pi capability tools require CapabilityService");
|
|
23
23
|
|
|
24
24
|
const baseContext = {
|
|
@@ -38,6 +38,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
|
|
|
38
38
|
}
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
const runWithFanout = (work) => toolFanout?.run ? toolFanout.run(work) : work();
|
|
41
42
|
const execute = (actorToolName, method, params = {}, context = {}) => capabilityService.execute({
|
|
42
43
|
method,
|
|
43
44
|
actorToolName,
|
|
@@ -113,12 +114,14 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
|
|
|
113
114
|
args: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
114
115
|
deliver: Type.Optional(Type.Boolean())
|
|
115
116
|
}),
|
|
116
|
-
execute: async (_id, params) => jsonResult(await
|
|
117
|
+
execute: async (_id, params) => jsonResult(await runWithFanout(
|
|
118
|
+
() => execute("run_tool", "tools.run", params)
|
|
119
|
+
))
|
|
117
120
|
}),
|
|
118
121
|
defineTool({
|
|
119
122
|
name: "list_scheduled_tasks",
|
|
120
123
|
label: "List scheduled tasks",
|
|
121
|
-
description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending
|
|
124
|
+
description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending, running, and authentication-blocked tasks, and accept an optional limit up to 100.",
|
|
122
125
|
parameters: Type.Object({
|
|
123
126
|
status: Type.Optional(Type.String()),
|
|
124
127
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxScheduledTaskListLimit }))
|
|
@@ -135,7 +138,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
|
|
|
135
138
|
defineTool({
|
|
136
139
|
name: "cancel_all_scheduled_tasks",
|
|
137
140
|
label: "Cancel all scheduled tasks",
|
|
138
|
-
description: "Cancel all
|
|
141
|
+
description: "Cancel all active async tasks, including authentication-blocked tasks, for the current Telegram chat.",
|
|
139
142
|
parameters: Type.Object({}),
|
|
140
143
|
execute: async () => jsonResult(await execute("cancel_all_scheduled_tasks", "tasks.cancelAll"))
|
|
141
144
|
}),
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
|
|
5
|
+
const readBufferBytes = 1024 * 1024;
|
|
6
|
+
const supportedSessionVersion = 3;
|
|
7
|
+
|
|
8
|
+
function parseEntry(line) {
|
|
9
|
+
if (!line.trim()) return null;
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(line);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readSessionHeader(filePath) {
|
|
18
|
+
const descriptor = openSync(filePath, "r");
|
|
19
|
+
try {
|
|
20
|
+
const buffer = Buffer.alloc(4096);
|
|
21
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
22
|
+
const line = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
|
23
|
+
const header = parseEntry(line);
|
|
24
|
+
return header?.type === "session" ? header : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
} finally {
|
|
28
|
+
closeSync(descriptor);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function findMostRecentSessionFile(sessionDir, cwd) {
|
|
33
|
+
const expectedCwd = path.resolve(cwd);
|
|
34
|
+
try {
|
|
35
|
+
return readdirSync(sessionDir)
|
|
36
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
37
|
+
.map((name) => path.join(sessionDir, name))
|
|
38
|
+
.map((filePath) => ({ filePath, header: readSessionHeader(filePath) }))
|
|
39
|
+
.filter(({ header }) => header && typeof header.cwd === "string" && path.resolve(header.cwd) === expectedCwd)
|
|
40
|
+
.map(({ filePath }) => ({ filePath, mtimeMs: statSync(filePath).mtimeMs }))
|
|
41
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath || null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function streamSessionEntries(filePath, visit) {
|
|
48
|
+
const descriptor = openSync(filePath, "r");
|
|
49
|
+
try {
|
|
50
|
+
const decoder = new StringDecoder("utf8");
|
|
51
|
+
const buffer = Buffer.allocUnsafe(readBufferBytes);
|
|
52
|
+
let pending = "";
|
|
53
|
+
while (true) {
|
|
54
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, null);
|
|
55
|
+
if (!bytesRead) break;
|
|
56
|
+
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
57
|
+
let newline = pending.indexOf("\n");
|
|
58
|
+
while (newline !== -1) {
|
|
59
|
+
const entry = parseEntry(pending.slice(0, newline));
|
|
60
|
+
if (entry) visit(entry);
|
|
61
|
+
pending = pending.slice(newline + 1);
|
|
62
|
+
newline = pending.indexOf("\n");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
pending += decoder.end();
|
|
66
|
+
const entry = parseEntry(pending);
|
|
67
|
+
if (entry) visit(entry);
|
|
68
|
+
} finally {
|
|
69
|
+
closeSync(descriptor);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function traceActivePath(entries, leafId) {
|
|
74
|
+
const reversed = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
let currentId = leafId;
|
|
77
|
+
while (currentId) {
|
|
78
|
+
if (seen.has(currentId)) return null;
|
|
79
|
+
seen.add(currentId);
|
|
80
|
+
const entry = entries.get(currentId);
|
|
81
|
+
if (!entry) return null;
|
|
82
|
+
reversed.push(entry);
|
|
83
|
+
currentId = entry.parentId || null;
|
|
84
|
+
}
|
|
85
|
+
return reversed.reverse();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findLatestValidCompaction(path) {
|
|
89
|
+
const pathIndex = new Map(path.map((entry, index) => [entry.id, index]));
|
|
90
|
+
for (let index = path.length - 1; index >= 0; index -= 1) {
|
|
91
|
+
const entry = path[index];
|
|
92
|
+
if (entry.type !== "compaction" || !entry.hasSummary) continue;
|
|
93
|
+
if (!entry.firstKeptEntryId) return { entry, index, firstKeptIndex: index };
|
|
94
|
+
const firstKeptIndex = pathIndex.get(entry.firstKeptEntryId);
|
|
95
|
+
if (firstKeptIndex !== undefined && firstKeptIndex < index) {
|
|
96
|
+
return { entry, index, firstKeptIndex };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function inspectSessionGraph(filePath) {
|
|
103
|
+
let header = null;
|
|
104
|
+
let firstEntry = true;
|
|
105
|
+
let invalidHeader = false;
|
|
106
|
+
let leafId = null;
|
|
107
|
+
let duplicateId = false;
|
|
108
|
+
const entries = new Map();
|
|
109
|
+
streamSessionEntries(filePath, (entry) => {
|
|
110
|
+
if (firstEntry) {
|
|
111
|
+
firstEntry = false;
|
|
112
|
+
header = entry.type === "session" ? entry : null;
|
|
113
|
+
invalidHeader = !header;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (invalidHeader) return;
|
|
117
|
+
if (!entry.id || entry.type === "session") return;
|
|
118
|
+
if (entries.has(entry.id)) duplicateId = true;
|
|
119
|
+
entries.set(entry.id, {
|
|
120
|
+
id: entry.id,
|
|
121
|
+
parentId: entry.parentId || null,
|
|
122
|
+
type: entry.type,
|
|
123
|
+
firstKeptEntryId: entry.type === "compaction" ? entry.firstKeptEntryId || null : null,
|
|
124
|
+
hasSummary: entry.type === "compaction" && Boolean(String(entry.summary || "").trim())
|
|
125
|
+
});
|
|
126
|
+
leafId = entry.id;
|
|
127
|
+
});
|
|
128
|
+
if (invalidHeader || !header || header.version !== supportedSessionVersion || duplicateId || !leafId) return null;
|
|
129
|
+
const path = traceActivePath(entries, leafId);
|
|
130
|
+
if (!path) return null;
|
|
131
|
+
const compaction = findLatestValidCompaction(path);
|
|
132
|
+
if (!compaction) return null;
|
|
133
|
+
return { header, path, compaction };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function loadMigrationPayload(filePath, path, compaction) {
|
|
137
|
+
const before = path.slice(compaction.firstKeptIndex, compaction.index);
|
|
138
|
+
const after = path.slice(compaction.index + 1);
|
|
139
|
+
const contextIds = [...before, ...after].map((entry) => entry.id);
|
|
140
|
+
const wanted = new Set(contextIds);
|
|
141
|
+
const loaded = new Map();
|
|
142
|
+
let summary = "";
|
|
143
|
+
streamSessionEntries(filePath, (entry) => {
|
|
144
|
+
if (entry.id === compaction.entry.id) summary = String(entry.summary || "").trim();
|
|
145
|
+
if (wanted.has(entry.id)) loaded.set(entry.id, entry);
|
|
146
|
+
});
|
|
147
|
+
if (!summary || loaded.size !== wanted.size) return null;
|
|
148
|
+
return {
|
|
149
|
+
summary,
|
|
150
|
+
contextEntries: contextIds.map((id) => loaded.get(id))
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function inspectSessionForPreloadMigration(filePath, maxPersistedBytes) {
|
|
155
|
+
const sourceBytes = statSync(filePath).size;
|
|
156
|
+
if (sourceBytes <= Math.max(1, Number(maxPersistedBytes) || 1)) return null;
|
|
157
|
+
const graph = inspectSessionGraph(filePath);
|
|
158
|
+
if (!graph) return null;
|
|
159
|
+
const payload = loadMigrationPayload(filePath, graph.path, graph.compaction);
|
|
160
|
+
if (!payload) return null;
|
|
161
|
+
return {
|
|
162
|
+
sourceFile: filePath,
|
|
163
|
+
sourceBytes,
|
|
164
|
+
header: graph.header,
|
|
165
|
+
compactionId: graph.compaction.entry.id,
|
|
166
|
+
...payload
|
|
167
|
+
};
|
|
168
|
+
}
|