@vallum/standards 0.0.0-prerelease → 0.0.1-prerelease.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/dist/a2aHttp.d.ts CHANGED
@@ -1,14 +1,25 @@
1
1
  import { type A2AAgentCardWellKnownOptions } from "@vallum/registry";
2
2
  import type { AgentActionPolicy } from "@vallum/policy-gateway";
3
3
  import { type A2APushNotificationTransport, type A2ATaskPushNotificationConfig, type LocalA2APushNotificationStore } from "./a2aPush.js";
4
- import { type A2ATask, type A2AProcessMessageContext, type A2AProcessMessageResult, type LocalA2ATaskStore } from "./a2aTask.js";
4
+ import { type A2ATask, type A2AMessage, type A2AProcessMessageContext, type A2AProcessMessageResult, type LocalA2ATaskStore } from "./a2aTask.js";
5
+ export type A2AHttpStandardsBodyMode = "vallum" | "a2a";
6
+ export type A2AHttpA2ATaskBody = Omit<A2ATask, "agenticVallum">;
5
7
  export interface A2AHttpRequest {
6
8
  readonly method?: string;
7
9
  readonly path?: string;
8
10
  readonly headers?: Record<string, string | undefined>;
9
11
  readonly body?: unknown;
10
12
  }
11
- export type A2AHttpResponseBody = {
13
+ export type A2AHttpResponseBody = A2AHttpA2ATaskBody | {
14
+ readonly message: A2AMessage;
15
+ } | {
16
+ readonly task: A2AHttpA2ATaskBody;
17
+ } | {
18
+ readonly tasks: readonly A2AHttpA2ATaskBody[];
19
+ readonly nextPageToken?: string;
20
+ readonly pageSize?: number;
21
+ readonly totalSize?: number;
22
+ } | {
12
23
  readonly kind: "agent-card";
13
24
  readonly [key: string]: unknown;
14
25
  } | {
@@ -32,8 +43,16 @@ export type A2AHttpResponseBody = {
32
43
  readonly deleted: boolean;
33
44
  } | {
34
45
  readonly error: {
35
- readonly code: A2AHttpErrorCode | string;
46
+ readonly code: number;
47
+ readonly status: string;
36
48
  readonly message: string;
49
+ readonly details: readonly {
50
+ readonly "@type": "type.googleapis.com/google.rpc.ErrorInfo";
51
+ readonly reason: string;
52
+ readonly domain: "a2a-protocol.org";
53
+ readonly metadata?: Record<string, string>;
54
+ }[];
55
+ readonly reason: A2AHttpErrorCode | string;
37
56
  };
38
57
  };
39
58
  export interface A2AHttpResponse {
@@ -54,10 +73,13 @@ export interface LocalA2AHttpHandlerOptions {
54
73
  readonly pushNotificationStore?: LocalA2APushNotificationStore;
55
74
  readonly pushNotificationTransport?: A2APushNotificationTransport;
56
75
  readonly now?: () => Date;
76
+ readonly standardsBodyMode?: A2AHttpStandardsBodyMode;
77
+ readonly allowUnmanifestedTasks?: boolean;
57
78
  readonly processMessage?: (context: A2AProcessMessageContext) => Promise<A2AProcessMessageResult> | A2AProcessMessageResult;
58
79
  }
59
80
  export declare const A2A_HTTP_SEND_MESSAGE_PATH: "/message:send";
60
81
  export declare const A2A_HTTP_STREAM_MESSAGE_PATH: "/message:stream";
61
82
  export declare const A2A_HTTP_EXTENDED_AGENT_CARD_PATH: "/extendedAgentCard";
62
83
  export declare const A2A_HTTP_TASKS_PATH: "/tasks";
84
+ export declare const A2A_HTTP_SUBSCRIBE_TASK_SUFFIX: ":subscribe";
63
85
  export declare function handleLocalA2AHttpRequest(request: A2AHttpRequest, options: LocalA2AHttpHandlerOptions): Promise<A2AHttpResponse>;
package/dist/a2aHttp.js CHANGED
@@ -5,6 +5,7 @@ export const A2A_HTTP_SEND_MESSAGE_PATH = "/message:send";
5
5
  export const A2A_HTTP_STREAM_MESSAGE_PATH = "/message:stream";
6
6
  export const A2A_HTTP_EXTENDED_AGENT_CARD_PATH = "/extendedAgentCard";
7
7
  export const A2A_HTTP_TASKS_PATH = "/tasks";
8
+ export const A2A_HTTP_SUBSCRIBE_TASK_SUFFIX = ":subscribe";
8
9
  export async function handleLocalA2AHttpRequest(request, options) {
9
10
  const method = normalizeMethod(request.method);
10
11
  const url = parsePath(request.path);
@@ -39,32 +40,41 @@ export async function handleLocalA2AHttpRequest(request, options) {
39
40
  if (url.pathname === A2A_HTTP_TASKS_PATH) {
40
41
  if (method !== "GET")
41
42
  return methodNotAllowed(["GET"]);
42
- return ok({
43
- kind: "task-list",
44
- ...listA2ATasks({
45
- store: options.store,
46
- contextId: optionalQuery(url, "contextId"),
47
- state: optionalQuery(url, "state"),
48
- includeArtifacts: booleanQuery(url, "includeArtifacts"),
49
- historyLength: numberQuery(url, "historyLength"),
50
- pageSize: numberQuery(url, "pageSize"),
51
- }),
52
- });
43
+ const pageSize = numberQuery(url, "pageSize");
44
+ return taskListResponse(listA2ATasks({
45
+ store: options.store,
46
+ contextId: optionalQuery(url, "contextId"),
47
+ state: optionalQuery(url, "state"),
48
+ includeArtifacts: booleanQuery(url, "includeArtifacts"),
49
+ historyLength: numberQuery(url, "historyLength"),
50
+ pageSize,
51
+ }), options, pageSize);
53
52
  }
54
53
  const taskRoute = matchTaskRoute(url.pathname);
55
54
  if (taskRoute) {
56
55
  if (taskRoute.action === "get") {
57
56
  if (method !== "GET")
58
57
  return methodNotAllowed(["GET"]);
59
- return ok({
60
- kind: "task",
61
- ...getA2ATask({
62
- store: options.store,
63
- id: taskRoute.taskId,
64
- includeArtifacts: booleanQuery(url, "includeArtifacts"),
65
- historyLength: numberQuery(url, "historyLength"),
66
- }),
58
+ return taskResponse(getA2ATask({
59
+ store: options.store,
60
+ id: taskRoute.taskId,
61
+ includeArtifacts: booleanQuery(url, "includeArtifacts"),
62
+ historyLength: numberQuery(url, "historyLength"),
63
+ }), options);
64
+ }
65
+ if (taskRoute.action === "subscribe") {
66
+ if (method !== "GET" && method !== "POST")
67
+ return methodNotAllowed(["GET", "POST"]);
68
+ const result = getA2ATask({
69
+ store: options.store,
70
+ id: taskRoute.taskId,
71
+ includeArtifacts: booleanQuery(url, "includeArtifacts"),
72
+ historyLength: numberQuery(url, "historyLength"),
67
73
  });
74
+ if (options.standardsBodyMode === "a2a" && isTerminalTaskState(result.task.status.state)) {
75
+ throw new A2ATaskError("A2A_TASK_TERMINAL", "Terminal A2A tasks cannot be subscribed.", 409);
76
+ }
77
+ return taskResponse(result, options);
68
78
  }
69
79
  if (method !== "POST")
70
80
  return methodNotAllowed(["POST"]);
@@ -75,10 +85,7 @@ export async function handleLocalA2AHttpRequest(request, options) {
75
85
  includeArtifacts: booleanQuery(url, "includeArtifacts"),
76
86
  });
77
87
  await maybeDeliverPushNotifications(result.task, options);
78
- return ok({
79
- kind: "task",
80
- ...result,
81
- });
88
+ return taskResponse(result, options);
82
89
  }
83
90
  return errorResponse(404, "A2A_ROUTE_NOT_FOUND", "A2A route was not found.");
84
91
  }
@@ -137,13 +144,19 @@ function handleExtendedAgentCardRequest(options) {
137
144
  });
138
145
  }
139
146
  async function handleSendMessage(request, options) {
140
- if (!options.taskPolicy) {
141
- return errorResponse(503, "A2A_POLICY_NOT_CONFIGURED", "A2A task endpoint policy is not configured.");
147
+ const contentType = header(request.headers, "content-type");
148
+ if (contentType !== undefined && !isSupportedRequestContentType(contentType)) {
149
+ return errorResponse(415, "A2A_CONTENT_TYPE_NOT_SUPPORTED", "A2A request content type is not supported.");
142
150
  }
143
151
  const body = parseBody(request.body);
144
152
  if (!isSendMessageBody(body)) {
145
153
  return errorResponse(400, "A2A_BODY_INVALID", "A2A request body is invalid.");
146
154
  }
155
+ const isNewTask = body.message.taskId === undefined;
156
+ const canCreateUnmanifestedTask = options.allowUnmanifestedTasks && body.manifest === undefined;
157
+ if (isNewTask && !options.taskPolicy && !canCreateUnmanifestedTask) {
158
+ return errorResponse(503, "A2A_POLICY_NOT_CONFIGURED", "A2A task endpoint policy is not configured.");
159
+ }
147
160
  const result = await sendA2AMessage({
148
161
  store: options.store,
149
162
  protocolVersion: body.protocolVersion ?? A2A_TASK_PROTOCOL_VERSION,
@@ -152,17 +165,16 @@ async function handleSendMessage(request, options) {
152
165
  policy: body.message.taskId ? undefined : options.taskPolicy,
153
166
  now: options.now?.(),
154
167
  contextId: body.contextId,
168
+ allowUnmanifestedTasks: options.allowUnmanifestedTasks,
169
+ returnImmediately: body.configuration?.returnImmediately,
155
170
  processMessage: options.processMessage,
156
171
  });
157
172
  await maybeDeliverPushNotifications(result.task, options);
158
- return ok({
159
- kind: "task",
160
- ...result,
161
- });
173
+ return sendMessageResponse(result, options);
162
174
  }
163
175
  function handlePushNotificationRoute(method, url, request, options, route) {
164
176
  if (!options.pushNotificationStore) {
165
- return errorResponse(501, "A2A_OPERATION_UNSUPPORTED", "A2A push notification configuration is not enabled for this local Vallum server.");
177
+ return errorResponse(options.standardsBodyMode === "a2a" ? 400 : 501, options.standardsBodyMode === "a2a" ? "A2A_PUSH_NOTIFICATION_NOT_SUPPORTED" : "A2A_OPERATION_UNSUPPORTED", "A2A push notification configuration is not enabled for this local Vallum server.");
166
178
  }
167
179
  getA2ATask({ store: options.store, id: route.taskId });
168
180
  if (!route.configId) {
@@ -211,6 +223,14 @@ function handlePushNotificationRoute(method, url, request, options, route) {
211
223
  }
212
224
  return methodNotAllowed(["DELETE", "GET"]);
213
225
  }
226
+ function isTerminalTaskState(state) {
227
+ return [
228
+ "TASK_STATE_COMPLETED",
229
+ "TASK_STATE_CANCELED",
230
+ "TASK_STATE_FAILED",
231
+ "TASK_STATE_REJECTED",
232
+ ].includes(state);
233
+ }
214
234
  async function maybeDeliverPushNotifications(task, options) {
215
235
  if (!options.pushNotificationStore || !options.pushNotificationTransport)
216
236
  return;
@@ -237,19 +257,73 @@ function validateProtocolVersion(request) {
237
257
  }
238
258
  return undefined;
239
259
  }
240
- function ok(body) {
260
+ function ok(body, contentType = A2A_TASK_MEDIA_TYPE) {
241
261
  return {
242
262
  status: 200,
243
263
  headers: {
244
- "content-type": `${A2A_TASK_MEDIA_TYPE}; charset=utf-8`,
264
+ "content-type": `${contentType}; charset=utf-8`,
245
265
  "cache-control": "no-store",
246
266
  },
247
267
  body,
248
268
  json: `${JSON.stringify(body)}\n`,
249
269
  };
250
270
  }
271
+ function sendMessageResponse(result, options) {
272
+ if (options.standardsBodyMode === "a2a") {
273
+ if (result.message) {
274
+ return ok({ message: result.message }, "application/json");
275
+ }
276
+ return ok({ task: a2aTaskBody(result.task) }, "application/json");
277
+ }
278
+ return ok({
279
+ kind: "task",
280
+ ...result,
281
+ });
282
+ }
283
+ function taskResponse(result, options) {
284
+ if (options.standardsBodyMode === "a2a") {
285
+ return ok(a2aTaskBody(result.task), "application/json");
286
+ }
287
+ return ok({
288
+ kind: "task",
289
+ ...result,
290
+ });
291
+ }
292
+ function taskListResponse(result, options, pageSize) {
293
+ if (options.standardsBodyMode === "a2a") {
294
+ return ok({
295
+ tasks: result.tasks.map(a2aTaskBody),
296
+ nextPageToken: "",
297
+ pageSize: pageSize ?? result.tasks.length,
298
+ totalSize: result.tasks.length,
299
+ }, "application/json");
300
+ }
301
+ return ok({
302
+ kind: "task-list",
303
+ ...result,
304
+ });
305
+ }
306
+ function a2aTaskBody(task) {
307
+ const { agenticVallum: _agenticVallum, ...rest } = task;
308
+ return rest;
309
+ }
251
310
  function errorResponse(status, code, message, headers = {}) {
252
- const body = { error: { code, message } };
311
+ const body = {
312
+ error: {
313
+ code: status,
314
+ status: httpStatusName(status),
315
+ message,
316
+ details: [{
317
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
318
+ reason: a2aErrorInfoReason(code),
319
+ domain: "a2a-protocol.org",
320
+ metadata: {
321
+ vallumCode: code,
322
+ },
323
+ }],
324
+ reason: code,
325
+ },
326
+ };
253
327
  return {
254
328
  status,
255
329
  headers: {
@@ -264,6 +338,51 @@ function errorResponse(status, code, message, headers = {}) {
264
338
  function methodNotAllowed(allowed) {
265
339
  return errorResponse(405, "A2A_METHOD_NOT_ALLOWED", "A2A route does not support this method.", { allow: allowed.join(", ") });
266
340
  }
341
+ function httpStatusName(status) {
342
+ switch (status) {
343
+ case 400:
344
+ return "INVALID_ARGUMENT";
345
+ case 401:
346
+ return "UNAUTHENTICATED";
347
+ case 404:
348
+ return "NOT_FOUND";
349
+ case 405:
350
+ return "METHOD_NOT_ALLOWED";
351
+ case 409:
352
+ return "FAILED_PRECONDITION";
353
+ case 410:
354
+ return "ABORTED";
355
+ case 415:
356
+ return "INVALID_ARGUMENT";
357
+ case 501:
358
+ return "UNIMPLEMENTED";
359
+ case 503:
360
+ return "UNAVAILABLE";
361
+ case 200:
362
+ return "OK";
363
+ }
364
+ }
365
+ function a2aErrorInfoReason(code) {
366
+ switch (code) {
367
+ case "A2A_TASK_NOT_FOUND":
368
+ return "TASK_NOT_FOUND";
369
+ case "A2A_TASK_NOT_CANCELABLE":
370
+ return "TASK_NOT_CANCELABLE";
371
+ case "A2A_PUSH_NOT_ENABLED":
372
+ case "A2A_OPERATION_UNSUPPORTED":
373
+ return "UNSUPPORTED_OPERATION";
374
+ case "A2A_PUSH_NOTIFICATION_NOT_SUPPORTED":
375
+ return "PUSH_NOTIFICATION_NOT_SUPPORTED";
376
+ case "A2A_EXTENDED_AGENT_CARD_NOT_CONFIGURED":
377
+ return "EXTENDED_AGENT_CARD_NOT_CONFIGURED";
378
+ case "A2A_VERSION_NOT_SUPPORTED":
379
+ return "VERSION_NOT_SUPPORTED";
380
+ case "A2A_CONTENT_TYPE_NOT_SUPPORTED":
381
+ return "CONTENT_TYPE_NOT_SUPPORTED";
382
+ default:
383
+ return "INVALID_REQUEST";
384
+ }
385
+ }
267
386
  function parseBody(body) {
268
387
  if (typeof body !== "string")
269
388
  return body;
@@ -279,6 +398,10 @@ function isSendMessageBody(value) {
279
398
  return false;
280
399
  return isRecord(value.message);
281
400
  }
401
+ function isSupportedRequestContentType(contentType) {
402
+ const mediaType = contentType.split(";")[0]?.trim().toLowerCase();
403
+ return mediaType === "application/json" || mediaType === A2A_TASK_MEDIA_TYPE;
404
+ }
282
405
  function matchTaskRoute(pathname) {
283
406
  const parts = pathname.split("/").filter(Boolean);
284
407
  if (parts.length !== 2 || parts[0] !== "tasks")
@@ -287,6 +410,12 @@ function matchTaskRoute(pathname) {
287
410
  if (taskPart.endsWith(":cancel")) {
288
411
  return { taskId: decodeURIComponent(taskPart.slice(0, -":cancel".length)), action: "cancel" };
289
412
  }
413
+ if (taskPart.endsWith(A2A_HTTP_SUBSCRIBE_TASK_SUFFIX)) {
414
+ return {
415
+ taskId: decodeURIComponent(taskPart.slice(0, -A2A_HTTP_SUBSCRIBE_TASK_SUFFIX.length)),
416
+ action: "subscribe",
417
+ };
418
+ }
290
419
  return { taskId: decodeURIComponent(taskPart), action: "get" };
291
420
  }
292
421
  function matchPushNotificationRoute(pathname) {
@@ -1,6 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { A2A_JWKS_WELL_KNOWN_PATH, handleA2APublicJwksRequest, } from "@vallum/registry";
3
- import { A2A_HTTP_SEND_MESSAGE_PATH, A2A_HTTP_STREAM_MESSAGE_PATH, handleLocalA2AHttpRequest, } from "./a2aHttp.js";
3
+ import { A2A_HTTP_SEND_MESSAGE_PATH, A2A_HTTP_SUBSCRIBE_TASK_SUFFIX, A2A_HTTP_STREAM_MESSAGE_PATH, handleLocalA2AHttpRequest, } from "./a2aHttp.js";
4
4
  const DEFAULT_HOST = "127.0.0.1";
5
5
  const DEFAULT_MAX_BODY_BYTES = 64_000;
6
6
  export async function startLocalA2ANodeServer(options) {
@@ -8,6 +8,7 @@ export async function startLocalA2ANodeServer(options) {
8
8
  if (!options.allowNonLoopbackHost && !isLoopbackHost(host)) {
9
9
  throw new Error("A2A local Node server refuses non-loopback hosts without explicit opt-in.");
10
10
  }
11
+ const subscribers = new Map();
11
12
  const server = createServer(async (request, response) => {
12
13
  try {
13
14
  const body = await readBody(request, options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
@@ -30,7 +31,27 @@ export async function startLocalA2ANodeServer(options) {
30
31
  writeHandledResponse(response, handled.status, handled.headers, handled.json);
31
32
  return;
32
33
  }
33
- writeServerSentEvents(response, [{ event: "task", data: handled.body }]);
34
+ notifySubscribers(subscribers, handled.body, options);
35
+ writeServerSentEvents(response, [{ event: "task", data: streamResponseBody(handled.body, options) }]);
36
+ return;
37
+ }
38
+ if (isSubscribeTaskPath(requestPathname(request.url))) {
39
+ const handled = await handleLocalA2AHttpRequest({
40
+ method: request.method,
41
+ path: request.url,
42
+ headers: normalizeHeaders(request.headers),
43
+ body,
44
+ }, options);
45
+ if (handled.status !== 200) {
46
+ writeHandledResponse(response, handled.status, handled.headers, handled.json);
47
+ return;
48
+ }
49
+ const task = a2aTaskFromResponseBody(handled.body);
50
+ if (!task) {
51
+ writeServerSentEvents(response, [{ event: "task", data: streamResponseBody(handled.body, options) }]);
52
+ return;
53
+ }
54
+ writeSubscribeResponse(response, task, subscribers, options);
34
55
  return;
35
56
  }
36
57
  const handled = await handleLocalA2AHttpRequest({
@@ -39,6 +60,9 @@ export async function startLocalA2ANodeServer(options) {
39
60
  headers: normalizeHeaders(request.headers),
40
61
  body,
41
62
  }, options);
63
+ if (handled.status === 200) {
64
+ notifySubscribers(subscribers, handled.body, options);
65
+ }
42
66
  writeHandledResponse(response, handled.status, handled.headers, handled.json);
43
67
  }
44
68
  catch (error) {
@@ -68,7 +92,10 @@ export async function startLocalA2ANodeServer(options) {
68
92
  host: address.address,
69
93
  port: address.port,
70
94
  boundToLoopback: isLoopbackHost(address.address),
71
- close: () => closeServer(server),
95
+ close: async () => {
96
+ closeSubscribers(subscribers);
97
+ await closeServer(server);
98
+ },
72
99
  };
73
100
  }
74
101
  function listen(server, host, port) {
@@ -99,11 +126,99 @@ function writeServerSentEvents(response, events) {
99
126
  response.setHeader("connection", "keep-alive");
100
127
  response.setHeader("x-accel-buffering", "no");
101
128
  for (const event of events) {
102
- response.write(`event: ${event.event}\n`);
103
- response.write(`data: ${JSON.stringify(event.data)}\n\n`);
129
+ writeServerSentEvent(response, event);
104
130
  }
105
131
  response.end();
106
132
  }
133
+ function writeSubscribeResponse(response, task, subscribers, options) {
134
+ response.statusCode = 200;
135
+ response.setHeader("content-type", "text/event-stream; charset=utf-8");
136
+ response.setHeader("cache-control", "no-store");
137
+ response.setHeader("connection", "keep-alive");
138
+ response.setHeader("x-accel-buffering", "no");
139
+ writeServerSentEvent(response, { event: "task", data: streamResponseBody(task, options) });
140
+ if (isTerminalTaskState(task.status.state)) {
141
+ response.end();
142
+ return;
143
+ }
144
+ const taskSubscribers = subscribers.get(task.id) ?? new Set();
145
+ taskSubscribers.add(response);
146
+ subscribers.set(task.id, taskSubscribers);
147
+ response.once("close", () => {
148
+ taskSubscribers.delete(response);
149
+ if (taskSubscribers.size === 0)
150
+ subscribers.delete(task.id);
151
+ });
152
+ }
153
+ function notifySubscribers(subscribers, body, options) {
154
+ const task = a2aTaskFromResponseBody(body);
155
+ if (!task)
156
+ return;
157
+ const taskSubscribers = subscribers.get(task.id);
158
+ if (!taskSubscribers || taskSubscribers.size === 0)
159
+ return;
160
+ for (const subscriber of [...taskSubscribers]) {
161
+ if (subscriber.destroyed || subscriber.writableEnded) {
162
+ taskSubscribers.delete(subscriber);
163
+ continue;
164
+ }
165
+ writeServerSentEvent(subscriber, { event: "task", data: streamResponseBody(task, options) });
166
+ if (isTerminalTaskState(task.status.state)) {
167
+ taskSubscribers.delete(subscriber);
168
+ subscriber.end();
169
+ }
170
+ }
171
+ if (taskSubscribers.size === 0)
172
+ subscribers.delete(task.id);
173
+ }
174
+ function closeSubscribers(subscribers) {
175
+ for (const taskSubscribers of subscribers.values()) {
176
+ for (const subscriber of taskSubscribers) {
177
+ if (!subscriber.destroyed && !subscriber.writableEnded)
178
+ subscriber.end();
179
+ }
180
+ }
181
+ subscribers.clear();
182
+ }
183
+ function writeServerSentEvent(response, event) {
184
+ response.write(`event: ${event.event}\n`);
185
+ response.write(`data: ${JSON.stringify(event.data)}\n\n`);
186
+ }
187
+ function streamResponseBody(body, options) {
188
+ if (options.standardsBodyMode !== "a2a")
189
+ return body;
190
+ const task = a2aTaskFromResponseBody(body);
191
+ if (!task)
192
+ return body;
193
+ return {
194
+ status_update: {
195
+ taskId: task.id,
196
+ contextId: task.contextId,
197
+ status: task.status,
198
+ },
199
+ };
200
+ }
201
+ function a2aTaskFromResponseBody(body) {
202
+ if (!isRecord(body))
203
+ return undefined;
204
+ const record = body;
205
+ if (typeof record.id === "string" && isRecord(record.status)) {
206
+ return record;
207
+ }
208
+ const task = record.task;
209
+ if (isRecord(task) && typeof task.id === "string" && isRecord(task.status)) {
210
+ return task;
211
+ }
212
+ return undefined;
213
+ }
214
+ function isTerminalTaskState(state) {
215
+ return [
216
+ "TASK_STATE_COMPLETED",
217
+ "TASK_STATE_CANCELED",
218
+ "TASK_STATE_FAILED",
219
+ "TASK_STATE_REJECTED",
220
+ ].includes(state);
221
+ }
107
222
  function normalizeHeaders(headers) {
108
223
  const normalized = {};
109
224
  for (const [name, value] of Object.entries(headers)) {
@@ -141,6 +256,10 @@ function requestPathname(url = "/") {
141
256
  return "/";
142
257
  }
143
258
  }
259
+ function isSubscribeTaskPath(pathname) {
260
+ const parts = pathname.split("/").filter(Boolean);
261
+ return parts.length === 2 && parts[0] === "tasks" && (parts[1] ?? "").endsWith(A2A_HTTP_SUBSCRIBE_TASK_SUFFIX);
262
+ }
144
263
  function isAddressInfo(value) {
145
264
  return Boolean(value)
146
265
  && typeof value === "object"
@@ -148,6 +267,9 @@ function isAddressInfo(value) {
148
267
  && "address" in value
149
268
  && "port" in value;
150
269
  }
270
+ function isRecord(value) {
271
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
272
+ }
151
273
  class BodyTooLargeError extends Error {
152
274
  constructor() {
153
275
  super("A2A request body is too large.");
package/dist/a2aPush.d.ts CHANGED
@@ -30,9 +30,12 @@ export interface A2APushNotificationTransportResponse {
30
30
  readonly status: number;
31
31
  }
32
32
  export type A2APushNotificationTransport = (request: A2APushNotificationDeliveryRequest) => A2APushNotificationTransportResponse | Promise<A2APushNotificationTransportResponse>;
33
+ export type A2ACallbackHostResolver = (hostname: string) => readonly string[] | Promise<readonly string[]>;
33
34
  export interface A2APushHttpTransportOptions {
34
35
  readonly allowedCallbackHosts?: readonly string[];
35
36
  readonly fetch?: typeof fetch;
37
+ readonly requireCallbackHostAllowlist?: boolean;
38
+ readonly resolveCallbackHost?: A2ACallbackHostResolver;
36
39
  readonly timeoutMs?: number;
37
40
  }
38
41
  export interface A2APushNotificationDeliveryAttempt {
package/dist/a2aPush.js CHANGED
@@ -327,11 +327,17 @@ export function createA2APushHttpTransport(options = {}) {
327
327
  if (typeof fetchImpl !== "function") {
328
328
  throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push HTTP transport requires fetch support.");
329
329
  }
330
+ if (options.requireCallbackHostAllowlist && (!options.allowedCallbackHosts || options.allowedCallbackHosts.length === 0)) {
331
+ throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A production push HTTP transport requires a callback host allowlist.");
332
+ }
330
333
  const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
331
334
  return async (request) => {
332
335
  const url = safeWebhookUrl(request.url, {
333
336
  allowedCallbackHosts: options.allowedCallbackHosts,
334
337
  });
338
+ if (options.resolveCallbackHost) {
339
+ await assertResolvedWebhookHostSafe(new URL(url).hostname, options.resolveCallbackHost);
340
+ }
335
341
  const controller = new AbortController();
336
342
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
337
343
  try {
@@ -349,6 +355,18 @@ export function createA2APushHttpTransport(options = {}) {
349
355
  }
350
356
  };
351
357
  }
358
+ async function assertResolvedWebhookHostSafe(hostname, resolver) {
359
+ let addresses;
360
+ try {
361
+ addresses = await resolver(hostname);
362
+ }
363
+ catch {
364
+ throw new A2APushNotificationError("A2A_PUSH_URL_UNSAFE", "A2A push notification URL host could not be resolved safely.");
365
+ }
366
+ if (addresses.length === 0 || addresses.some((address) => isUnsafeWebhookHost(address))) {
367
+ throw new A2APushNotificationError("A2A_PUSH_URL_UNSAFE", "A2A push notification URL host resolves to an unsafe network address.");
368
+ }
369
+ }
352
370
  function parsePushNotificationConfig(taskId, value, options = {}) {
353
371
  if (!isRecord(value)) {
354
372
  throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push notification config must be an object.");
@@ -586,6 +604,9 @@ function isUnsafeWebhookHost(hostname) {
586
604
  || first >= 224;
587
605
  }
588
606
  if (ipVersion === 6) {
607
+ if (normalized.startsWith("::ffff:")) {
608
+ return isUnsafeWebhookHost(normalized.slice("::ffff:".length));
609
+ }
589
610
  return normalized === "::"
590
611
  || normalized === "::1"
591
612
  || normalized.startsWith("fc")
package/dist/a2aTask.d.ts CHANGED
@@ -7,6 +7,10 @@ export type A2ATaskState = "TASK_STATE_SUBMITTED" | "TASK_STATE_WORKING" | "TASK
7
7
  export interface A2APart {
8
8
  readonly text?: string;
9
9
  readonly data?: Record<string, unknown>;
10
+ readonly raw?: string;
11
+ readonly url?: string;
12
+ readonly filename?: string;
13
+ readonly mediaType?: string;
10
14
  readonly file?: {
11
15
  readonly uri?: string;
12
16
  readonly bytes?: string;
@@ -54,11 +58,11 @@ export interface A2ATask {
54
58
  };
55
59
  readonly metadata?: Record<string, unknown>;
56
60
  }
57
- export type A2ATaskErrorCode = "A2A_VERSION_NOT_SUPPORTED" | "A2A_MESSAGE_INVALID" | "A2A_MANIFEST_REQUIRED" | "A2A_POLICY_REQUIRED" | "A2A_MANIFEST_INVALID" | "A2A_TASK_NOT_FOUND" | "A2A_TASK_TERMINAL" | "A2A_CONTEXT_MISMATCH";
61
+ export type A2ATaskErrorCode = "A2A_VERSION_NOT_SUPPORTED" | "A2A_MESSAGE_INVALID" | "A2A_CONTENT_TYPE_NOT_SUPPORTED" | "A2A_MANIFEST_REQUIRED" | "A2A_POLICY_REQUIRED" | "A2A_MANIFEST_INVALID" | "A2A_TASK_NOT_FOUND" | "A2A_TASK_TERMINAL" | "A2A_CONTEXT_MISMATCH";
58
62
  export declare class A2ATaskError extends Error {
59
63
  readonly code: A2ATaskErrorCode;
60
- readonly status: 400 | 404 | 409;
61
- constructor(code: A2ATaskErrorCode, message: string, status?: 400 | 404 | 409);
64
+ readonly status: 400 | 404 | 409 | 415;
65
+ constructor(code: A2ATaskErrorCode, message: string, status?: 400 | 404 | 409 | 415);
62
66
  }
63
67
  export interface A2AProcessMessageContext {
64
68
  readonly task: A2ATask;
@@ -71,6 +75,7 @@ export interface A2AProcessMessageResult {
71
75
  readonly message?: A2AMessage;
72
76
  readonly artifacts?: readonly A2AArtifact[];
73
77
  readonly metadata?: Record<string, unknown>;
78
+ readonly responseKind?: "task" | "message";
74
79
  }
75
80
  export interface SendA2AMessageOptions {
76
81
  readonly store: LocalA2ATaskStore;
@@ -80,10 +85,13 @@ export interface SendA2AMessageOptions {
80
85
  readonly policy?: AgentActionPolicy;
81
86
  readonly now?: Date;
82
87
  readonly contextId?: string;
88
+ readonly allowUnmanifestedTasks?: boolean;
89
+ readonly returnImmediately?: boolean;
83
90
  readonly processMessage?: (context: A2AProcessMessageContext) => Promise<A2AProcessMessageResult> | A2AProcessMessageResult;
84
91
  }
85
92
  export interface SendA2AMessageResult {
86
93
  readonly task: A2ATask;
94
+ readonly message?: A2AMessage;
87
95
  readonly policyDecision?: AgentPolicyDecision;
88
96
  }
89
97
  export interface GetA2ATaskOptions {
package/dist/a2aTask.js CHANGED
@@ -78,6 +78,21 @@ export function redactA2ATaskForLog(task) {
78
78
  }
79
79
  function createTask(options, message, now) {
80
80
  if (options.manifest === undefined) {
81
+ if (options.allowUnmanifestedTasks) {
82
+ const baseTask = {
83
+ id: randomId("task"),
84
+ contextId: message.contextId ?? options.contextId ?? randomId("ctx"),
85
+ status: {
86
+ state: "TASK_STATE_SUBMITTED",
87
+ timestamp: timestamp(now),
88
+ },
89
+ history: [message],
90
+ };
91
+ if (options.returnImmediately) {
92
+ return { task: options.store.put(baseTask) };
93
+ }
94
+ return processAndStoreTask(options, baseTask, message, now);
95
+ }
81
96
  throw new A2ATaskError("A2A_MANIFEST_REQUIRED", "New A2A tasks require an Vallum manifest.");
82
97
  }
83
98
  if (!options.policy) {
@@ -115,6 +130,9 @@ function createTask(options, message, now) {
115
130
  if (!policyDecision.allowed) {
116
131
  return { task: options.store.put(baseTask), policyDecision };
117
132
  }
133
+ if (options.returnImmediately) {
134
+ return { task: options.store.put(baseTask), policyDecision };
135
+ }
118
136
  return processAndStoreTask(options, baseTask, message, now, manifest, policyDecision);
119
137
  }
120
138
  async function continueTask(options, message, now) {
@@ -151,6 +169,9 @@ async function processAndStoreTask(options, task, message, now, manifest, policy
151
169
  assertValidTaskState(outcome.state);
152
170
  if (outcome.message)
153
171
  assertValidMessage(outcome.message, "$.processMessage.message");
172
+ if (outcome.responseKind === "message" && !outcome.message) {
173
+ throw new A2ATaskError("A2A_MESSAGE_INVALID", "A2A message responses require an agent message.");
174
+ }
154
175
  for (const [index, artifact] of (outcome.artifacts ?? []).entries()) {
155
176
  assertValidArtifact(artifact, `$.processMessage.artifacts[${index}]`);
156
177
  }
@@ -169,7 +190,11 @@ async function processAndStoreTask(options, task, message, now, manifest, policy
169
190
  ...(outcome.artifacts ? { artifacts: outcome.artifacts.map(redactA2AArtifact) } : {}),
170
191
  ...(outcome.metadata ? { metadata: redactRecord(outcome.metadata) } : {}),
171
192
  };
172
- return { task: options.store.put(nextTask), policyDecision };
193
+ return {
194
+ task: options.store.put(nextTask),
195
+ ...(outcome.responseKind === "message" && statusMessage ? { message: statusMessage } : {}),
196
+ policyDecision,
197
+ };
173
198
  }
174
199
  function rejectionMessage(policyDecision, now) {
175
200
  return {
@@ -233,10 +258,21 @@ function assertValidPart(part, path) {
233
258
  typeof part.text === "string" && part.text.trim() !== "",
234
259
  isRecord(part.data),
235
260
  isRecord(part.file),
261
+ typeof part.raw === "string" && part.raw.trim() !== "",
262
+ typeof part.url === "string" && part.url.trim() !== "",
236
263
  ].filter(Boolean);
237
264
  if (variants.length !== 1) {
238
265
  throw new A2ATaskError("A2A_MESSAGE_INVALID", `${path} must contain exactly one text, data, or file part.`);
239
266
  }
267
+ if ((typeof part.raw === "string" || typeof part.url === "string") && !isSupportedFileMediaType(part.mediaType)) {
268
+ throw new A2ATaskError("A2A_CONTENT_TYPE_NOT_SUPPORTED", "A2A message part media type is not supported.", 415);
269
+ }
270
+ }
271
+ function isSupportedFileMediaType(mediaType) {
272
+ return typeof mediaType === "string" && [
273
+ "text/plain",
274
+ "application/json",
275
+ ].includes(mediaType.trim().toLowerCase());
240
276
  }
241
277
  function taskView(task, options) {
242
278
  const history = typeof options.historyLength === "number"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vallum/standards",
3
- "version": "0.0.0-prerelease",
3
+ "version": "0.0.1-prerelease.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,10 +12,10 @@
12
12
  },
13
13
  "license": "Apache-2.0",
14
14
  "dependencies": {
15
- "@vallum/manifest": "0.0.0-prerelease",
16
- "@vallum/policy-gateway": "0.0.0-prerelease",
17
- "@vallum/receipts": "0.0.0-prerelease",
18
- "@vallum/registry": "0.0.0-prerelease"
15
+ "@vallum/manifest": "0.0.1-prerelease.0",
16
+ "@vallum/policy-gateway": "0.0.1-prerelease.0",
17
+ "@vallum/receipts": "0.0.1-prerelease.0",
18
+ "@vallum/registry": "0.0.1-prerelease.0"
19
19
  },
20
20
  "description": "Standards bridge adapters for Vallum.",
21
21
  "files": [