nexus-agentd 0.2.5 → 0.3.0
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 +359 -250
- package/dist/a2a/runtime.d.ts +7 -1
- package/dist/a2a/runtime.js +290 -17
- package/dist/acp/runtime.d.ts +5 -1
- package/dist/acp/runtime.js +88 -39
- package/dist/artifact-store.d.ts +162 -0
- package/dist/artifact-store.js +1081 -0
- package/dist/cli.js +13 -14
- package/dist/completion-contract.js +1 -1
- package/dist/config.js +45 -5
- package/dist/control-plane-validation.d.ts +54 -0
- package/dist/control-plane-validation.js +152 -0
- package/dist/control-plane.d.ts +9 -32
- package/dist/control-plane.js +52 -158
- package/dist/drivers/index.js +12 -0
- package/dist/drivers/pi.js +1 -1
- package/dist/drivers/stdio.d.ts +4 -1
- package/dist/drivers/stdio.js +135 -19
- package/dist/events.d.ts +9 -0
- package/dist/events.js +17 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -6
- package/dist/process-tree.js +15 -6
- package/dist/run-store.d.ts +57 -7
- package/dist/run-store.js +188 -23
- package/dist/server/auth-policy.d.ts +27 -0
- package/dist/server/auth-policy.js +143 -0
- package/dist/server/connection.d.ts +8 -0
- package/dist/server/connection.js +67 -0
- package/dist/server/diagnostics.d.ts +40 -0
- package/dist/server/diagnostics.js +130 -0
- package/dist/server/metrics.d.ts +51 -0
- package/dist/server/metrics.js +56 -0
- package/dist/server/quota.d.ts +59 -0
- package/dist/server/quota.js +176 -0
- package/dist/server/validation.d.ts +29 -0
- package/dist/server/validation.js +221 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +682 -418
- package/dist/session/admin-runs.d.ts +73 -0
- package/dist/session/admin-runs.js +297 -0
- package/dist/session/admission.d.ts +32 -0
- package/dist/session/admission.js +62 -0
- package/dist/session/readiness.d.ts +33 -0
- package/dist/session/readiness.js +174 -0
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +64 -7
- package/dist/session.js +321 -159
- package/dist/sse-writer.d.ts +7 -0
- package/dist/sse-writer.js +60 -0
- package/dist/types.d.ts +44 -2
- package/dist/types.js +1 -0
- package/dist/webui/app/sources.js +18 -10
- package/dist/webui/index.js +11 -11
- package/dist/webui/markup.js +69 -66
- package/dist/webui/styles.js +791 -616
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +67 -2
- package/examples/client.mjs +102 -0
- package/nexus-agentd.example.json +21 -2
- package/package.json +6 -3
package/dist/session.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { extname } from 'node:path';
|
|
4
4
|
import { AcpProcessRuntime } from './acp/runtime.js';
|
|
5
|
-
import { A2AClientRuntime
|
|
5
|
+
import { A2AClientRuntime } from './a2a/runtime.js';
|
|
6
6
|
import { SessionEventLog } from './events.js';
|
|
7
|
+
import { AdminRunManager } from './session/admin-runs.js';
|
|
8
|
+
import { SessionAdmission } from './session/admission.js';
|
|
9
|
+
import { SessionReadiness } from './session/readiness.js';
|
|
10
|
+
import { ContainedReadError, readContainedFile } from './workspace.js';
|
|
7
11
|
const MAX_SESSION_ARTIFACTS = 64;
|
|
8
12
|
const MAX_ARTIFACT_BASE64_CHARS = 16 * 1024 * 1024;
|
|
9
13
|
const MAX_SESSION_BASE64_CHARS = 32 * 1024 * 1024;
|
|
10
|
-
const READINESS_CACHE_MS = 20_000;
|
|
11
14
|
const MAX_INPUT_ATTACHMENTS = 16;
|
|
12
15
|
const MAX_INPUT_ATTACHMENT_BYTES = 16 * 1024 * 1024;
|
|
13
16
|
const MAX_SESSION_INPUT_BYTES = 32 * 1024 * 1024;
|
|
@@ -35,7 +38,9 @@ export class ManagedSession {
|
|
|
35
38
|
artifacts = [];
|
|
36
39
|
inputAttachments = new Map();
|
|
37
40
|
runtime;
|
|
38
|
-
|
|
41
|
+
artifactStore;
|
|
42
|
+
onRunUpdate;
|
|
43
|
+
constructor(agentId, protocol, workspace, ownerKeyId, maxEvents, maxOutputChars, agentName, runStore, instanceId = randomUUID(), artifactStore, onRunUpdate) {
|
|
39
44
|
this.agentId = agentId;
|
|
40
45
|
this.protocol = protocol;
|
|
41
46
|
this.workspace = workspace;
|
|
@@ -44,12 +49,17 @@ export class ManagedSession {
|
|
|
44
49
|
this.agentName = agentName;
|
|
45
50
|
this.runStore = runStore;
|
|
46
51
|
this.instanceId = instanceId;
|
|
52
|
+
this.artifactStore = artifactStore || runStore;
|
|
53
|
+
this.onRunUpdate = onRunUpdate;
|
|
47
54
|
this.events = new SessionEventLog(this.id, maxEvents);
|
|
48
55
|
this.events.append('session_state', { state: this.state, protocol });
|
|
49
56
|
}
|
|
50
57
|
attach(runtime) {
|
|
51
58
|
this.runtime = runtime;
|
|
52
59
|
}
|
|
60
|
+
runtimeAvailable() {
|
|
61
|
+
return Boolean(this.runtime && this.runtime.isAvailable?.() !== false);
|
|
62
|
+
}
|
|
53
63
|
setProtocolSessionId(id) {
|
|
54
64
|
this.protocolSessionId = id;
|
|
55
65
|
this.updatedAt = Date.now();
|
|
@@ -193,40 +203,21 @@ export class ManagedSession {
|
|
|
193
203
|
return artifacts;
|
|
194
204
|
}
|
|
195
205
|
async publishSingleFile(requestedPath) {
|
|
196
|
-
let handle;
|
|
197
206
|
try {
|
|
198
207
|
const workspaceRoot = await realpath(this.workspace);
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
handle = await open(target, 'r');
|
|
203
|
-
// Resolve again after opening so a swapped symlink cannot silently
|
|
204
|
-
// redirect a publish request outside the configured workspace.
|
|
205
|
-
const verifiedTarget = await realpath(unresolved);
|
|
206
|
-
assertWorkspacePath(workspaceRoot, verifiedTarget);
|
|
207
|
-
if (verifiedTarget !== target) {
|
|
208
|
-
throw new SessionRequestError(409, 'Published file changed while it was being opened');
|
|
209
|
-
}
|
|
210
|
-
const stats = await handle.stat();
|
|
211
|
-
if (!stats.isFile()) {
|
|
212
|
-
throw new SessionRequestError(400, 'Only regular files can be published');
|
|
213
|
-
}
|
|
214
|
-
if (stats.size > MAX_PUBLISHED_FILE_BYTES) {
|
|
215
|
-
throw new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
|
|
216
|
-
}
|
|
217
|
-
const bytes = await handle.readFile();
|
|
218
|
-
const filename = basename(verifiedTarget);
|
|
219
|
-
const workspacePath = relative(workspaceRoot, verifiedTarget).split(sep).join('/');
|
|
208
|
+
const file = await readContainedFile([workspaceRoot], requestedPath, {
|
|
209
|
+
maxBytes: MAX_PUBLISHED_FILE_BYTES
|
|
210
|
+
});
|
|
220
211
|
const artifact = {
|
|
221
212
|
id: `published:${randomUUID()}`,
|
|
222
|
-
name: filename,
|
|
223
|
-
filename,
|
|
224
|
-
mediaType: mediaTypeForPath(filename),
|
|
225
|
-
bytesBase64: bytes.toString('base64'),
|
|
213
|
+
name: file.filename,
|
|
214
|
+
filename: file.filename,
|
|
215
|
+
mediaType: mediaTypeForPath(file.filename),
|
|
216
|
+
bytesBase64: file.bytes.toString('base64'),
|
|
226
217
|
metadata: {
|
|
227
218
|
source: 'workspace_publish',
|
|
228
|
-
path:
|
|
229
|
-
size:
|
|
219
|
+
path: file.relative,
|
|
220
|
+
size: file.size
|
|
230
221
|
}
|
|
231
222
|
};
|
|
232
223
|
this.addArtifact(artifact);
|
|
@@ -236,6 +227,8 @@ export class ManagedSession {
|
|
|
236
227
|
catch (error) {
|
|
237
228
|
if (error instanceof SessionRequestError)
|
|
238
229
|
throw error;
|
|
230
|
+
if (error instanceof ContainedReadError)
|
|
231
|
+
throw publishFailure(error);
|
|
239
232
|
const code = error?.code;
|
|
240
233
|
if (code === 'ENOENT') {
|
|
241
234
|
throw new SessionRequestError(404, 'Published file was not found');
|
|
@@ -245,9 +238,6 @@ export class ManagedSession {
|
|
|
245
238
|
}
|
|
246
239
|
throw error;
|
|
247
240
|
}
|
|
248
|
-
finally {
|
|
249
|
-
await handle?.close();
|
|
250
|
-
}
|
|
251
241
|
}
|
|
252
242
|
setPending(request) {
|
|
253
243
|
this.completion = undefined;
|
|
@@ -296,8 +286,8 @@ export class ManagedSession {
|
|
|
296
286
|
this.updatedAt = Date.now();
|
|
297
287
|
return attachmentView(attachment);
|
|
298
288
|
}
|
|
299
|
-
async message(message, attachmentIds = []) {
|
|
300
|
-
if (!this.runtime)
|
|
289
|
+
async message(message, attachmentIds = [], retryOfRunId) {
|
|
290
|
+
if (!this.runtime || this.runtime.isAvailable?.() === false)
|
|
301
291
|
throw new SessionRequestError(409, 'Agent runtime is unavailable');
|
|
302
292
|
const attachments = this.inputAttachmentsFor(attachmentIds);
|
|
303
293
|
if (this.pendingRequest) {
|
|
@@ -324,15 +314,17 @@ export class ManagedSession {
|
|
|
324
314
|
protocolSessionId: this.protocolSessionId,
|
|
325
315
|
ownerKeyId: this.ownerKeyId,
|
|
326
316
|
task: message,
|
|
327
|
-
inputAttachmentCount: attachments.length
|
|
317
|
+
inputAttachmentCount: attachments.length,
|
|
318
|
+
...(retryOfRunId ? { retryOfRunId } : {})
|
|
328
319
|
}).id;
|
|
320
|
+
this.setState('running');
|
|
329
321
|
void this.runtime.prompt(message, attachments).catch((error) => {
|
|
330
322
|
if (this.state !== 'canceled')
|
|
331
323
|
this.setState('failed', errorMessage(error));
|
|
332
324
|
});
|
|
333
325
|
}
|
|
334
326
|
async resolvePending(response, attachmentIds = []) {
|
|
335
|
-
if (!this.runtime) {
|
|
327
|
+
if (!this.runtime || this.runtime.isAvailable?.() === false) {
|
|
336
328
|
throw new SessionRequestError(409, 'Agent runtime is unavailable');
|
|
337
329
|
}
|
|
338
330
|
const pending = this.pendingRequest;
|
|
@@ -349,11 +341,31 @@ export class ManagedSession {
|
|
|
349
341
|
await this.runtime.respondPending(response, attachments);
|
|
350
342
|
}
|
|
351
343
|
async cancel() {
|
|
352
|
-
|
|
344
|
+
const runtime = this.runtime;
|
|
345
|
+
this.runtime = undefined;
|
|
346
|
+
this.clearPending();
|
|
347
|
+
this.setState('canceled');
|
|
348
|
+
try {
|
|
349
|
+
await runtime?.cancel();
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
try {
|
|
353
|
+
await runtime?.dispose();
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
this.inputAttachments.clear();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
353
359
|
}
|
|
354
360
|
async dispose() {
|
|
355
|
-
|
|
356
|
-
this.
|
|
361
|
+
const runtime = this.runtime;
|
|
362
|
+
this.runtime = undefined;
|
|
363
|
+
try {
|
|
364
|
+
await runtime?.dispose();
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
this.inputAttachments.clear();
|
|
368
|
+
}
|
|
357
369
|
}
|
|
358
370
|
snapshot() {
|
|
359
371
|
return {
|
|
@@ -401,6 +413,34 @@ export class ManagedSession {
|
|
|
401
413
|
updatedAt: this.updatedAt,
|
|
402
414
|
...patch
|
|
403
415
|
});
|
|
416
|
+
if (patch.artifacts && this.artifactStore?.recordArtifacts) {
|
|
417
|
+
const runId = this.currentRunId;
|
|
418
|
+
const artifacts = structuredClone(this.artifacts);
|
|
419
|
+
void Promise.resolve()
|
|
420
|
+
.then(() => this.artifactStore.recordArtifacts(runId, artifacts))
|
|
421
|
+
.catch((error) => {
|
|
422
|
+
console.error(JSON.stringify({
|
|
423
|
+
level: 'error',
|
|
424
|
+
event: 'artifact_snapshot_record_failed',
|
|
425
|
+
runId,
|
|
426
|
+
message: errorMessage(error)
|
|
427
|
+
}));
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
this.onRunUpdate?.(this.currentRunId, this.state);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
// Lifecycle observers (for example quota accounting) must never
|
|
435
|
+
// turn a successful agent event into a failed protocol turn.
|
|
436
|
+
console.error(JSON.stringify({
|
|
437
|
+
level: 'error',
|
|
438
|
+
event: 'session_lifecycle_observer_failed',
|
|
439
|
+
sessionId: this.id,
|
|
440
|
+
runId: this.currentRunId,
|
|
441
|
+
message: errorMessage(error)
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
404
444
|
}
|
|
405
445
|
inputAttachmentsFor(ids) {
|
|
406
446
|
const unique = Array.from(new Set(ids));
|
|
@@ -420,18 +460,83 @@ export class SessionManager {
|
|
|
420
460
|
sessions = new Map();
|
|
421
461
|
sessionLocks = new Map();
|
|
422
462
|
cleanupTimer;
|
|
423
|
-
|
|
424
|
-
|
|
463
|
+
readiness;
|
|
464
|
+
admission;
|
|
465
|
+
adminRuns;
|
|
466
|
+
artifactStore;
|
|
467
|
+
lifecycleListeners = new Set();
|
|
468
|
+
constructor(config, workspacePolicy, drivers, runStore, artifactStore) {
|
|
425
469
|
this.config = config;
|
|
426
470
|
this.workspacePolicy = workspacePolicy;
|
|
427
471
|
this.drivers = drivers;
|
|
428
472
|
this.runStore = runStore;
|
|
473
|
+
this.artifactStore = artifactStore || runStore;
|
|
474
|
+
this.readiness = new SessionReadiness(config, drivers, {
|
|
475
|
+
requestError: (status, message) => new SessionRequestError(status, message)
|
|
476
|
+
});
|
|
477
|
+
this.admission = new SessionAdmission({
|
|
478
|
+
maxSessions: () => this.config.maxSessions || 64,
|
|
479
|
+
sessionCount: () => this.sessions.size,
|
|
480
|
+
sessions: () => this.sessions.values(),
|
|
481
|
+
isCurrent: (session) => this.sessions.get(session.id) === session,
|
|
482
|
+
withSessionLock: (id, operation) => this.withSessionLock(id, operation),
|
|
483
|
+
removeSession: (id) => {
|
|
484
|
+
const session = this.sessions.get(id);
|
|
485
|
+
if (!session || !this.sessions.delete(id))
|
|
486
|
+
return;
|
|
487
|
+
this.notifyLifecycle({
|
|
488
|
+
type: 'session_closed',
|
|
489
|
+
sessionId: session.id,
|
|
490
|
+
ownerKeyId: session.ownerKeyId,
|
|
491
|
+
agentId: session.agentId
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
requestError: (status, message) => new SessionRequestError(status, message)
|
|
495
|
+
});
|
|
496
|
+
this.adminRuns = new AdminRunManager({
|
|
497
|
+
getRun: (runId) => this.getRun(runId),
|
|
498
|
+
getSession: (sessionId) => this.sessions.get(sessionId),
|
|
499
|
+
getAgent: (agentId) => this.config.agents[agentId],
|
|
500
|
+
getOwnerKeyId: (runId) => this.getRunOwnerKeyId(runId),
|
|
501
|
+
withSessionLock: (sessionId, operation) => this.withSessionLock(sessionId, operation),
|
|
502
|
+
startRetry: async (run, ownerKeyId, reservation) => {
|
|
503
|
+
const created = await this.create(run.agentId, run.workspace, ownerKeyId);
|
|
504
|
+
try {
|
|
505
|
+
reservation?.bind(created.id);
|
|
506
|
+
await this.message(created.id, run.task, [], run.id);
|
|
507
|
+
const snapshot = this.get(created.id);
|
|
508
|
+
if (!snapshot.runId) {
|
|
509
|
+
throw new SessionRequestError(502, 'Retried task did not create a run record');
|
|
510
|
+
}
|
|
511
|
+
return { runId: snapshot.runId, sessionId: created.id };
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
try {
|
|
515
|
+
await this.close(created.id);
|
|
516
|
+
}
|
|
517
|
+
catch (cleanupError) {
|
|
518
|
+
console.error(JSON.stringify({
|
|
519
|
+
level: 'error',
|
|
520
|
+
event: 'retry_session_cleanup_failed',
|
|
521
|
+
sessionId: created.id,
|
|
522
|
+
message: errorMessage(cleanupError)
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
markRetryOf: (newRunId, oldRunId) => {
|
|
529
|
+
const store = this.runStore;
|
|
530
|
+
return store?.setRetryOfRunId?.(newRunId, oldRunId);
|
|
531
|
+
},
|
|
532
|
+
requestError: (status, message) => new SessionRequestError(status, message)
|
|
533
|
+
});
|
|
429
534
|
}
|
|
430
535
|
reconfigure(config, workspacePolicy, drivers) {
|
|
431
536
|
this.config = config;
|
|
432
537
|
this.workspacePolicy = workspacePolicy;
|
|
433
538
|
this.drivers = drivers;
|
|
434
|
-
this.
|
|
539
|
+
this.readiness.reconfigure(config, drivers);
|
|
435
540
|
if (this.cleanupTimer)
|
|
436
541
|
this.restartCleanup();
|
|
437
542
|
}
|
|
@@ -441,81 +546,25 @@ export class SessionManager {
|
|
|
441
546
|
this.restartCleanup();
|
|
442
547
|
}
|
|
443
548
|
async listAgents(agentIds, force = false) {
|
|
444
|
-
|
|
445
|
-
if (!force && this.readinessCache && this.readinessCache.expiresAt > now) {
|
|
446
|
-
return filterAgents(this.readinessCache.agents, agentIds);
|
|
447
|
-
}
|
|
448
|
-
const agents = await Promise.all(Object.entries(this.config.agents).map(async ([id, config]) => {
|
|
449
|
-
if (config.protocol === 'a2a')
|
|
450
|
-
return probeA2AAgent(id, config);
|
|
451
|
-
if (config.enabled === false) {
|
|
452
|
-
return {
|
|
453
|
-
id,
|
|
454
|
-
name: config.name || id,
|
|
455
|
-
description: config.description,
|
|
456
|
-
protocol: 'acp',
|
|
457
|
-
driver: config.driver,
|
|
458
|
-
ready: false,
|
|
459
|
-
enabled: false,
|
|
460
|
-
workspace: this.defaultWorkspace(id),
|
|
461
|
-
error: 'Agent is disabled',
|
|
462
|
-
checkedAt: Date.now()
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
const driver = this.drivers.get(id);
|
|
466
|
-
if (!driver) {
|
|
467
|
-
return {
|
|
468
|
-
id,
|
|
469
|
-
name: config.name || id,
|
|
470
|
-
description: config.description,
|
|
471
|
-
protocol: 'acp',
|
|
472
|
-
driver: config.driver,
|
|
473
|
-
ready: false,
|
|
474
|
-
enabled: true,
|
|
475
|
-
workspace: this.defaultWorkspace(id),
|
|
476
|
-
error: 'ACP driver is unavailable',
|
|
477
|
-
checkedAt: Date.now()
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
const startedAt = Date.now();
|
|
481
|
-
try {
|
|
482
|
-
const probe = await driver.probe();
|
|
483
|
-
return {
|
|
484
|
-
...probe,
|
|
485
|
-
protocol: 'acp',
|
|
486
|
-
driver: config.driver,
|
|
487
|
-
enabled: true,
|
|
488
|
-
workspace: this.defaultWorkspace(id),
|
|
489
|
-
checkedAt: Date.now(),
|
|
490
|
-
responseMs: Date.now() - startedAt
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
catch (error) {
|
|
494
|
-
return {
|
|
495
|
-
id,
|
|
496
|
-
name: config.name || id,
|
|
497
|
-
description: config.description,
|
|
498
|
-
protocol: 'acp',
|
|
499
|
-
driver: config.driver,
|
|
500
|
-
ready: false,
|
|
501
|
-
enabled: true,
|
|
502
|
-
workspace: this.defaultWorkspace(id),
|
|
503
|
-
error: errorMessage(error),
|
|
504
|
-
checkedAt: Date.now(),
|
|
505
|
-
responseMs: Date.now() - startedAt
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
}));
|
|
509
|
-
agents.sort((left, right) => left.name.localeCompare(right.name));
|
|
510
|
-
this.readinessCache = {
|
|
511
|
-
expiresAt: Date.now() + READINESS_CACHE_MS,
|
|
512
|
-
agents: structuredClone(agents)
|
|
513
|
-
};
|
|
514
|
-
return filterAgents(agents, agentIds);
|
|
549
|
+
return this.readiness.listAgents(agentIds, force);
|
|
515
550
|
}
|
|
516
551
|
async create(agentId, workspaceInput, ownerKeyId) {
|
|
517
|
-
|
|
518
|
-
|
|
552
|
+
if (!Object.hasOwn(this.config.agents, agentId) || this.config.agents[agentId].enabled === false) {
|
|
553
|
+
throw new SessionRequestError(404, `Configured agent not found: ${agentId}`);
|
|
554
|
+
}
|
|
555
|
+
let release = () => undefined;
|
|
556
|
+
await this.withSessionLock('admission', async () => {
|
|
557
|
+
release = await this.admission.reserve();
|
|
558
|
+
});
|
|
559
|
+
try {
|
|
560
|
+
return await this.createReserved(agentId, workspaceInput, ownerKeyId, release);
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
release();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async createReserved(agentId, workspaceInput, ownerKeyId, release) {
|
|
567
|
+
const config = Object.hasOwn(this.config.agents, agentId) ? this.config.agents[agentId] : undefined;
|
|
519
568
|
if (!config || config.enabled === false) {
|
|
520
569
|
throw new SessionRequestError(404, `Configured agent not found: ${agentId}`);
|
|
521
570
|
}
|
|
@@ -532,11 +581,27 @@ export class SessionManager {
|
|
|
532
581
|
}
|
|
533
582
|
workspace = await this.workspacePolicy.resolve(requestedWorkspace);
|
|
534
583
|
}
|
|
535
|
-
|
|
584
|
+
if (this.admission.isClosing()) {
|
|
585
|
+
throw new SessionRequestError(503, 'Gateway is shutting down');
|
|
586
|
+
}
|
|
587
|
+
const session = new ManagedSession(agentId, config.protocol === 'a2a' ? 'a2a' : 'acp', workspace, ownerKeyId, this.config.maxEventsPerSession, this.config.maxOutputChars, config.name || agentId, this.runStore, this.instanceId, this.artifactStore, (runId, state) => this.notifyLifecycle({
|
|
588
|
+
type: 'run_updated',
|
|
589
|
+
sessionId: session.id,
|
|
590
|
+
runId,
|
|
591
|
+
ownerKeyId: session.ownerKeyId,
|
|
592
|
+
state
|
|
593
|
+
}));
|
|
536
594
|
const runtime = config.protocol === 'a2a'
|
|
537
595
|
? new A2AClientRuntime(config, session, this.config.promptTimeoutMs || 30 * 60_000)
|
|
538
|
-
: new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000);
|
|
596
|
+
: new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000, this.workspacePolicy.listRoots());
|
|
539
597
|
this.sessions.set(session.id, session);
|
|
598
|
+
this.notifyLifecycle({
|
|
599
|
+
type: 'session_created',
|
|
600
|
+
sessionId: session.id,
|
|
601
|
+
ownerKeyId: session.ownerKeyId,
|
|
602
|
+
agentId: session.agentId
|
|
603
|
+
});
|
|
604
|
+
release();
|
|
540
605
|
session.attach(runtime);
|
|
541
606
|
try {
|
|
542
607
|
await runtime.start(workspace);
|
|
@@ -544,8 +609,22 @@ export class SessionManager {
|
|
|
544
609
|
}
|
|
545
610
|
catch (error) {
|
|
546
611
|
session.setState('failed', errorMessage(error));
|
|
547
|
-
|
|
548
|
-
|
|
612
|
+
try {
|
|
613
|
+
await session.dispose();
|
|
614
|
+
}
|
|
615
|
+
catch (cleanupError) {
|
|
616
|
+
console.error(JSON.stringify({ level: 'error', event: 'session_start_cleanup_failed', sessionId: session.id, message: errorMessage(cleanupError) }));
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
if (this.sessions.delete(session.id)) {
|
|
620
|
+
this.notifyLifecycle({
|
|
621
|
+
type: 'session_closed',
|
|
622
|
+
sessionId: session.id,
|
|
623
|
+
ownerKeyId: session.ownerKeyId,
|
|
624
|
+
agentId: session.agentId
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
549
628
|
throw new SessionRequestError(502, `Agent failed to start: ${errorMessage(error)}`);
|
|
550
629
|
}
|
|
551
630
|
}
|
|
@@ -558,8 +637,12 @@ export class SessionManager {
|
|
|
558
637
|
count() {
|
|
559
638
|
return this.sessions.size;
|
|
560
639
|
}
|
|
640
|
+
subscribeLifecycle(listener) {
|
|
641
|
+
this.lifecycleListeners.add(listener);
|
|
642
|
+
return () => this.lifecycleListeners.delete(listener);
|
|
643
|
+
}
|
|
561
644
|
listRuns(query = {}) {
|
|
562
|
-
return this.runStore?.list(query) || { runs: [], total: 0 };
|
|
645
|
+
return this.runStore?.list(query) || { runs: [], total: 0, stats: { active: 0, completed: 0, failed: 0 } };
|
|
563
646
|
}
|
|
564
647
|
getRun(id) {
|
|
565
648
|
const run = this.runStore?.get(id);
|
|
@@ -567,13 +650,51 @@ export class SessionManager {
|
|
|
567
650
|
throw new RunNotFoundError(id);
|
|
568
651
|
return run;
|
|
569
652
|
}
|
|
570
|
-
|
|
653
|
+
getRunOwnerKeyId(id) {
|
|
654
|
+
const store = this.runStore;
|
|
655
|
+
return store?.getOwnerKeyId?.(id);
|
|
656
|
+
}
|
|
657
|
+
ownsRun(id, keyId) {
|
|
658
|
+
return this.getRunOwnerKeyId(id) === keyId;
|
|
659
|
+
}
|
|
660
|
+
getRunArtifacts(id) {
|
|
661
|
+
return this.getRun(id).artifacts;
|
|
662
|
+
}
|
|
663
|
+
getStorageMetrics() {
|
|
664
|
+
return this.runStore?.metrics();
|
|
665
|
+
}
|
|
666
|
+
async readRunArtifact(runId, artifactId) {
|
|
667
|
+
// Read through RunStore so the artifact directory remains the only
|
|
668
|
+
// place that knows how payload filenames are derived and validated.
|
|
669
|
+
this.getRun(runId);
|
|
670
|
+
const store = this.runStore;
|
|
671
|
+
return store?.readArtifact?.(runId, artifactId);
|
|
672
|
+
}
|
|
673
|
+
getRunArtifact(runId, artifactId) {
|
|
674
|
+
return this.readRunArtifact(runId, artifactId);
|
|
675
|
+
}
|
|
676
|
+
getRunControls(id) {
|
|
677
|
+
return this.adminRuns.getRunControls(id);
|
|
678
|
+
}
|
|
679
|
+
async cancelRun(id) {
|
|
680
|
+
return this.adminRuns.cancelRun(id);
|
|
681
|
+
}
|
|
682
|
+
async respondRun(id, response, attachmentIds = []) {
|
|
683
|
+
return this.adminRuns.respondRun(id, response, attachmentIds);
|
|
684
|
+
}
|
|
685
|
+
async retryRun(id, reserveRetry) {
|
|
686
|
+
return this.adminRuns.retryRun(id, reserveRetry);
|
|
687
|
+
}
|
|
688
|
+
retryOfRunId(id) {
|
|
689
|
+
return this.adminRuns.retryOfRunId(id);
|
|
690
|
+
}
|
|
691
|
+
async message(id, message, attachmentIds = [], retryOfRunId) {
|
|
571
692
|
const text = String(message || '');
|
|
572
693
|
if (!text.trim())
|
|
573
694
|
throw new SessionRequestError(400, 'message is required');
|
|
574
695
|
return this.withSessionLock(id, async () => {
|
|
575
696
|
const session = this.require(id);
|
|
576
|
-
await session.message(text, attachmentIds);
|
|
697
|
+
await session.message(text, attachmentIds, retryOfRunId);
|
|
577
698
|
return session.snapshot();
|
|
578
699
|
});
|
|
579
700
|
}
|
|
@@ -609,25 +730,47 @@ export class SessionManager {
|
|
|
609
730
|
await session.cancel();
|
|
610
731
|
const snapshot = session.snapshot();
|
|
611
732
|
await session.dispose();
|
|
612
|
-
this.sessions.delete(id)
|
|
733
|
+
if (this.sessions.delete(id)) {
|
|
734
|
+
this.notifyLifecycle({
|
|
735
|
+
type: 'session_closed',
|
|
736
|
+
sessionId: session.id,
|
|
737
|
+
ownerKeyId: session.ownerKeyId,
|
|
738
|
+
agentId: session.agentId
|
|
739
|
+
});
|
|
740
|
+
}
|
|
613
741
|
return snapshot;
|
|
614
742
|
});
|
|
615
743
|
}
|
|
616
744
|
eventsAfter(id, after) {
|
|
617
745
|
return this.require(id).events.after(after);
|
|
618
746
|
}
|
|
747
|
+
eventReplay(id, after) {
|
|
748
|
+
return this.require(id).events.replay(after);
|
|
749
|
+
}
|
|
619
750
|
subscribe(id, listener) {
|
|
620
751
|
return this.require(id).events.subscribe(listener);
|
|
621
752
|
}
|
|
622
753
|
async shutdown() {
|
|
754
|
+
this.admission.markClosing();
|
|
623
755
|
if (this.cleanupTimer)
|
|
624
756
|
clearInterval(this.cleanupTimer);
|
|
625
757
|
this.cleanupTimer = undefined;
|
|
626
|
-
await Promise.allSettled(Array.from(this.sessions.values()).map(async (session) => {
|
|
758
|
+
const results = await Promise.allSettled(Array.from(this.sessions.values()).map(async (session) => {
|
|
627
759
|
session.interruptForShutdown();
|
|
628
760
|
await session.dispose();
|
|
629
761
|
}));
|
|
762
|
+
for (const session of this.sessions.values()) {
|
|
763
|
+
this.notifyLifecycle({
|
|
764
|
+
type: 'session_closed',
|
|
765
|
+
sessionId: session.id,
|
|
766
|
+
ownerKeyId: session.ownerKeyId,
|
|
767
|
+
agentId: session.agentId
|
|
768
|
+
});
|
|
769
|
+
}
|
|
630
770
|
this.sessions.clear();
|
|
771
|
+
const failures = results.filter((result) => result.status === 'rejected');
|
|
772
|
+
if (failures.length)
|
|
773
|
+
throw new AggregateError(failures.map((result) => result.reason), 'Failed to dispose one or more Agent sessions');
|
|
631
774
|
}
|
|
632
775
|
async withSessionLock(id, operation) {
|
|
633
776
|
// Serialise mutating operations per session so that concurrent
|
|
@@ -657,38 +800,41 @@ export class SessionManager {
|
|
|
657
800
|
throw new SessionNotFoundError(id);
|
|
658
801
|
return session;
|
|
659
802
|
}
|
|
660
|
-
async ensureCapacity() {
|
|
661
|
-
const limit = this.config.maxSessions || 64;
|
|
662
|
-
if (this.sessions.size < limit)
|
|
663
|
-
return;
|
|
664
|
-
const terminal = Array.from(this.sessions.values())
|
|
665
|
-
.filter((session) => isTerminal(session.state))
|
|
666
|
-
.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
667
|
-
while (this.sessions.size >= limit && terminal.length) {
|
|
668
|
-
const session = terminal.shift();
|
|
669
|
-
await session.dispose();
|
|
670
|
-
this.sessions.delete(session.id);
|
|
671
|
-
}
|
|
672
|
-
if (this.sessions.size >= limit) {
|
|
673
|
-
throw new SessionRequestError(429, 'Session capacity has been reached');
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
803
|
async cleanup() {
|
|
677
804
|
const cutoff = Date.now() - this.config.sessionTtlMs;
|
|
678
805
|
for (const [id, session] of this.sessions) {
|
|
679
806
|
if (session.updatedAt > cutoff)
|
|
680
807
|
continue;
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
808
|
+
await this.withSessionLock(id, async () => {
|
|
809
|
+
if (this.sessions.get(id) !== session || session.updatedAt > cutoff)
|
|
810
|
+
return;
|
|
811
|
+
if (!isTerminal(session.state))
|
|
812
|
+
session.setState('failed', 'Session expired after exceeding its lifetime');
|
|
813
|
+
try {
|
|
814
|
+
await session.dispose();
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', sessionId: id, message: errorMessage(error) }));
|
|
818
|
+
}
|
|
819
|
+
finally {
|
|
820
|
+
if (this.sessions.delete(id)) {
|
|
821
|
+
this.notifyLifecycle({
|
|
822
|
+
type: 'session_closed',
|
|
823
|
+
sessionId: session.id,
|
|
824
|
+
ownerKeyId: session.ownerKeyId,
|
|
825
|
+
agentId: session.agentId
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
});
|
|
686
830
|
}
|
|
687
831
|
}
|
|
688
832
|
restartCleanup() {
|
|
689
833
|
if (this.cleanupTimer)
|
|
690
834
|
clearInterval(this.cleanupTimer);
|
|
691
|
-
this.cleanupTimer = setInterval(() => void this.cleanup()
|
|
835
|
+
this.cleanupTimer = setInterval(() => void this.cleanup().catch((error) => {
|
|
836
|
+
console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', message: errorMessage(error) }));
|
|
837
|
+
}), this.config.cleanupIntervalMs || 60_000);
|
|
692
838
|
this.cleanupTimer.unref?.();
|
|
693
839
|
}
|
|
694
840
|
defaultWorkspace(agentId) {
|
|
@@ -697,6 +843,21 @@ export class SessionManager {
|
|
|
697
843
|
return '';
|
|
698
844
|
return config?.workspace || this.config.workspaceRoots[0] || '';
|
|
699
845
|
}
|
|
846
|
+
notifyLifecycle(event) {
|
|
847
|
+
for (const listener of this.lifecycleListeners) {
|
|
848
|
+
try {
|
|
849
|
+
listener(structuredClone(event));
|
|
850
|
+
}
|
|
851
|
+
catch (error) {
|
|
852
|
+
console.error(JSON.stringify({
|
|
853
|
+
level: 'error',
|
|
854
|
+
event: 'session_lifecycle_listener_failed',
|
|
855
|
+
type: event.type,
|
|
856
|
+
message: errorMessage(error)
|
|
857
|
+
}));
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
700
861
|
}
|
|
701
862
|
function progressForState(state, error) {
|
|
702
863
|
const phases = {
|
|
@@ -746,10 +907,16 @@ function runArtifact(artifact) {
|
|
|
746
907
|
metadata: artifact.metadata ? structuredClone(artifact.metadata) : undefined
|
|
747
908
|
};
|
|
748
909
|
}
|
|
749
|
-
function
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
910
|
+
function publishFailure(error) {
|
|
911
|
+
switch (error.failure) {
|
|
912
|
+
case 'swapped':
|
|
913
|
+
return new SessionRequestError(409, 'Published file changed while it was being opened');
|
|
914
|
+
case 'not-a-file':
|
|
915
|
+
return new SessionRequestError(400, 'Only regular files can be published');
|
|
916
|
+
case 'too-large':
|
|
917
|
+
return new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
|
|
918
|
+
case 'outside':
|
|
919
|
+
return new SessionRequestError(403, 'Published files must stay inside the session workspace');
|
|
753
920
|
}
|
|
754
921
|
}
|
|
755
922
|
function mediaTypeForPath(path) {
|
|
@@ -822,11 +989,6 @@ export class RunNotFoundError extends Error {
|
|
|
822
989
|
this.runId = runId;
|
|
823
990
|
}
|
|
824
991
|
}
|
|
825
|
-
function filterAgents(agents, ids) {
|
|
826
|
-
return agents
|
|
827
|
-
.filter((agent) => !ids || ids.has(agent.id))
|
|
828
|
-
.map((agent) => structuredClone(agent));
|
|
829
|
-
}
|
|
830
992
|
function isTerminal(state) {
|
|
831
993
|
return state === 'completed' || state === 'failed' || state === 'canceled';
|
|
832
994
|
}
|