nexus-ai-pro 2.19.0 → 2.20.2

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.
@@ -7,6 +7,7 @@ export const A2A_ERRORS = {
7
7
  unsupportedOperation: -32004,
8
8
  extendedCardNotConfigured: -32007,
9
9
  versionNotSupported: -32009,
10
+ permissionDenied: -32099,
10
11
  };
11
12
  export class A2aError extends Error {
12
13
  code;
@@ -45,7 +46,8 @@ export class MemoryA2aTaskStore {
45
46
  list(query) {
46
47
  const matching = [...this.tasks.values()]
47
48
  .filter((record) => (query.contextId === undefined || record.task.contextId === query.contextId) &&
48
- (query.state === undefined || record.task.status.state === query.state))
49
+ (query.state === undefined || record.task.status.state === query.state) &&
50
+ (query.tenant === undefined || record.tenant === query.tenant))
49
51
  .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
50
52
  return {
51
53
  records: matching.slice(query.offset, query.offset + query.limit).map((record) => structuredClone(record)),
@@ -83,6 +85,7 @@ export function storeA2aTasks(store, options = {}) {
83
85
  const filter = {
84
86
  ...(query.contextId === undefined ? {} : { 'task.contextId': query.contextId }),
85
87
  ...(query.state === undefined ? {} : { 'task.status.state': query.state }),
88
+ ...(query.tenant === undefined ? {} : { tenant: query.tenant }),
86
89
  };
87
90
  const items = await store.search(tasks, {
88
91
  limit: query.limit,
@@ -129,6 +132,7 @@ const ERROR_STATUS = {
129
132
  [-32007]: { httpStatus: 400, grpcStatus: 'FAILED_PRECONDITION', reason: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED' },
130
133
  [-32008]: { httpStatus: 400, grpcStatus: 'FAILED_PRECONDITION', reason: 'EXTENSION_SUPPORT_REQUIRED' },
131
134
  [-32009]: { httpStatus: 400, grpcStatus: 'UNIMPLEMENTED', reason: 'VERSION_NOT_SUPPORTED' },
135
+ [-32099]: { httpStatus: 403, grpcStatus: 'PERMISSION_DENIED', reason: 'PERMISSION_DENIED' },
132
136
  [-32700]: { httpStatus: 400, grpcStatus: 'INVALID_ARGUMENT', reason: 'INVALID_PARAMS' },
133
137
  [-32600]: { httpStatus: 400, grpcStatus: 'INVALID_ARGUMENT', reason: 'INVALID_PARAMS' },
134
138
  [-32601]: { httpStatus: 404, grpcStatus: 'UNIMPLEMENTED', reason: 'METHOD_NOT_FOUND' },
@@ -184,7 +188,12 @@ export function a2aHandler(graph, options) {
184
188
  defaultInputModes: options.card.defaultInputModes ?? ['text/plain', 'application/json'],
185
189
  defaultOutputModes: options.card.defaultOutputModes ?? ['text/plain'],
186
190
  skills: options.card.skills,
191
+ ...(options.card.securitySchemes ? { securitySchemes: options.card.securitySchemes } : {}),
192
+ ...(options.card.securityRequirements ? { securityRequirements: options.card.securityRequirements } : {}),
193
+ ...(options.card.signatures ? { signatures: options.card.signatures } : {}),
187
194
  };
195
+ const tenancy = options.tenants ? (options.tenants === true ? {} : options.tenants) : undefined;
196
+ const conversationKey = (record) => record.tenant === undefined ? record.task.contextId : `${record.tenant}\u0000${record.task.contextId}`;
188
197
  const store = options.store ?? new MemoryA2aTaskStore({ maxTasks: options.maxTasks ?? 10_000 });
189
198
  const owner = options.lease?.owner ?? protocolId('replica');
190
199
  const leaseMs = options.lease?.ms ?? 30_000;
@@ -385,8 +394,8 @@ export function a2aHandler(graph, options) {
385
394
  const text = answerOf(result.state);
386
395
  const reply = agentMessage(task, text);
387
396
  record.history.push(reply);
388
- const before = (await store.conversation(task.contextId)) ?? [];
389
- await store.saveConversation(task.contextId, transcriptOf(result.state, [...before, messageOf('assistant', text)]));
397
+ const before = (await store.conversation(conversationKey(record))) ?? [];
398
+ await store.saveConversation(conversationKey(record), transcriptOf(result.state, [...before, messageOf('assistant', text)]));
390
399
  task.artifacts = [{ artifactId: `${task.id}:answer`, name: 'answer', parts: [{ text }] }];
391
400
  if (streamed) {
392
401
  const last = {
@@ -426,10 +435,10 @@ export function a2aHandler(graph, options) {
426
435
  }
427
436
  };
428
437
  const startRun = async (record, message, runOptions) => {
429
- const history = (await store.conversation(record.task.contextId)) ?? [];
438
+ const history = (await store.conversation(conversationKey(record))) ?? [];
430
439
  const said = messageOf('user', textOfParts(message.parts));
431
440
  const result = await graph.invoke(options.input ? options.input(message, history) : { messages: [...history, said] }, runOptions);
432
- await store.saveConversation(record.task.contextId, [...history, said]);
441
+ await store.saveConversation(conversationKey(record), [...history, said]);
433
442
  return result;
434
443
  };
435
444
  const carryOn = async (record, runOptions) => {
@@ -481,11 +490,12 @@ export function a2aHandler(graph, options) {
481
490
  void recover().catch((error) => options.onError?.(error));
482
491
  }, options.recoverEveryMs);
483
492
  timer?.unref?.();
484
- const taskFor = async (message, principal) => {
493
+ const taskFor = async (message, principal, mine, tenant) => {
485
494
  if (message.taskId) {
486
495
  const existing = await store.get(message.taskId);
487
- if (!existing)
496
+ if (!existing || !mine(existing)) {
488
497
  return { error: A2A_ERRORS.taskNotFound, message: `Task ${message.taskId} was not found` };
498
+ }
489
499
  if (existing.task.status.state !== 'TASK_STATE_INPUT_REQUIRED') {
490
500
  return { error: A2A_ERRORS.unsupportedOperation, message: `Task ${message.taskId} is not waiting for input` };
491
501
  }
@@ -504,6 +514,7 @@ export function a2aHandler(graph, options) {
504
514
  history: [said],
505
515
  run: { kind: 'invoke', message: said, ...(principal ? { principal } : {}) },
506
516
  updatedAt: new Date().toISOString(),
517
+ ...(tenant !== undefined ? { tenant } : {}),
507
518
  };
508
519
  await store.put(record);
509
520
  return record;
@@ -590,9 +601,29 @@ export function a2aHandler(graph, options) {
590
601
  const pushAnswer = (taskId, config, binding) => binding === 'JSONRPC' ? { taskId, ...config, pushNotificationConfig: config } : { taskId, ...config };
591
602
  const fail = (code, message) => ({ error: { code, message } });
592
603
  const call = async (method, params, context = {}) => {
593
- const principal = context.principal;
604
+ let principal = context.principal;
594
605
  const binding = context.binding ?? 'JSONRPC';
595
606
  const pushOn = Boolean(options.pushNotifications);
607
+ const named = typeof params.tenant === 'string' && params.tenant !== '' ? params.tenant : undefined;
608
+ if (tenancy) {
609
+ if (named === undefined) {
610
+ if (tenancy.required !== false)
611
+ return fail(-32602, 'This agent serves several tenants: name one as tenant');
612
+ }
613
+ else {
614
+ const allowed = tenancy.authorize ? await tenancy.authorize(named, principal) : principal?.tenantId === named;
615
+ if (!allowed)
616
+ return fail(A2A_ERRORS.permissionDenied, `This caller may not act for tenant ${named}`);
617
+ if (principal?.tenantId !== named)
618
+ principal = { ...principal, tenantId: named };
619
+ }
620
+ }
621
+ const tenant = tenancy ? named : undefined;
622
+ const mine = (record) => !tenancy || record.tenant === tenant;
623
+ const own = async (id) => {
624
+ const record = await store.get(id);
625
+ return record && mine(record) ? record : undefined;
626
+ };
596
627
  try {
597
628
  switch (method) {
598
629
  case 'SendMessage':
@@ -605,7 +636,7 @@ export function a2aHandler(graph, options) {
605
636
  if (pushConfig && !pushOn) {
606
637
  return fail(A2A_ERRORS.pushNotificationNotSupported, 'This agent does not push notifications');
607
638
  }
608
- const record = await taskFor(message, principal);
639
+ const record = await taskFor(message, principal, mine, tenant);
609
640
  if ('error' in record)
610
641
  return fail(record.error, record.message);
611
642
  if (pushConfig) {
@@ -623,7 +654,7 @@ export function a2aHandler(graph, options) {
623
654
  return { events: follow(record, () => execute(record, message, principal), context.signal) };
624
655
  }
625
656
  case 'GetTask': {
626
- const record = await store.get(String(params.id ?? ''));
657
+ const record = await own(String(params.id ?? ''));
627
658
  if (!record)
628
659
  return fail(A2A_ERRORS.taskNotFound, `Task ${String(params.id)} was not found`);
629
660
  return { result: view(record, typeof params.historyLength === 'number' ? params.historyLength : undefined) };
@@ -639,6 +670,7 @@ export function a2aHandler(graph, options) {
639
670
  const listed = await store.list({
640
671
  ...(typeof params.contextId === 'string' && params.contextId !== '' ? { contextId: params.contextId } : {}),
641
672
  ...(filtered ? { state: status } : {}),
673
+ ...(tenant !== undefined ? { tenant } : {}),
642
674
  limit: pageSize,
643
675
  offset,
644
676
  });
@@ -648,6 +680,7 @@ export function a2aHandler(graph, options) {
648
680
  return {
649
681
  result: {
650
682
  tasks: listed.records
683
+ .filter(mine)
651
684
  .filter((record) => !after || (record.task.status.timestamp ?? '') >= after)
652
685
  .map((record) => view(record, historyLength))
653
686
  .map(({ artifacts, ...task }) => params.includeArtifacts === false ? task : { ...task, ...(artifacts ? { artifacts } : {}) }),
@@ -658,7 +691,7 @@ export function a2aHandler(graph, options) {
658
691
  };
659
692
  }
660
693
  case 'SubscribeToTask': {
661
- const record = await store.get(String(params.id ?? ''));
694
+ const record = await own(String(params.id ?? ''));
662
695
  if (!record)
663
696
  return fail(A2A_ERRORS.taskNotFound, `Task ${String(params.id)} was not found`);
664
697
  if (TERMINAL.has(record.task.status.state)) {
@@ -670,7 +703,7 @@ export function a2aHandler(graph, options) {
670
703
  return { events: follow(record, undefined, context.signal) };
671
704
  }
672
705
  case 'CancelTask': {
673
- const record = await store.get(String(params.id ?? ''));
706
+ const record = await own(String(params.id ?? ''));
674
707
  if (!record)
675
708
  return fail(A2A_ERRORS.taskNotFound, `Task ${String(params.id)} was not found`);
676
709
  if (TERMINAL.has(record.task.status.state)) {
@@ -690,7 +723,7 @@ export function a2aHandler(graph, options) {
690
723
  if (!pushOn)
691
724
  return fail(A2A_ERRORS.pushNotificationNotSupported, 'This agent does not push notifications');
692
725
  const taskId = String(params.taskId ?? params.id ?? '');
693
- const record = await store.get(taskId);
726
+ const record = await own(taskId);
694
727
  if (!record)
695
728
  return fail(A2A_ERRORS.taskNotFound, `Task ${taskId} was not found`);
696
729
  if (method === 'CreateTaskPushNotificationConfig') {
@@ -771,8 +804,21 @@ export function a2aHandler(graph, options) {
771
804
  return rpcError(id, outcome.error.code, outcome.error.message);
772
805
  return rpcResult(id, outcome.result);
773
806
  };
774
- const httpJson = async (request, route) => {
807
+ const httpJson = async (request, path) => {
775
808
  const verb = request.method;
809
+ const known = /^\/(?:message:send$|message:stream$|tasks(?:$|\/)|extendedAgentCard$)/;
810
+ let route = path;
811
+ let routedTenant;
812
+ const prefixed = /^\/([^/]+)(\/.*)$/.exec(path);
813
+ if (!known.test(path) && prefixed && known.test(prefixed[2])) {
814
+ try {
815
+ routedTenant = decodeURIComponent(prefixed[1]);
816
+ }
817
+ catch {
818
+ return restError(-32602, 'The tenant in the path is not valid percent-encoding');
819
+ }
820
+ route = prefixed[2];
821
+ }
776
822
  const query = new URL(request.url).searchParams;
777
823
  const number = (name) => (query.has(name) ? Number(query.get(name)) : undefined);
778
824
  const id = (value) => decodeURIComponent(value ?? '');
@@ -824,6 +870,8 @@ export function a2aHandler(graph, options) {
824
870
  const refused = versionRefused(request);
825
871
  if (refused)
826
872
  return restError(A2A_ERRORS.versionNotSupported, refused);
873
+ if (routedTenant !== undefined)
874
+ params = { ...params, tenant: routedTenant };
827
875
  if (body) {
828
876
  const text = await request.text();
829
877
  try {
@@ -1034,23 +1082,34 @@ export function a2aClient(options) {
1034
1082
  });
1035
1083
  return cached;
1036
1084
  };
1037
- const endpoint = async () => isCard
1038
- ? ((await card()).supportedInterfaces.find((item) => item.protocolBinding === binding)?.url ?? options.url)
1039
- : options.url;
1085
+ const target = async () => {
1086
+ if (!isCard)
1087
+ return { url: options.url, ...(options.tenant ? { tenant: options.tenant } : {}) };
1088
+ const chosen = (await card()).supportedInterfaces.find((item) => item.protocolBinding === binding);
1089
+ const tenant = options.tenant ?? chosen?.tenant;
1090
+ return { url: chosen?.url ?? options.url, ...(tenant ? { tenant } : {}) };
1091
+ };
1040
1092
  const headers = { 'content-type': 'application/json', 'a2a-version': A2A_PROTOCOL_VERSION, ...options.headers };
1041
1093
  let ids = 0;
1042
- const rpc = async (method, params, signal) => fetcher(await endpoint(), {
1043
- method: 'POST',
1044
- headers,
1045
- body: JSON.stringify({ jsonrpc: '2.0', id: ++ids, method, params }),
1046
- ...(signal ? { signal } : {}),
1047
- });
1048
- const route = async (verb, path, body, signal) => fetcher(`${(await endpoint()).replace(/\/+$/, '')}${path}`, {
1049
- method: verb,
1050
- headers: body === undefined ? { ...headers, 'content-type': 'application/json' } : headers,
1051
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
1052
- ...(signal ? { signal } : {}),
1053
- });
1094
+ const rpc = async (method, params, signal) => {
1095
+ const { url, tenant } = await target();
1096
+ return fetcher(url, {
1097
+ method: 'POST',
1098
+ headers,
1099
+ body: JSON.stringify({ jsonrpc: '2.0', id: ++ids, method, params: tenant ? { tenant, ...params } : params }),
1100
+ ...(signal ? { signal } : {}),
1101
+ });
1102
+ };
1103
+ const route = async (verb, path, body, signal) => {
1104
+ const { url, tenant } = await target();
1105
+ const under = tenant ? `/${encodeURIComponent(tenant)}` : '';
1106
+ return fetcher(`${url.replace(/\/+$/, '')}${under}${path}`, {
1107
+ method: verb,
1108
+ headers: body === undefined ? { ...headers, 'content-type': 'application/json' } : headers,
1109
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
1110
+ ...(signal ? { signal } : {}),
1111
+ });
1112
+ };
1054
1113
  const restFailure = async (response) => {
1055
1114
  const body = (await response.json().catch(() => ({})));
1056
1115
  const reason = body.error?.details?.find((detail) => detail.reason)?.reason;
package/dist/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * This package's version, recorded where provenance matters, such as on every experiment. A test
3
3
  * keeps it equal to `package.json`.
4
4
  */
5
- export declare const NEXUS_VERSION = "2.19.0";
5
+ export declare const NEXUS_VERSION = "2.20.2";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const NEXUS_VERSION = '2.19.0';
1
+ export const NEXUS_VERSION = '2.20.2';
@@ -12,6 +12,9 @@ import type { ToolDefinition } from '../types/messages.js';
12
12
  * | `mcp.read:<server>/<tool>` | An MCP tool its server says only reads |
13
13
  * | `mcp.write:<server>/<tool>` | An MCP tool its server says changes things but destroys nothing |
14
14
  * | `mcp.destructive:<server>/<tool>` | An MCP tool its server says may destroy or overwrite |
15
+ * | `api.read:<api>/<operation>` | An operation of an OpenAPI document whose method only reads |
16
+ * | `api.write:<api>/<operation>` | One whose method changes things |
17
+ * | `api.destructive:<api>/<operation>` | One whose method deletes |
15
18
  * | `<name>`, `<name>:<detail>` | Anything the application names, such as `payments:refund` |
16
19
  *
17
20
  * A tool that declares `capabilities: []` does nothing to the world; one that declares none at all
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tools read from a service's own API description, an OpenAPI document: one tool per operation, its
3
+ * name, description, and parameters the service's, the capabilities it declares read from its method,
4
+ * and what it returns kept as a schema. Given where the API lives, each tool calls it; without, the
5
+ * tools describe the API and run nowhere, as `emulatedTools()` wants them.
6
+ */
7
+ import type { ToolContext, ToolDefinition } from '../types/messages.js';
8
+ /** An OpenAPI 3 document, as parsed JSON. Only what tools are read from is typed. */
9
+ export interface OpenApiDocument {
10
+ /** The OpenAPI version, `3.0.x` or `3.1.x`. */
11
+ openapi: string;
12
+ /** The API's title and version. */
13
+ info?: {
14
+ title?: string;
15
+ version?: string;
16
+ description?: string;
17
+ };
18
+ /** Where the API is served. */
19
+ servers?: Array<{
20
+ url: string;
21
+ variables?: Record<string, {
22
+ default: string;
23
+ }>;
24
+ }>;
25
+ /** Its operations, by path and method. */
26
+ paths?: Record<string, Record<string, unknown>>;
27
+ /** Schemas, parameters, request bodies, and responses its operations refer to. */
28
+ components?: Record<string, unknown>;
29
+ [key: string]: unknown;
30
+ }
31
+ /** One operation of the API, as a tool reads it. */
32
+ export interface OpenApiOperation {
33
+ /** The tool's name: the operation's id, or its method and path. */
34
+ name: string;
35
+ /** The HTTP method, in capitals. */
36
+ method: string;
37
+ /** The path, with its `{parameters}`. */
38
+ path: string;
39
+ /** The operation's id, when it has one. */
40
+ operationId?: string;
41
+ /** Its summary, when it has one. */
42
+ summary?: string;
43
+ /** Its tags. */
44
+ tags: string[];
45
+ /** What it does to the service, read from its method: `read` for GET, HEAD, OPTIONS, and TRACE, `destructive` for DELETE, and `write` for the rest. */
46
+ access: 'read' | 'write' | 'destructive';
47
+ }
48
+ /** What a tool's headers are chosen from: the operation, and the call's context. */
49
+ export interface OpenApiHeadersRequest {
50
+ /** The operation being called. */
51
+ operation: OpenApiOperation;
52
+ /** The call's context: its tenant, principal, and thread. */
53
+ context?: ToolContext;
54
+ }
55
+ /** Options for `openApiTools()`. */
56
+ export interface OpenApiToolsOptions {
57
+ /** The API's name, in each tool's capabilities. Defaults to its title, as a slug, or `api`. */
58
+ name?: string;
59
+ /**
60
+ * Where the API lives, its path prefix included, such as `https://shop.example.com/api`. Defaults
61
+ * to the document's first server, resolved against `documentUrl` when relative. Without one, the
62
+ * tools describe the API and run nowhere.
63
+ */
64
+ baseUrl?: string;
65
+ /** Where the document came from, to resolve a relative server URL against. */
66
+ documentUrl?: string;
67
+ /**
68
+ * Headers every call sends, such as authorization, or a function choosing them per operation and
69
+ * call, such as by its tenant. They win over a header parameter the model sets.
70
+ */
71
+ headers?: Readonly<Record<string, string>> | ((request: OpenApiHeadersRequest) => Record<string, string> | Promise<Record<string, string>>);
72
+ /** Which operations become tools. Defaults to every one. */
73
+ include?: (operation: OpenApiOperation) => boolean;
74
+ /** Put before every tool's name, with an underscore, so two APIs' operations stay apart. */
75
+ prefix?: string;
76
+ /** How long a call may take, in milliseconds. Defaults to 30 seconds. */
77
+ timeoutMs?: number;
78
+ /** The largest answer a call reads, in bytes; a larger one fails the call. Defaults to 1 MB. */
79
+ maxResponseBytes?: number;
80
+ /** Replaces the global `fetch`. */
81
+ fetch?: typeof globalThis.fetch;
82
+ }
83
+ /** An API's operations as tools, with what each returns. */
84
+ export interface OpenApiTools {
85
+ /** The API's name, as its tools' capabilities carry it. */
86
+ name: string;
87
+ /** Where the tools send their calls, when they run. */
88
+ baseUrl?: string;
89
+ /** One tool per operation. */
90
+ tools: ToolDefinition[];
91
+ /** The operations, in the tools' order. */
92
+ operations: OpenApiOperation[];
93
+ /** The JSON Schema of what each tool returns, by name: its first success response's. Pass it to `emulatedTools()` as `results`. */
94
+ results: Record<string, Record<string, unknown>>;
95
+ /** Operations that could not become tools, and why: a request body of a type the tools do not send. */
96
+ skipped: Array<{
97
+ method: string;
98
+ path: string;
99
+ reason: string;
100
+ }>;
101
+ }
102
+ /** Why an API could not be read into tools, or a call to it failed. */
103
+ export declare class OpenApiError extends Error {
104
+ /**
105
+ * `OPENAPI_INVALID` for a malformed document or call, `OPENAPI_UNSUPPORTED` for one these tools
106
+ * cannot read, such as Swagger 2.0 or a reference outside the document, and `OPENAPI_REQUEST`
107
+ * for a call the API refused or did not answer.
108
+ */
109
+ readonly code: 'OPENAPI_INVALID' | 'OPENAPI_UNSUPPORTED' | 'OPENAPI_REQUEST';
110
+ /** The HTTP status the API answered with, when it did. */
111
+ readonly status?: number | undefined;
112
+ constructor(message: string,
113
+ /**
114
+ * `OPENAPI_INVALID` for a malformed document or call, `OPENAPI_UNSUPPORTED` for one these tools
115
+ * cannot read, such as Swagger 2.0 or a reference outside the document, and `OPENAPI_REQUEST`
116
+ * for a call the API refused or did not answer.
117
+ */
118
+ code: 'OPENAPI_INVALID' | 'OPENAPI_UNSUPPORTED' | 'OPENAPI_REQUEST',
119
+ /** The HTTP status the API answered with, when it did. */
120
+ status?: number | undefined);
121
+ }
122
+ /**
123
+ * Reads an OpenAPI 3 document into tools, one per operation: named by its `operationId`, or its
124
+ * method and path; described by its summary; its path, query, and header parameters, and the fields
125
+ * of a JSON body, as the tool's parameters. Each declares `api.read`, `api.write`, or
126
+ * `api.destructive` with the API's name and its own, as its method says, and `network` for the
127
+ * API's host when it runs. References within the document are followed; one outside it is refused.
128
+ *
129
+ * ```ts
130
+ * const shop = openApiTools(JSON.parse(await readFile('shop.openapi.json', 'utf8')), {
131
+ * baseUrl: 'https://shop.example.com/api',
132
+ * headers: { authorization: `Bearer ${process.env.SHOP_TOKEN}` },
133
+ * });
134
+ * const agent = createAgent({ client: ai, tools: shop.tools });
135
+ * ```
136
+ */
137
+ export declare function openApiTools(document: OpenApiDocument | string, options?: OpenApiToolsOptions): OpenApiTools;
138
+ /** Fetches an OpenAPI document, as JSON, and reads it into tools, resolving its servers against where it came from. */
139
+ export declare function loadOpenApiTools(url: string, options?: OpenApiToolsOptions): Promise<OpenApiTools>;