nexus-agentd 0.1.1 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +376 -185
  3. package/dist/a2a/runtime.d.ts +32 -0
  4. package/dist/a2a/runtime.js +382 -0
  5. package/dist/acp/runtime.d.ts +14 -3
  6. package/dist/acp/runtime.js +256 -17
  7. package/dist/agent-instructions.d.ts +11 -0
  8. package/dist/agent-instructions.js +44 -0
  9. package/dist/artifact-store.d.ts +41 -0
  10. package/dist/artifact-store.js +340 -0
  11. package/dist/auth.d.ts +13 -0
  12. package/dist/auth.js +77 -0
  13. package/dist/cli.js +58 -1
  14. package/dist/config.d.ts +10 -0
  15. package/dist/config.js +284 -53
  16. package/dist/control-plane.d.ts +92 -0
  17. package/dist/control-plane.js +646 -0
  18. package/dist/drivers/hermes.d.ts +2 -0
  19. package/dist/drivers/hermes.js +10 -0
  20. package/dist/drivers/index.js +6 -0
  21. package/dist/events.js +13 -2
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.js +15 -3
  24. package/dist/run-store.d.ts +49 -0
  25. package/dist/run-store.js +291 -0
  26. package/dist/server.d.ts +3 -2
  27. package/dist/server.js +913 -41
  28. package/dist/session-contract.d.ts +16 -4
  29. package/dist/session.d.ts +74 -8
  30. package/dist/session.js +433 -38
  31. package/dist/types.d.ts +204 -6
  32. package/dist/types.js +2 -1
  33. package/dist/webui/app.d.ts +1 -0
  34. package/dist/webui/app.js +1074 -0
  35. package/dist/webui/icons.d.ts +9 -0
  36. package/dist/webui/icons.js +10 -0
  37. package/dist/webui/index.d.ts +3 -0
  38. package/dist/webui/index.js +32 -0
  39. package/dist/webui/markup.d.ts +1 -0
  40. package/dist/webui/markup.js +68 -0
  41. package/dist/webui/styles.d.ts +1 -0
  42. package/dist/webui/styles.js +269 -0
  43. package/dist/workspace-files.d.ts +48 -0
  44. package/dist/workspace-files.js +269 -0
  45. package/nexus-agentd.example.json +31 -27
  46. package/package.json +6 -6
@@ -1,6 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
2
4
  import { Readable, Writable } from 'node:stream';
5
+ import { pathToFileURL } from 'node:url';
3
6
  import * as acp from '@agentclientprotocol/sdk';
7
+ import { buildAgentInstructions, composeInitialAgentPrompt } from '../agent-instructions.js';
4
8
  export class AcpProcessRuntime {
5
9
  driver;
6
10
  sink;
@@ -10,13 +14,26 @@ export class AcpProcessRuntime {
10
14
  pendingInput;
11
15
  disposed = false;
12
16
  prompting = false;
13
- constructor(driver, sink) {
17
+ maxStderrChunkChars;
18
+ promptTimeoutMs;
19
+ instructions;
20
+ firstPrompt = true;
21
+ promptCapabilities = {};
22
+ inputDirectory;
23
+ constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000, customInstructions) {
14
24
  this.driver = driver;
15
25
  this.sink = sink;
26
+ const limit = Number.isFinite(maxStderrChunkChars)
27
+ ? Math.floor(maxStderrChunkChars)
28
+ : 16 * 1024;
29
+ this.maxStderrChunkChars = Math.max(1, Math.min(16 * 1024, limit));
30
+ this.promptTimeoutMs = Math.max(10_000, Math.trunc(promptTimeoutMs));
31
+ this.instructions = buildAgentInstructions(customInstructions);
16
32
  }
17
33
  async start(workspace) {
18
34
  if (this.process)
19
35
  throw new Error('ACP runtime is already started');
36
+ this.inputDirectory = path.join(workspace, '.nexus-inputs', this.sink.id);
20
37
  const child = this.driver.spawn(workspace);
21
38
  this.process = child;
22
39
  this.captureStderr(child);
@@ -47,7 +64,7 @@ export class AcpProcessRuntime {
47
64
  },
48
65
  clientInfo: {
49
66
  name: 'nexus-agentd',
50
- version: '0.1.1'
67
+ version: '0.1.4'
51
68
  }
52
69
  });
53
70
  if (initialize.protocolVersion !== acp.PROTOCOL_VERSION) {
@@ -56,6 +73,7 @@ export class AcpProcessRuntime {
56
73
  text: `ACP negotiated protocol ${initialize.protocolVersion}`
57
74
  });
58
75
  }
