arisa 5.1.68 → 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 +28 -6
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +123 -8
- package/src/core/agent/agent-session-lifecycle.js +108 -8
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/core-tools.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-capability-tools.js +7 -4
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/agent/runtime-context.js +1 -1
- 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-heap-circuit-breaker.js +122 -0
- package/src/core/agent/worker-tool-fanout.js +117 -0
- package/src/core/artifacts/artifact-store.js +1 -1
- package/src/core/capabilities/capability-service.js +4 -3
- package/src/core/config/config-defaults.js +59 -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-runner.js +8 -4
- package/src/core/tasks/task-store.js +48 -5
- package/src/core/tools/daemon-client.js +180 -0
- package/src/core/tools/daemon-processes.js +26 -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 +131 -191
- 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 +189 -38
- package/src/index.js +14 -2
- package/src/official-tools.lock.json +526 -78
- 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 +26 -76
- 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/obsolete-daemon-reaper.js +43 -0
- package/src/runtime/oom-protection.js +20 -0
- package/src/runtime/paths.js +3 -151
- package/src/runtime/process-inspection.js +78 -0
- 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 +36 -9
- 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 +45 -321
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/prompt-builders.js +8 -3
- package/src/transport/telegram/task-dispatcher.js +36 -11
- package/src/transport/telegram/telegram-prompt-controller.js +359 -0
- package/src/transport/telegram/workspace-topic-store.js +1 -1
- package/test/agent-session-lifecycle.test.js +92 -0
- package/test/agent-turn-coordinator.test.js +45 -0
- package/test/architecture-boundaries.test.js +29 -0
- package/test/bootstrap.test.js +65 -0
- package/test/context-and-task-bounds.test.js +2 -1
- package/test/daemon-process-invocation.test.js +27 -0
- package/test/daemon-runtime.test.js +38 -5
- package/test/doctor.test.js +41 -0
- package/test/memory-pressure.test.js +41 -0
- package/test/model-selection.test.js +11 -1
- package/test/obsolete-daemon-reaper.test.js +61 -0
- package/test/official-tool-dependencies.test.js +7 -2
- 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 +30 -0
- package/test/service-manager.test.js +6 -1
- 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 +82 -0
- package/test/telegram-task-dispatcher.test.js +66 -5
- package/test/telegram-text-artifact.test.js +30 -0
- package/test/tool-registry-run.test.js +117 -4
- package/test/tui.test.js +41 -0
- package/test/weighted-resource-governor.test.js +125 -5
- package/test/worker-heap-circuit-breaker.test.js +79 -0
- package/test/worker-recovery-report.test.js +69 -0
- package/test/worker-tool-fanout.test.js +79 -0
- package/test-fixtures/fake-daemon.js +5 -0
package/README.md
CHANGED
|
@@ -108,13 +108,16 @@ 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
|
|
115
115
|
socket. The runtime persists request, accepted and terminal records so a restart
|
|
116
|
-
can recover queued work and will not silently repeat an accepted effect.
|
|
117
|
-
|
|
116
|
+
can recover queued work and will not silently repeat an accepted effect. When a
|
|
117
|
+
client deadline expires, the runtime sends a scoped cancellation signal to that
|
|
118
|
+
job before closing the request; cooperative tools can stop it without restarting
|
|
119
|
+
the shared daemon or interrupting unrelated sessions. Legacy daemon tools
|
|
120
|
+
continue to use the existing request-file contract.
|
|
118
121
|
|
|
119
122
|
### Arisa Master and Slave
|
|
120
123
|
|
|
@@ -148,12 +151,31 @@ Automatic context compaction uses Pi's native implementation and can be tuned in
|
|
|
148
151
|
"enabled": true,
|
|
149
152
|
"reserveTokens": 120000,
|
|
150
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
|
|
151
171
|
}
|
|
152
172
|
}
|
|
153
173
|
}
|
|
154
174
|
```
|
|
155
175
|
|
|
156
|
-
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.
|
|
157
179
|
|
|
158
180
|
## Install globally
|
|
159
181
|
|
|
@@ -181,9 +203,9 @@ arisa --silent # run without verbose logs
|
|
|
181
203
|
|
|
182
204
|
Authorized Telegram chats can run the same safe service lifecycle with `/restart`.
|
|
183
205
|
|
|
184
|
-
Background mode runs the Telegram/Pi worker under a lightweight supervisor. Unexpected worker exits use bounded exponential restart backoff. Scheduled agent tasks are serialized FIFO per conversation while different conversations remain independent; execution deadlines default to 15 minutes for scheduled prompts and 5 minutes for agent events. A timed-out turn is marked outcome-uncertain and is never replayed automatically. These policies can be overridden with `service.workerRestart*` and `tasks.*TimeoutMs` in the Arisa config.
|
|
206
|
+
Background mode runs the Telegram/Pi worker under a lightweight supervisor. Unexpected worker exits use bounded exponential restart backoff. After recovery, authorized Telegram chats automatically receive a bounded report with the classified exit cause, recent tool names and counts, uncertain scheduled executions, restart delay, and running version; prompts and private payloads are never included. Scheduled agent tasks are serialized FIFO per conversation while different conversations remain independent; execution deadlines default to 15 minutes for scheduled prompts and 5 minutes for agent events. A timed-out turn is marked outcome-uncertain and is never replayed automatically. These policies can be overridden with `service.workerRestart*` and `tasks.*TimeoutMs` in the Arisa config.
|
|
185
207
|
|
|
186
|
-
Tools may
|
|
208
|
+
Tools may opt into weighted resource governance with a manifest declaration such as `"execution": { "resourceClass": "browser", "weight": 1 }`; undeclared lightweight and nested orchestration calls remain unconstrained. Declared runs queue when their class reaches its concurrency capacity. A global memory broker also reserves RAM across declared classes, leaving configurable system and core reserves before it admits work. The broker starts declared tools with a 384 MiB recommendation, derives their V8 heap from the granted memory, and raises the recommendation after an isolated memory-limit failure. Manifest values for `maxHeapMb` and `maxMemoryMb` act as ceilings; `maxOutputBytes` bounds protocol output. On systemd-based Linux hosts, each declared process tree runs in `arisa-tools.slice` with `MemoryHigh`, `MemoryMax`, bounded swap, and a higher OOM-kill priority than the core. Tool daemons receive the same OOM priority. Arisa lowers the core OOM score when the service account permits it. A limit failure stays inside the tool process tree and returns an uncertain result. Set host reserves and dynamic grants with `toolExecution.systemReserveMb`, `coreReserveMb`, `initialToolMemoryMb`, `minimumToolMemoryMb`, and `maximumToolMemoryMb`. Heap, soft-pressure, and swap controls use `toolHeapPercent`, `toolMemoryHighPercent`, and `toolSwapMaxMb`. Admission still checks `maxWorkerRssMb` and `maxSwapUsedPercent`. Class controls remain available through `defaultCapacity`, `capacities`, and `maxQueuedPerClass`. `deduplicateConcurrent: true` joins exact concurrent duplicates from the same chat.
|
|
187
209
|
|
|
188
210
|
Runtime model override (current process only):
|
|
189
211
|
|
package/package.json
CHANGED
|
@@ -8,11 +8,15 @@ import { buildPiToolPolicy } from "./core-tools.js";
|
|
|
8
8
|
import { createSystemShellTool } from "./system-shell-tool.js";
|
|
9
9
|
import { clampModelThinkingLevel } from "./pi-runtime.js";
|
|
10
10
|
import { clampModelSpeed, createModelSpeedController } from "./model-speed.js";
|
|
11
|
-
import { arisaHomeDir } from "../../
|
|
11
|
+
import { arisaHomeDir } from "../../platform/paths.js";
|
|
12
12
|
import { AgentSessionLifecycle } from "./agent-session-lifecycle.js";
|
|
13
13
|
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
|
+
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";
|
|
16
20
|
|
|
17
21
|
const piValidationTimeoutMs = 60_000;
|
|
18
22
|
const arisaToolNames = [
|
|
@@ -154,7 +158,23 @@ export class AgentManager {
|
|
|
154
158
|
this.resourceNotes = new ToolResourceNoteStore();
|
|
155
159
|
this.sessionLifecycle = new AgentSessionLifecycle({
|
|
156
160
|
logger,
|
|
157
|
-
summarizeContext: summarizeRetainedContext
|
|
161
|
+
summarizeContext: summarizeRetainedContext,
|
|
162
|
+
cachePolicy: config.pi.sessionCache,
|
|
163
|
+
sessionRotationPolicy: config.pi.sessionRotation
|
|
164
|
+
});
|
|
165
|
+
this.heapCircuitBreaker = new WorkerHeapCircuitBreaker({
|
|
166
|
+
lifecycle: this.sessionLifecycle,
|
|
167
|
+
logger,
|
|
168
|
+
config: config.pi.heapCircuitBreaker
|
|
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
|
|
158
178
|
});
|
|
159
179
|
this.sessions = this.sessionLifecycle.sessions;
|
|
160
180
|
this.pendingNewSessions = this.sessionLifecycle.pendingNewSessions;
|
|
@@ -188,6 +208,11 @@ export class AgentManager {
|
|
|
188
208
|
|
|
189
209
|
setConfig(config) {
|
|
190
210
|
this.sessionLifecycle.resetConfigState();
|
|
211
|
+
this.sessionLifecycle.setCachePolicy(config.pi.sessionCache);
|
|
212
|
+
this.sessionLifecycle.setSessionRotationPolicy(config.pi.sessionRotation);
|
|
213
|
+
this.heapCircuitBreaker.setConfig(config.pi.heapCircuitBreaker);
|
|
214
|
+
this.toolFanout.setConfig(config.pi.toolFanout);
|
|
215
|
+
this.turnCoordinator.setConfig(config.pi.turnCoordinator);
|
|
191
216
|
this.config = config;
|
|
192
217
|
}
|
|
193
218
|
|
|
@@ -209,14 +234,93 @@ export class AgentManager {
|
|
|
209
234
|
}
|
|
210
235
|
}
|
|
211
236
|
|
|
212
|
-
getRuntimeDiagnostic() {
|
|
213
|
-
|
|
237
|
+
async getRuntimeDiagnostic() {
|
|
238
|
+
const diagnostic = await this.sessionLifecycle.getDiagnostic();
|
|
239
|
+
return {
|
|
240
|
+
...diagnostic,
|
|
241
|
+
heapCircuitBreaker: this.heapCircuitBreaker.getDiagnostic(),
|
|
242
|
+
toolFanout: this.toolFanout.getDiagnostic(),
|
|
243
|
+
turnCoordinator: this.turnCoordinator.diagnostic()
|
|
244
|
+
};
|
|
214
245
|
}
|
|
215
246
|
|
|
216
247
|
createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
|
|
217
248
|
return this.sessionLifecycle.createSessionManager(chatId, workspaceDir, sessionRevision);
|
|
218
249
|
}
|
|
219
250
|
|
|
251
|
+
async estimatePersistedSessionBytes(session) {
|
|
252
|
+
const sessionStats = session?.getSessionStats?.();
|
|
253
|
+
const sessionFile = session?.sessionManager?.getSessionFile?.() || sessionStats?.sessionFile;
|
|
254
|
+
if (!sessionFile) return 0;
|
|
255
|
+
try {
|
|
256
|
+
return (await stat(sessionFile)).size;
|
|
257
|
+
} catch {
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async acquireSessionContext(sessionKey, context) {
|
|
263
|
+
const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
|
|
264
|
+
this.sessionLifecycle.acquireCached(sessionKey, persistedBytes);
|
|
265
|
+
context.release = () => this.releaseSessionContext(sessionKey, context);
|
|
266
|
+
await this.sessionLifecycle.enforceCachePolicy({ protectedSessionKeys: [sessionKey] });
|
|
267
|
+
return context;
|
|
268
|
+
}
|
|
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
|
+
|
|
285
|
+
async releaseSessionContext(sessionKey, context) {
|
|
286
|
+
if (this.sessions.get(String(sessionKey)) !== context) return;
|
|
287
|
+
await context.rotationCheckPromise;
|
|
288
|
+
const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
|
|
289
|
+
if ((context.activeUsers || 0) <= 1) {
|
|
290
|
+
await this.compactPersistedSessionIfNeeded(sessionKey, context, persistedBytes);
|
|
291
|
+
}
|
|
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
|
+
}
|
|
305
|
+
await this.sessionLifecycle.enforceCachePolicy();
|
|
306
|
+
}
|
|
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
|
+
|
|
220
324
|
async validatePiAgent(config = this.config) {
|
|
221
325
|
this.logger?.log("agent", "validating Pi session");
|
|
222
326
|
const { authStorage, modelRegistry } = createPiRuntime({
|
|
@@ -254,6 +358,7 @@ export class AgentManager {
|
|
|
254
358
|
}
|
|
255
359
|
|
|
256
360
|
async getSessionContext(chatId, telegram, { scopeChatId = chatId, accessGuard = async () => {} } = {}) {
|
|
361
|
+
await this.heapCircuitBreaker.admit();
|
|
257
362
|
const sessionKey = String(chatId);
|
|
258
363
|
const modelSelection = resolveChatModelSelection(this.config, sessionKey);
|
|
259
364
|
const effectiveModelId = modelSelection.model;
|
|
@@ -274,7 +379,7 @@ export class AgentManager {
|
|
|
274
379
|
existing.telegramTarget.current = telegram;
|
|
275
380
|
existing.accessGuardTarget.current = accessGuard;
|
|
276
381
|
this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
|
|
277
|
-
return existing;
|
|
382
|
+
return this.acquireSessionContext(sessionKey, existing);
|
|
278
383
|
}
|
|
279
384
|
this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelKey || "unknown"} -> ${effectiveModelKey}; recreating session`);
|
|
280
385
|
this.closeCachedSession(sessionKey);
|
|
@@ -362,11 +467,14 @@ export class AgentManager {
|
|
|
362
467
|
modelKey: effectiveModelKey,
|
|
363
468
|
speedController,
|
|
364
469
|
telegramTarget,
|
|
365
|
-
accessGuardTarget
|
|
470
|
+
accessGuardTarget,
|
|
471
|
+
rotationCheckPromise: Promise.resolve(),
|
|
472
|
+
rotationRequest: null
|
|
366
473
|
};
|
|
474
|
+
session.subscribe((event) => this.scheduleCompactionRotationCheck(sessionKey, ctx, event));
|
|
367
475
|
this.sessions.set(sessionKey, ctx);
|
|
368
476
|
if (isNewSession) this.sessionLifecycle.completeNewSession(sessionKey);
|
|
369
|
-
return ctx;
|
|
477
|
+
return this.acquireSessionContext(sessionKey, ctx);
|
|
370
478
|
}
|
|
371
479
|
|
|
372
480
|
async getAvailableModels(chatId) {
|
|
@@ -384,9 +492,15 @@ export class AgentManager {
|
|
|
384
492
|
}
|
|
385
493
|
|
|
386
494
|
async close() {
|
|
495
|
+
this.turnCoordinator.close();
|
|
387
496
|
await this.sessionLifecycle.closeAll();
|
|
388
497
|
}
|
|
389
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
|
+
|
|
390
504
|
async runTool({ name, request, chatId, taskContext = null }) {
|
|
391
505
|
await this.toolRegistry.load();
|
|
392
506
|
this.logger?.log("agent", `run_tool ${name}`);
|
|
@@ -413,7 +527,8 @@ export class AgentManager {
|
|
|
413
527
|
telegram,
|
|
414
528
|
chatId,
|
|
415
529
|
policy,
|
|
416
|
-
logger: this.logger
|
|
530
|
+
logger: this.logger,
|
|
531
|
+
toolFanout: this.toolFanout
|
|
417
532
|
});
|
|
418
533
|
}
|
|
419
534
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { getChatPiSessionsDir, sessionStartOperationalNotesFile } from "../../
|
|
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,13 +42,89 @@ function closeAgentSession(session) {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export class AgentSessionLifecycle {
|
|
44
|
-
constructor({ logger, summarizeContext }) {
|
|
45
|
+
constructor({ logger, summarizeContext, cachePolicy = {}, sessionRotationPolicy = {} }) {
|
|
45
46
|
this.logger = logger;
|
|
46
47
|
this.summarizeContext = summarizeContext;
|
|
47
48
|
this.sessions = new Map();
|
|
48
49
|
this.pendingNewSessions = new Set();
|
|
49
50
|
this.pendingSessionHandoffs = new Map();
|
|
50
51
|
this.sessionClosePromises = new Map();
|
|
52
|
+
this.setCachePolicy(cachePolicy);
|
|
53
|
+
this.setSessionRotationPolicy(sessionRotationPolicy);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setCachePolicy(cachePolicy = {}) {
|
|
57
|
+
this.cachePolicy = {
|
|
58
|
+
maxSessions: Math.max(1, Number(cachePolicy.maxSessions) || 3),
|
|
59
|
+
maxPersistedBytes: Math.max(1, Number(cachePolicy.maxPersistedBytes) || 48 * 1024 * 1024)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setSessionRotationPolicy(policy = {}) {
|
|
64
|
+
this.sessionRotationPolicy = { ...policy };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
acquireCached(sessionKey, persistedBytes = 0) {
|
|
68
|
+
const context = this.sessions.get(String(sessionKey));
|
|
69
|
+
if (!context) return;
|
|
70
|
+
context.activeUsers = (context.activeUsers || 0) + 1;
|
|
71
|
+
context.lastAccessedAt = Date.now();
|
|
72
|
+
context.persistedBytes = Math.max(0, Number(persistedBytes) || 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
releaseCached(sessionKey, persistedBytes = 0) {
|
|
76
|
+
const context = this.sessions.get(String(sessionKey));
|
|
77
|
+
if (!context) return;
|
|
78
|
+
context.activeUsers = Math.max(0, (context.activeUsers || 0) - 1);
|
|
79
|
+
context.lastAccessedAt = Date.now();
|
|
80
|
+
context.persistedBytes = Math.max(0, Number(persistedBytes) || 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
cacheUsage() {
|
|
84
|
+
return {
|
|
85
|
+
sessions: this.sessions.size,
|
|
86
|
+
persistedBytes: [...this.sessions.values()].reduce((total, context) => total + (context.persistedBytes || 0), 0)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
cacheOverLimit() {
|
|
91
|
+
const usage = this.cacheUsage();
|
|
92
|
+
return usage.sessions > this.cachePolicy.maxSessions
|
|
93
|
+
|| usage.persistedBytes > this.cachePolicy.maxPersistedBytes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
evictionCandidate(protectedKeys = new Set()) {
|
|
97
|
+
return [...this.sessions.entries()]
|
|
98
|
+
.filter(([key, context]) => !protectedKeys.has(key) && !(context.activeUsers > 0) && !context.session?.isStreaming)
|
|
99
|
+
.sort((left, right) => (left[1].lastAccessedAt || 0) - (right[1].lastAccessedAt || 0))[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async evictInactive({ protectedSessionKeys = [] } = {}) {
|
|
103
|
+
const protectedKeys = new Set(protectedSessionKeys.map(String));
|
|
104
|
+
const evicted = [];
|
|
105
|
+
let candidate = this.evictionCandidate(protectedKeys);
|
|
106
|
+
while (candidate) {
|
|
107
|
+
const [sessionKey, context] = candidate;
|
|
108
|
+
evicted.push({ sessionKey, persistedBytes: context.persistedBytes || 0 });
|
|
109
|
+
this.logger?.log("agent", `evicting inactive Pi session for chat ${sessionKey} from resident cache`);
|
|
110
|
+
await this.closeCached(sessionKey);
|
|
111
|
+
candidate = this.evictionCandidate(protectedKeys);
|
|
112
|
+
}
|
|
113
|
+
return evicted;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async enforceCachePolicy({ protectedSessionKeys = [] } = {}) {
|
|
117
|
+
const protectedKeys = new Set(protectedSessionKeys.map(String));
|
|
118
|
+
const evicted = [];
|
|
119
|
+
while (this.cacheOverLimit()) {
|
|
120
|
+
const candidate = this.evictionCandidate(protectedKeys);
|
|
121
|
+
if (!candidate) break;
|
|
122
|
+
const [sessionKey, context] = candidate;
|
|
123
|
+
evicted.push({ sessionKey, persistedBytes: context.persistedBytes || 0 });
|
|
124
|
+
this.logger?.log("agent", `evicting inactive Pi session for chat ${sessionKey} from resident cache`);
|
|
125
|
+
await this.closeCached(sessionKey);
|
|
126
|
+
}
|
|
127
|
+
return evicted;
|
|
51
128
|
}
|
|
52
129
|
|
|
53
130
|
closeCached(sessionKey) {
|
|
@@ -90,14 +167,19 @@ export class AgentSessionLifecycle {
|
|
|
90
167
|
this.pendingSessionHandoffs.clear();
|
|
91
168
|
}
|
|
92
169
|
|
|
93
|
-
resetSession(chatId, { handoff = "", parentSession = "" } = {}) {
|
|
170
|
+
resetSession(chatId, { handoff = "", parentSession = "", source = "" } = {}) {
|
|
94
171
|
const sessionKey = String(chatId);
|
|
95
172
|
this.closeCached(sessionKey);
|
|
96
173
|
this.pendingNewSessions.add(sessionKey);
|
|
97
174
|
const text = String(handoff || "").trim();
|
|
98
175
|
const parent = String(parentSession || "").trim();
|
|
176
|
+
const handoffSource = String(source || "").trim();
|
|
99
177
|
if (text || parent) {
|
|
100
|
-
this.pendingSessionHandoffs.set(sessionKey, {
|
|
178
|
+
this.pendingSessionHandoffs.set(sessionKey, {
|
|
179
|
+
text,
|
|
180
|
+
parentSession: parent,
|
|
181
|
+
...(handoffSource ? { source: handoffSource } : {})
|
|
182
|
+
});
|
|
101
183
|
} else {
|
|
102
184
|
this.pendingSessionHandoffs.delete(sessionKey);
|
|
103
185
|
}
|
|
@@ -106,6 +188,7 @@ export class AgentSessionLifecycle {
|
|
|
106
188
|
createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
|
|
107
189
|
const sessionKey = String(chatId);
|
|
108
190
|
const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
|
|
191
|
+
const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
|
|
109
192
|
if (this.pendingNewSessions.has(sessionKey)) {
|
|
110
193
|
this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
|
|
111
194
|
const handoff = this.pendingSessionHandoffs.get(sessionKey);
|
|
@@ -114,7 +197,6 @@ export class AgentSessionLifecycle {
|
|
|
114
197
|
sessionDir,
|
|
115
198
|
handoff?.parentSession ? { parentSession: handoff.parentSession } : undefined
|
|
116
199
|
);
|
|
117
|
-
const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
|
|
118
200
|
if (operationalNotes) {
|
|
119
201
|
sessionManager.appendCustomMessageEntry(
|
|
120
202
|
"arisa-operational-notes",
|
|
@@ -128,11 +210,23 @@ export class AgentSessionLifecycle {
|
|
|
128
210
|
"arisa-session-handoff",
|
|
129
211
|
handoff.text,
|
|
130
212
|
false,
|
|
131
|
-
{ source: "telegram-new" }
|
|
213
|
+
{ source: handoff.source || "telegram-new" }
|
|
132
214
|
);
|
|
133
215
|
}
|
|
134
216
|
return { sessionManager, isNewSession: true };
|
|
135
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
|
+
}
|
|
136
230
|
this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
|
|
137
231
|
return {
|
|
138
232
|
sessionManager: SessionManager.continueRecent(workspaceDir, sessionDir),
|
|
@@ -147,7 +241,12 @@ export class AgentSessionLifecycle {
|
|
|
147
241
|
|
|
148
242
|
async getDiagnostic() {
|
|
149
243
|
const contexts = await Promise.all([...this.sessions.entries()].map(async ([chatId, context]) => {
|
|
150
|
-
const base = {
|
|
244
|
+
const base = {
|
|
245
|
+
chatId,
|
|
246
|
+
activeUsers: context.activeUsers || 0,
|
|
247
|
+
persistedBytes: context.persistedBytes || 0,
|
|
248
|
+
lastAccessedAt: context.lastAccessedAt || null
|
|
249
|
+
};
|
|
151
250
|
try {
|
|
152
251
|
const stats = context.session.getSessionStats();
|
|
153
252
|
const retained = this.summarizeContext(context.session.messages);
|
|
@@ -166,6 +265,7 @@ export class AgentSessionLifecycle {
|
|
|
166
265
|
harness: "pi",
|
|
167
266
|
sessions: this.sessions.size,
|
|
168
267
|
closingSessions: this.sessionClosePromises.size,
|
|
268
|
+
cache: { ...this.cachePolicy, ...this.cacheUsage() },
|
|
169
269
|
contexts
|
|
170
270
|
};
|
|
171
271
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AuthStorage } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { piAuthFile } from "../../
|
|
2
|
+
import { piAuthFile } from "../../platform/paths.js";
|
|
3
3
|
|
|
4
4
|
export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, onProgress, onSelect } = {}) {
|
|
5
5
|
const authStorage = AuthStorage.create(piAuthFile);
|