arisa 5.2.7 → 5.2.19
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 +25 -4
- package/package.json +10 -9
- package/pnpm-workspace.yaml +7 -5
- package/src/core/agent/agent-manager.js +92 -18
- package/src/core/agent/agent-session-lifecycle.js +29 -6
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/auth-flow.js +6 -6
- package/src/core/agent/model-speed.js +3 -1
- package/src/core/agent/pi-auth-login.js +28 -28
- package/src/core/agent/pi-capability-tools.js +8 -4
- package/src/core/agent/pi-runtime.js +14 -21
- 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 +28 -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 +48 -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 +129 -48
- package/src/runtime/bootstrap-cli.js +3 -3
- package/src/runtime/bootstrap-telegram.js +7 -7
- 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/slave-cli.js +20 -10
- package/src/runtime/slave-service.js +299 -7
- package/src/runtime/tool-process-supervisor.js +35 -8
- package/src/runtime/tui.js +6 -7
- package/src/transport/telegram/bot.js +11 -5
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/model-controls.js +1 -1
- package/src/transport/telegram/task-dispatcher.js +67 -15
- package/src/transport/telegram/telegram-auth-controller.js +7 -7
- package/src/transport/telegram/telegram-prompt-controller.js +17 -4
- package/src/transport/telegram/telegram-session-bridge.js +2 -1
- package/test/agent-turn-coordinator.test.js +48 -0
- package/test/auth-flow.test.js +2 -2
- package/test/capabilities-security.test.js +36 -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/model-selection.test.js +3 -2
- 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 +13 -0
- package/test/pi-auth-login.test.js +78 -0
- package/test/pi-capability-tools.test.js +3 -0
- package/test/pi-compaction.test.js +21 -0
- package/test/pi-speed-integration.test.js +176 -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/slave-cli.test.js +221 -5
- package/test/task-store.test.js +34 -0
- package/test/telegram-prompt-controller.test.js +2 -1
- package/test/telegram-task-dispatcher.test.js +121 -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
|
|
@@ -127,8 +127,10 @@ Slave runs only the IPC host, daemon supervisor and installed tools. Connections
|
|
|
127
127
|
are authenticated, encrypted, initiated by Slave and restricted by per-Slave
|
|
128
128
|
roots and capability grants.
|
|
129
129
|
|
|
130
|
-
Linux with systemd
|
|
131
|
-
|
|
130
|
+
Arisa Slave supports Linux with systemd, macOS with launchd, and Windows with
|
|
131
|
+
Task Scheduler. On desktop systems, run bootstrap as the normal user so the
|
|
132
|
+
persistent background task retains access to that user's browser profile.
|
|
133
|
+
Bootstrap uses a single-use URL issued by Master:
|
|
132
134
|
|
|
133
135
|
```bash
|
|
134
136
|
npm i -g arisa && arisa slave tcp://198.51.100.12:4719/arisa_secret_v1_<secret>
|
|
@@ -151,12 +153,31 @@ Automatic context compaction uses Pi's native implementation and can be tuned in
|
|
|
151
153
|
"enabled": true,
|
|
152
154
|
"reserveTokens": 120000,
|
|
153
155
|
"keepRecentTokens": 20000
|
|
156
|
+
},
|
|
157
|
+
"sessionRotation": {
|
|
158
|
+
"enabled": true,
|
|
159
|
+
"compactAtPersistedBytes": 25165824,
|
|
160
|
+
"maxPersistedBytes": 33554432
|
|
161
|
+
},
|
|
162
|
+
"toolFanout": {
|
|
163
|
+
"enabled": true,
|
|
164
|
+
"maxConcurrent": 2,
|
|
165
|
+
"pressureConcurrent": 1,
|
|
166
|
+
"serializePercent": 60
|
|
167
|
+
},
|
|
168
|
+
"turnCoordinator": {
|
|
169
|
+
"enabled": true,
|
|
170
|
+
"backgroundQueueTtlMs": 600000,
|
|
171
|
+
"interactiveQueueTtlMs": 0,
|
|
172
|
+
"maxQueued": 100
|
|
154
173
|
}
|
|
155
174
|
}
|
|
156
175
|
}
|
|
157
176
|
```
|
|
158
177
|
|
|
159
|
-
Pi compacts when the context exceeds the model's context window minus `reserveTokens`.
|
|
178
|
+
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.
|
|
179
|
+
|
|
180
|
+
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
181
|
|
|
161
182
|
## Install globally
|
|
162
183
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arisa",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.19",
|
|
4
4
|
"description": "Telegram + Pi Agent modular assistant",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"arisa": "bin/arisa.js"
|
|
9
9
|
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node src/index.js",
|
|
12
|
+
"bootstrap": "node src/index.js --bootstrap",
|
|
13
|
+
"test": "node --test"
|
|
14
|
+
},
|
|
10
15
|
"keywords": [
|
|
11
16
|
"telegram",
|
|
12
17
|
"pi-agent",
|
|
@@ -29,7 +34,7 @@
|
|
|
29
34
|
"author": "Martin Clasen",
|
|
30
35
|
"license": "MIT",
|
|
31
36
|
"engines": {
|
|
32
|
-
"node": ">=22.
|
|
37
|
+
"node": ">=22.19.0"
|
|
33
38
|
},
|
|
34
39
|
"repository": {
|
|
35
40
|
"type": "git",
|
|
@@ -39,14 +44,10 @@
|
|
|
39
44
|
"url": "https://github.com/clasen/Arisa/issues"
|
|
40
45
|
},
|
|
41
46
|
"homepage": "https://arisa.sh",
|
|
47
|
+
"packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215",
|
|
42
48
|
"dependencies": {
|
|
43
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
49
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
44
50
|
"@sinclair/typebox": "^0.34.41",
|
|
45
51
|
"grammy": "^1.42.0"
|
|
46
|
-
},
|
|
47
|
-
"scripts": {
|
|
48
|
-
"start": "node src/index.js",
|
|
49
|
-
"bootstrap": "node src/index.js --bootstrap",
|
|
50
|
-
"test": "node --test"
|
|
51
52
|
}
|
|
52
|
-
}
|
|
53
|
+
}
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
allowBuilds:
|
|
2
2
|
'@google/genai': false
|
|
3
|
+
esbuild: true
|
|
3
4
|
|
|
4
5
|
minimumReleaseAgeExclude:
|
|
5
|
-
- '@earendil-works/pi-agent-core@0.
|
|
6
|
-
- '@earendil-works/pi-ai@0.
|
|
7
|
-
- '@earendil-works/pi-coding-agent@0.
|
|
8
|
-
- '@earendil-works/pi-tui@0.
|
|
6
|
+
- '@earendil-works/pi-agent-core@0.85.1'
|
|
7
|
+
- '@earendil-works/pi-ai@0.85.1'
|
|
8
|
+
- '@earendil-works/pi-coding-agent@0.85.1'
|
|
9
|
+
- '@earendil-works/pi-tui@0.85.1'
|
|
10
|
+
- '@earendil-works/chord@0.85.1'
|
|
9
11
|
|
|
10
12
|
overrides:
|
|
11
13
|
'node-fetch@2.7.0>whatwg-url': '13.0.0'
|
|
@@ -14,7 +16,7 @@ overrides:
|
|
|
14
16
|
basic-ftp@<=5.2.2: '>=5.3.0'
|
|
15
17
|
basic-ftp@<=5.3.0: '>=5.3.1'
|
|
16
18
|
basic-ftp@=5.2.0: '>=5.2.1'
|
|
17
|
-
brace-expansion@>=
|
|
19
|
+
brace-expansion@>=4.0.0 <5.0.9: '5.0.9'
|
|
18
20
|
ip-address@<=10.1.0: '>=10.1.1'
|
|
19
21
|
protobufjs@<7.5.5: '>=7.5.5'
|
|
20
22
|
protobufjs@<=7.5.5: '>=7.5.6'
|
|
@@ -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,31 +267,77 @@ 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
|
-
const
|
|
326
|
+
const modelRuntime = await createPiRuntime({
|
|
262
327
|
provider: config.pi.provider,
|
|
263
328
|
apiKey: config.pi.apiKey
|
|
264
329
|
});
|
|
265
|
-
const model =
|
|
330
|
+
const model = modelRuntime.getModel(config.pi.provider, config.pi.model);
|
|
266
331
|
if (!model) {
|
|
267
332
|
throw new Error(`Model not found: ${config.pi.provider}/${config.pi.model}`);
|
|
268
333
|
}
|
|
269
|
-
if (requiresProviderAuth(model) && !config.pi.apiKey && !hasProviderAuth(config.pi.provider,
|
|
334
|
+
if (requiresProviderAuth(model) && !config.pi.apiKey && !hasProviderAuth(config.pi.provider, modelRuntime)) {
|
|
270
335
|
throw new Error(`No auth found for ${config.pi.provider}. Provide a Pi API key in bootstrap, or authenticate with Pi login for this provider during bootstrap.`);
|
|
271
336
|
}
|
|
272
337
|
|
|
273
338
|
const settingsManager = createPiSettingsManager(config);
|
|
274
339
|
const { session } = await createAgentSession({
|
|
275
|
-
|
|
276
|
-
modelRegistry,
|
|
340
|
+
modelRuntime,
|
|
277
341
|
model,
|
|
278
342
|
settingsManager,
|
|
279
343
|
sessionManager: SessionManager.inMemory()
|
|
@@ -321,13 +385,13 @@ export class AgentManager {
|
|
|
321
385
|
this.pendingNewSessions.add(sessionKey);
|
|
322
386
|
}
|
|
323
387
|
|
|
324
|
-
const
|
|
388
|
+
const modelRuntime = await createPiRuntime({
|
|
325
389
|
provider: this.config.pi.provider,
|
|
326
390
|
apiKey: this.config.pi.apiKey
|
|
327
391
|
});
|
|
328
|
-
const model =
|
|
392
|
+
const model = modelRuntime.getModel(this.config.pi.provider, effectiveModelId);
|
|
329
393
|
if (!model) throw new Error(`Model not found: ${this.config.pi.provider}/${effectiveModelId}`);
|
|
330
|
-
if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider,
|
|
394
|
+
if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, modelRuntime)) {
|
|
331
395
|
throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
|
|
332
396
|
}
|
|
333
397
|
const thinkingLevel = clampModelThinkingLevel(model, modelSelection.thinkingLevel);
|
|
@@ -353,7 +417,8 @@ export class AgentManager {
|
|
|
353
417
|
initializeForumTopic: (...args) => telegramTarget.current.initializeForumTopic(...args),
|
|
354
418
|
prepareRestartReceipt: (...args) => telegramTarget.current.prepareRestartReceipt(...args),
|
|
355
419
|
cancelRestartReceipt: (...args) => telegramTarget.current.cancelRestartReceipt(...args),
|
|
356
|
-
getTaskContext: (...args) => telegramTarget.current.getTaskContext?.(...args) || null
|
|
420
|
+
getTaskContext: (...args) => telegramTarget.current.getTaskContext?.(...args) || null,
|
|
421
|
+
getAgentTaskExecution: (...args) => telegramTarget.current.getAgentTaskExecution?.(...args) || null
|
|
357
422
|
};
|
|
358
423
|
const assertAccess = () => accessGuardTarget.current();
|
|
359
424
|
const customTools = guardTools([
|
|
@@ -375,8 +440,7 @@ export class AgentManager {
|
|
|
375
440
|
cwd: policy.workspaceDir,
|
|
376
441
|
agentDir: arisaHomeDir,
|
|
377
442
|
resourceLoader,
|
|
378
|
-
|
|
379
|
-
modelRegistry,
|
|
443
|
+
modelRuntime,
|
|
380
444
|
model,
|
|
381
445
|
thinkingLevel,
|
|
382
446
|
tools: policy.tools,
|
|
@@ -385,8 +449,8 @@ export class AgentManager {
|
|
|
385
449
|
settingsManager,
|
|
386
450
|
sessionManager
|
|
387
451
|
});
|
|
388
|
-
const speedController = createModelSpeedController(session.agent.
|
|
389
|
-
session.agent.
|
|
452
|
+
const speedController = createModelSpeedController(session.agent.streamFunction, speed);
|
|
453
|
+
session.agent.streamFunction = speedController.streamFn;
|
|
390
454
|
|
|
391
455
|
if (!hasExistingSession) {
|
|
392
456
|
this.logger?.log("agent", `created new session for chat ${sessionKey}`);
|
|
@@ -402,8 +466,11 @@ export class AgentManager {
|
|
|
402
466
|
modelKey: effectiveModelKey,
|
|
403
467
|
speedController,
|
|
404
468
|
telegramTarget,
|
|
405
|
-
accessGuardTarget
|
|
469
|
+
accessGuardTarget,
|
|
470
|
+
rotationCheckPromise: Promise.resolve(),
|
|
471
|
+
rotationRequest: null
|
|
406
472
|
};
|
|
473
|
+
session.subscribe((event) => this.scheduleCompactionRotationCheck(sessionKey, ctx, event));
|
|
407
474
|
this.sessions.set(sessionKey, ctx);
|
|
408
475
|
if (isNewSession) this.sessionLifecycle.completeNewSession(sessionKey);
|
|
409
476
|
return this.acquireSessionContext(sessionKey, ctx);
|
|
@@ -411,7 +478,7 @@ export class AgentManager {
|
|
|
411
478
|
|
|
412
479
|
async getAvailableModels(chatId) {
|
|
413
480
|
const { listProviderModels } = await import("./pi-runtime.js");
|
|
414
|
-
const runtime = createPiRuntime({ provider: this.config.pi.provider, apiKey: this.config.pi.apiKey });
|
|
481
|
+
const runtime = await createPiRuntime({ provider: this.config.pi.provider, apiKey: this.config.pi.apiKey });
|
|
415
482
|
return listProviderModels(this.config.pi.provider, runtime);
|
|
416
483
|
}
|
|
417
484
|
|
|
@@ -424,9 +491,15 @@ export class AgentManager {
|
|
|
424
491
|
}
|
|
425
492
|
|
|
426
493
|
async close() {
|
|
494
|
+
this.turnCoordinator.close();
|
|
427
495
|
await this.sessionLifecycle.closeAll();
|
|
428
496
|
}
|
|
429
497
|
|
|
498
|
+
async runTurn(options, work) {
|
|
499
|
+
if (typeof work !== "function") throw new Error("Agent turn work is required");
|
|
500
|
+
return this.turnCoordinator.run(options, work);
|
|
501
|
+
}
|
|
502
|
+
|
|
430
503
|
async runTool({ name, request, chatId, taskContext = null }) {
|
|
431
504
|
await this.toolRegistry.load();
|
|
432
505
|
this.logger?.log("agent", `run_tool ${name}`);
|
|
@@ -453,7 +526,8 @@ export class AgentManager {
|
|
|
453
526
|
telegram,
|
|
454
527
|
chatId,
|
|
455
528
|
policy,
|
|
456
|
-
logger: this.logger
|
|
529
|
+
logger: this.logger,
|
|
530
|
+
toolFanout: this.toolFanout
|
|
457
531
|
});
|
|
458
532
|
}
|
|
459
533
|
|
|
@@ -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
|
+
}
|
|
@@ -33,8 +33,8 @@ export function getPiAuthIssue(error) {
|
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function getPiAuthStatus(config, chatId = null) {
|
|
37
|
-
const runtime = createPiRuntime({
|
|
36
|
+
export async function getPiAuthStatus(config, chatId = null) {
|
|
37
|
+
const runtime = await createPiRuntime({
|
|
38
38
|
provider: config.pi.provider,
|
|
39
39
|
apiKey: config.pi.apiKey
|
|
40
40
|
});
|
|
@@ -51,8 +51,8 @@ export function getPiAuthStatus(config, chatId = null) {
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export function buildPiAuthTelegramMessage({ config, chatId = null, issue = null, verified = false }) {
|
|
55
|
-
const status = getPiAuthStatus(config, chatId);
|
|
54
|
+
export async function buildPiAuthTelegramMessage({ config, chatId = null, issue = null, verified = false }) {
|
|
55
|
+
const status = await getPiAuthStatus(config, chatId);
|
|
56
56
|
let title = `Pi authentication status for ${status.provider}/${status.model}.`;
|
|
57
57
|
if (issue) {
|
|
58
58
|
title = `Pi authentication needs attention for ${status.provider}/${status.model}.`;
|
|
@@ -95,8 +95,8 @@ export function buildPiAuthTelegramMessage({ config, chatId = null, issue = null
|
|
|
95
95
|
return lines.join("\n");
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export function buildPiAuthRecoveryBlockedMessage({ config, chatId = null, issue = null, renewalActive = false }) {
|
|
99
|
-
const status = getPiAuthStatus(config, chatId);
|
|
98
|
+
export async function buildPiAuthRecoveryBlockedMessage({ config, chatId = null, issue = null, renewalActive = false }) {
|
|
99
|
+
const status = await getPiAuthStatus(config, chatId);
|
|
100
100
|
const lines = [
|
|
101
101
|
`Pi authentication is not ready for ${status.provider}/${status.model}.`,
|
|
102
102
|
"I did not send your message to the agent."
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
export const MODEL_SPEEDS = Object.freeze([1, 1.5]);
|
|
2
2
|
|
|
3
3
|
export function normalizeModelSpeed(speed) {
|
|
4
4
|
const value = Number(speed);
|
|
@@ -17,6 +17,7 @@ export function modelSupportsSpeed(model) {
|
|
|
17
17
|
|| model.id === "gpt-5.5"
|
|
18
18
|
|| model.id === "gpt-5.6"
|
|
19
19
|
|| model.id.startsWith("gpt-5.6-")
|
|
20
|
+
|| model.id === "gpt-6-astra"
|
|
20
21
|
);
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -40,6 +41,7 @@ export function createModelSpeedController(streamFn, initialSpeed) {
|
|
|
40
41
|
speed = normalizeModelSpeed(nextSpeed);
|
|
41
42
|
},
|
|
42
43
|
streamFn(model, context, options) {
|
|
44
|
+
if (!modelSupportsSpeed(model)) return streamFn(model, context, options);
|
|
43
45
|
const serviceTier = speedToServiceTier(speed);
|
|
44
46
|
const onPayload = options?.onPayload;
|
|
45
47
|
return streamFn(model, context, {
|