76
+ this.promptCapabilities = initialize.agentCapabilities?.promptCapabilities || {};
59
77
  const session = await this.connection.agent.request(acp.methods.agent.session.new, {
60
78
  cwd: workspace,
61
79
  mcpServers: []
@@ -63,7 +81,7 @@ export class AcpProcessRuntime {
63
81
  this.sink.setAcpSessionId(String(session.sessionId));
64
82
  this.sink.setState('created');
65
83
  }
66
- async prompt(message) {
84
+ async prompt(message, attachments = []) {
67
85
  if (!this.connection)
68
86
  throw new Error('ACP runtime is not connected');
69
87
  if (this.prompting)
@@ -72,13 +90,18 @@ export class AcpProcessRuntime {
72
90
  this.prompting = true;
73
91
  this.sink.clearPending();
74
92
  this.sink.setState('running');
93
+ const promptMessage = this.firstPrompt
94
+ ? composeInitialAgentPrompt(this.instructions, message)
95
+ : message;
96
+ this.firstPrompt = false;
75
97
  try {
76
- const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
98
+ const response = await withTimeout(this.connection.agent.request(acp.methods.agent.session.prompt, {
77
99
  sessionId,
78
- prompt: [{ type: 'text', text: message }]
79
- });
80
- if (this.sink.state === 'canceled')
100
+ prompt: await this.promptBlocks(promptMessage, attachments)
101
+ }), this.promptTimeoutMs, 'ACP prompt timed out');
102
+ if (this.sink.state === 'canceled' || this.sink.state === 'failed') {
81
103
  return;
104
+ }
82
105
  if (response.stopReason === 'cancelled') {
83
106
  this.sink.setState('canceled');
84
107
  }
@@ -93,12 +116,14 @@ export class AcpProcessRuntime {
93
116
  if (this.sink.state !== 'canceled') {
94
117
  this.sink.setState('failed', error instanceof Error ? error.message : String(error));
95
118
  }
119
+ if (error instanceof PromptTimeoutError)
120
+ await this.dispose();
96
121
  }
97
122
  finally {
98
123
  this.prompting = false;
99
124
  }
100
125
  }
101
- async respondPending(message) {
126
+ async respondPending(message, _attachments = []) {
102
127
  if (this.pendingInput) {
103
128
  this.finishInput(message);
104
129
  return;
@@ -168,6 +193,10 @@ export class AcpProcessRuntime {
168
193
  }
169
194
  this.connection?.close();
170
195
  this.connection = undefined;
196
+ if (this.inputDirectory) {
197
+ await rm(this.inputDirectory, { recursive: true, force: true }).catch(() => undefined);
198
+ this.inputDirectory = undefined;
199
+ }
171
200
  const child = this.process;
172
201
  this.process = undefined;
173
202
  if (child && child.exitCode === null && child.signalCode === null) {
@@ -286,11 +315,15 @@ export class AcpProcessRuntime {
286
315
  content: update.content
287
316
  });
288
317
  }
318
+ else {
319
+ this.captureArtifact(update.content, `message:${String(update.messageId || 'assistant')}:0`);
320
+ }
289
321
  break;
290
322
  case 'agent_thought_chunk':
291
323
  this.sink.emit('thought_chunk', update);
292
324
  break;
293
325
  case 'tool_call':
326
+ this.captureToolArtifacts(String(update.toolCallId), update.content, update.title);
294
327
  this.sink.emit('tool_call', update);
295
328
  if (update.locations?.length) {
296
329
  this.sink.emit('file_activity', {
@@ -300,6 +333,7 @@ export class AcpProcessRuntime {
300
333
  }
301
334
  break;
302
335
  case 'tool_call_update':
336
+ this.captureToolArtifacts(String(update.toolCallId), update.content, update.title || update.name);
303
337
  this.sink.emit('tool_update', update);
304
338
  if (update.locations?.length) {
305
339
  this.sink.emit('file_activity', {
@@ -317,21 +351,57 @@ export class AcpProcessRuntime {
317
351
  break;
318
352
  }
319
353
  }
354
+ captureToolArtifacts(toolCallId, content, hint) {
355
+ content?.forEach((item, index) => {
356
+ if (item.type !== 'content')
357
+ return;
358
+ this.captureArtifact(item.content, `tool:${toolCallId}:${index}`, hint || undefined);
359
+ });
360
+ }
361
+ captureArtifact(content, id, hint) {
362
+ const artifact = artifactFromContent(content, id, hint);
363
+ if (artifact)
364
+ this.sink.addArtifact(artifact);
365
+ }
320
366
  captureStderr(child) {
321
367
  let buffer = '';
322
- child.stderr.on('data', (chunk) => {
323
- buffer += String(chunk);
368
+ let flushed = false;
369
+ const emit = (text) => {
370
+ if (!text)
371
+ return;
372
+ for (let offset = 0; offset < text.length; offset += this.maxStderrChunkChars) {
373
+ this.sink.emit('terminal_output', {
374
+ stream: 'stderr',
375
+ text: text.slice(offset, offset + this.maxStderrChunkChars)
376
+ });
377
+ }
378
+ };
379
+ const drain = (flush = false) => {
324
380
  const lines = buffer.split(/\r?\n/);
325
381
  buffer = lines.pop() || '';
326
- for (const line of lines) {
327
- if (line) {
328
- this.sink.emit('terminal_output', {
329
- stream: 'stderr',
330
- text: line
331
- });
332
- }
382
+ for (const line of lines)
383
+ emit(line);
384
+ while (buffer.length >= this.maxStderrChunkChars) {
385
+ emit(buffer.slice(0, this.maxStderrChunkChars));
386
+ buffer = buffer.slice(this.maxStderrChunkChars);
333
387
  }
388
+ if (flush && buffer) {
389
+ emit(buffer);
390
+ buffer = '';
391
+ }
392
+ };
393
+ const flush = () => {
394
+ if (flushed)
395
+ return;
396
+ flushed = true;
397
+ drain(true);
398
+ };
399
+ child.stderr.on('data', (chunk) => {
400
+ buffer += String(chunk);
401
+ drain();
334
402
  });
403
+ child.stderr.once('end', flush);
404
+ child.stderr.once('close', flush);
335
405
  }
336
406
  onProcessFailure(error) {
337
407
  if (this.disposed)
@@ -345,6 +415,175 @@ export class AcpProcessRuntime {
345
415
  }
346
416
  return value;
347
417
  }
418
+ async promptBlocks(message, attachments) {
419
+ const blocks = [{ type: 'text', text: message }];
420
+ for (const attachment of attachments) {
421
+ const mediaType = attachment.mediaType || 'application/octet-stream';
422
+ const data = attachment.bytes.toString('base64');
423
+ if (mediaType.startsWith('image/') && this.promptCapabilities.image) {
424
+ blocks.push({ type: 'image', data, mimeType: mediaType });
425
+ continue;
426
+ }
427
+ if (mediaType.startsWith('audio/') && this.promptCapabilities.audio) {
428
+ blocks.push({ type: 'audio', data, mimeType: mediaType });
429
+ continue;
430
+ }
431
+ if (this.promptCapabilities.embeddedContext) {
432
+ blocks.push({
433
+ type: 'resource',
434
+ resource: {
435
+ uri: `nexus-input://${this.sink.id}/${encodeURIComponent(attachment.name)}`,
436
+ blob: data,
437
+ mimeType: mediaType
438
+ }
439
+ });
440
+ continue;
441
+ }
442
+ blocks.push(await this.writeResourceLink(attachment, mediaType));
443
+ }
444
+ return blocks;
445
+ }
446
+ async writeResourceLink(attachment, mediaType) {
447
+ const directory = this.inputDirectory || path.join(process.cwd(), '.nexus-inputs', this.sink.id);
448
+ this.inputDirectory = directory;
449
+ await mkdir(directory, { recursive: true });
450
+ const filename = `${attachment.id}-${safeFilename(attachment.name) || 'attachment'}`;
451
+ const filePath = path.join(directory, filename);
452
+ await writeFile(filePath, attachment.bytes, { mode: 0o600 });
453
+ return {
454
+ type: 'resource_link',
455
+ name: attachment.name,
456
+ uri: pathToFileURL(filePath).toString(),
457
+ mimeType: mediaType,
458
+ size: attachment.bytes.length
459
+ };
460
+ }
461
+ }
462
+ class PromptTimeoutError extends Error {
463
+ }
464
+ function safeFilename(value) {
465
+ return String(value || '')
466
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
467
+ .trim()
468
+ .slice(0, 180);
469
+ }
470
+ function withTimeout(promise, timeoutMs, message) {
471
+ let timer;
472
+ const timeout = new Promise((_, reject) => {
473
+ timer = setTimeout(() => reject(new PromptTimeoutError(message)), timeoutMs);
474
+ timer.unref?.();
475
+ });
476
+ return Promise.race([promise, timeout]).finally(() => {
477
+ if (timer)
478
+ clearTimeout(timer);
479
+ });
480
+ }
481
+ function artifactFromContent(content, id, hint) {
482
+ switch (content.type) {
483
+ case 'image': {
484
+ const filename = artifactFilename(content.uri, hint, `image-${artifactIndex(id)}`, content.mimeType);
485
+ return {
486
+ id,
487
+ name: filename,
488
+ filename,
489
+ mediaType: content.mimeType,
490
+ bytesBase64: content.data,
491
+ url: usableUrl(content.uri),
492
+ metadata: artifactMetadata(id, content.uri, content.annotations)
493
+ };
494
+ }
495
+ case 'audio': {
496
+ const filename = artifactFilename(undefined, hint, `audio-${artifactIndex(id)}`, content.mimeType);
497
+ return {
498
+ id,
499
+ name: filename,
500
+ filename,
501
+ mediaType: content.mimeType,
502
+ bytesBase64: content.data,
503
+ metadata: artifactMetadata(id, undefined, content.annotations)
504
+ };
505
+ }
506
+ case 'resource_link': {
507
+ const filename = artifactFilename(content.uri, content.name || hint, `resource-${artifactIndex(id)}`, content.mimeType || undefined);
508
+ return {
509
+ id,
510
+ name: content.title || content.name || filename,
511
+ description: content.description || undefined,
512
+ filename,
513
+ mediaType: content.mimeType || undefined,
514
+ url: content.uri,
515
+ metadata: artifactMetadata(id, content.uri, content.annotations, content.size ?? undefined)
516
+ };
517
+ }
518
+ case 'resource': {
519
+ const resource = content.resource;
520
+ const filename = artifactFilename(resource.uri, hint, `resource-${artifactIndex(id)}`, resource.mimeType || undefined);
521
+ return {
522
+ id,
523
+ name: filename,
524
+ filename,
525
+ mediaType: resource.mimeType || undefined,
526
+ text: 'text' in resource ? resource.text : undefined,
527
+ bytesBase64: 'blob' in resource ? resource.blob : undefined,
528
+ url: usableUrl(resource.uri),
529
+ metadata: artifactMetadata(id, resource.uri, content.annotations)
530
+ };
531
+ }
532
+ default:
533
+ return undefined;
534
+ }
535
+ }
536
+ function artifactMetadata(sourceId, uri, annotations, size) {
537
+ return {
538
+ sourceId,
539
+ ...(uri ? { uri } : {}),
540
+ ...(size !== undefined ? { size } : {}),
541
+ ...(annotations ? { annotations } : {})
542
+ };
543
+ }
544
+ function artifactFilename(uri, hint, fallback, mediaType) {
545
+ return (filenameFromValue(uri) ||
546
+ filenameFromValue(hint) ||
547
+ `${fallback}${extensionForMediaType(mediaType)}`);
548
+ }
549
+ function filenameFromValue(value) {
550
+ if (!value)
551
+ return undefined;
552
+ let candidate = value;
553
+ try {
554
+ candidate = new URL(value).pathname;
555
+ }
556
+ catch { }
557
+ candidate = candidate.split(/[\\/]/).pop() || candidate;
558
+ candidate = candidate.split(/[?#]/, 1)[0].trim();
559
+ const filename = candidate.match(/([^\s]+\.[A-Za-z0-9]{1,16})$/)?.[1];
560
+ if (!filename)
561
+ return undefined;
562
+ try {
563
+ return decodeURIComponent(filename);
564
+ }
565
+ catch {
566
+ return filename;
567
+ }
568
+ }
569
+ function usableUrl(value) {
570
+ return value && /^(?:https?|file):/i.test(value) ? value : undefined;
571
+ }
572
+ function artifactIndex(id) {
573
+ return id.split(':').pop() || 'output';
574
+ }
575
+ function extensionForMediaType(mediaType) {
576
+ const extensions = {
577
+ 'image/png': '.png',
578
+ 'image/jpeg': '.jpg',
579
+ 'image/gif': '.gif',
580
+ 'image/webp': '.webp',
581
+ 'audio/mpeg': '.mp3',
582
+ 'audio/wav': '.wav',
583
+ 'audio/ogg': '.ogg',
584
+ 'application/pdf': '.pdf'
585
+ };
586
+ return mediaType ? extensions[mediaType.toLowerCase()] || '' : '';
348
587
  }
349
588
  function elicitationOptions(params) {
350
589
  if (!isFormElicitation(params))
@@ -0,0 +1,11 @@
1
+ export declare const MAX_AGENT_INSTRUCTIONS_CHARS: number;
2
+ /**
3
+ * Baseline guidance for Agents running behind Agent Nexus.
4
+ *
5
+ * This is delivered as the first prompt because ACP and A2A do not expose a
6
+ * portable system-message field across all supported Agent implementations.
7
+ */
8
+ export declare const DEFAULT_AGENT_INSTRUCTIONS: string;
9
+ export declare function normalizeAgentInstructions(value: unknown, field?: string): string | undefined;
10
+ export declare function buildAgentInstructions(custom?: string): string;
11
+ export declare function composeInitialAgentPrompt(instructions: string, userMessage: string): string;
@@ -0,0 +1,44 @@
1
+ export const MAX_AGENT_INSTRUCTIONS_CHARS = 32 * 1024;
2
+ /**
3
+ * Baseline guidance for Agents running behind Agent Nexus.
4
+ *
5
+ * This is delivered as the first prompt because ACP and A2A do not expose a
6
+ * portable system-message field across all supported Agent implementations.
7
+ */
8
+ export const DEFAULT_AGENT_INSTRUCTIONS = [
9
+ 'You are running behind the Agent Nexus conversational host.',
10
+ 'When an external tool or operation needs the user to choose, confirm, authorize, pay, or provide more information before it can continue:',
11
+ '1. Do not present the task as completed.',
12
+ '2. Use the host\'s native user-input, elicitation, or permission request mechanism when available; do not only print a normal-text question and stop.',
13
+ '3. Include a concise prompt and structured options when the mechanism supports them. For payment, include the payment URL and stable order metadata.',
14
+ '4. After the user replies, validate the result with the upstream tool. Never treat a message such as "payment completed" as proof that payment succeeded.',
15
+ 'Only finish the task after the external operation has actually completed.'
16
+ ].join('\n');
17
+ export function normalizeAgentInstructions(value, field = 'instructions') {
18
+ if (value === undefined || value === null)
19
+ return undefined;
20
+ if (typeof value !== 'string')
21
+ throw new Error(`${field} must be a string`);
22
+ const text = value.trim();
23
+ if (text.length > MAX_AGENT_INSTRUCTIONS_CHARS) {
24
+ throw new Error(`${field} must be at most ${MAX_AGENT_INSTRUCTIONS_CHARS} characters`);
25
+ }
26
+ return text || undefined;
27
+ }
28
+ export function buildAgentInstructions(custom) {
29
+ const extra = normalizeAgentInstructions(custom);
30
+ return extra
31
+ ? `${DEFAULT_AGENT_INSTRUCTIONS}\n\nAdditional agent-specific instructions:\n${extra}`
32
+ : DEFAULT_AGENT_INSTRUCTIONS;
33
+ }
34
+ export function composeInitialAgentPrompt(instructions, userMessage) {
35
+ return [
36
+ '<agent-nexus-host-instructions>',
37
+ instructions,
38
+ '</agent-nexus-host-instructions>',
39
+ '',
40
+ '<user-request>',
41
+ userMessage,
42
+ '</user-request>'
43
+ ].join('\n');
44
+ }
@@ -0,0 +1,41 @@
1
+ import { type FileHandle } from 'node:fs/promises';
2
+ import { type Readable } from 'node:stream';
3
+ import type { AgentdPublishedFile } from './types.js';
4
+ interface StoredArtifact extends AgentdPublishedFile {
5
+ filePath: string;
6
+ handle?: FileHandle;
7
+ }
8
+ export declare class ArtifactStoreError extends Error {
9
+ readonly status: number;
10
+ constructor(status: number, message: string);
11
+ }
12
+ export declare class GatewayArtifactStore {
13
+ private readonly directory;
14
+ private readonly ttlMs;
15
+ private readonly maxBytes;
16
+ private readonly maxStorageBytes;
17
+ private readonly maxFiles;
18
+ private readonly maxConcurrentPublishes;
19
+ private readonly byCacheKey;
20
+ private cleanupTimer?;
21
+ private capacityQueue;
22
+ private activePublishes;
23
+ private reservedBytes;
24
+ private reservedFiles;
25
+ constructor(directory: string, ttlMs: number, maxBytes: number, maxStorageBytes?: number, maxFiles?: number, maxConcurrentPublishes?: number);
26
+ startCleanup(intervalMs?: number): void;
27
+ stopCleanup(): void;
28
+ publishFile(filePath: string, preferredName?: string, mediaType?: string): Promise<AgentdPublishedFile>;
29
+ publishHandle(handle: FileHandle, preferredName: string, mediaType?: string): Promise<AgentdPublishedFile>;
30
+ publishBytes(bytes: Uint8Array, preferredName: string, mediaType?: string, cacheKey?: string): Promise<AgentdPublishedFile>;
31
+ publishStream(input: Readable, preferredName: string, mediaType?: string, declaredSize?: number, cacheKey?: string): Promise<AgentdPublishedFile>;
32
+ open(id: string, expiresInput: string, requestedName: string): Promise<StoredArtifact>;
33
+ cleanup(): Promise<void>;
34
+ private ensureDirectory;
35
+ private isAvailable;
36
+ private reserveCapacity;
37
+ private storageUsage;
38
+ }
39
+ export declare function contentDisposition(name: string): string;
40
+ export declare function mediaTypeForName(name: string): string;
41
+ export {};