@nshipster/sosumi 1.0.8 → 1.1.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 +48 -0
- package/package.json +1 -1
- package/public/SKILL.md +1 -0
- package/public/index.html +52 -5
- package/public/llms.txt +17 -2
- package/public/page.css +104 -0
- package/public/privacy.html +111 -0
- package/public/robots.txt +2 -0
- package/public/sitemap.xml +9 -0
- package/public/support.html +65 -0
- package/public/terms.html +73 -0
- package/src/index.ts +205 -10
- package/src/lib/a2a.ts +676 -5
- package/src/lib/ard.ts +77 -0
- package/src/lib/auth.ts +105 -0
- package/src/lib/did.ts +40 -0
- package/src/lib/hig/render.ts +1 -1
- package/src/lib/hig/types.ts +1 -1
- package/src/lib/identity.ts +4 -0
- package/src/lib/mcp.ts +13 -2
- package/src/lib/reference/render.ts +47 -2
- package/src/lib/types.ts +11 -0
- package/src/lib/webbotauth.ts +19 -120
- package/wrangler.jsonc +14 -2
package/src/lib/a2a.ts
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { resolveFetchEndpoint, resolveSearchEndpoint } from "./cli-endpoints"
|
|
1
3
|
import { MCP_SERVER_INFO, TOOL_DEFINITIONS } from "./mcp"
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* The A2A protocol version each interface exposes, as `major.minor`.
|
|
5
7
|
* See https://a2a-protocol.org/latest/specification/
|
|
6
8
|
*/
|
|
7
|
-
const A2A_PROTOCOL_VERSION = "0
|
|
9
|
+
export const A2A_PROTOCOL_VERSION = "1.0"
|
|
10
|
+
|
|
11
|
+
/** The HTTP+JSON media type registered by the A2A specification. */
|
|
12
|
+
export const A2A_MEDIA_TYPE = "application/a2a+json"
|
|
13
|
+
|
|
14
|
+
/** The synchronous HTTP+JSON operation exposed by Sosumi. */
|
|
15
|
+
export const A2A_MESSAGE_PATH = "/message:send"
|
|
16
|
+
|
|
17
|
+
/** The media type returned by every Sosumi A2A skill. */
|
|
18
|
+
export const A2A_OUTPUT_MEDIA_TYPE = "text/markdown"
|
|
19
|
+
|
|
20
|
+
/** Maximum wire size accepted for an unauthenticated A2A request body. */
|
|
21
|
+
export const A2A_MAX_REQUEST_BYTES = 32 * 1024
|
|
22
|
+
|
|
23
|
+
/** Maximum capability output that can be safely wrapped in an A2A JSON response. */
|
|
24
|
+
export const A2A_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
8
25
|
|
|
9
26
|
/**
|
|
10
27
|
* The transport binding advertised for the agent's interface.
|
|
@@ -43,8 +60,6 @@ interface AgentInterface {
|
|
|
43
60
|
protocolVersion: string
|
|
44
61
|
/** Transport binding. Named `protocolBinding` by the current A2A proto schema. */
|
|
45
62
|
protocolBinding: string
|
|
46
|
-
/** Alias of `protocolBinding` for clients reading the published JSON schema's `transport`. */
|
|
47
|
-
transport: string
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
/**
|
|
@@ -57,7 +72,6 @@ export function buildAgentCard(origin: string) {
|
|
|
57
72
|
url: origin,
|
|
58
73
|
protocolVersion: A2A_PROTOCOL_VERSION,
|
|
59
74
|
protocolBinding: TRANSPORT,
|
|
60
|
-
transport: TRANSPORT,
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
const skills = Object.values(TOOL_DEFINITIONS).map((def) => ({
|
|
@@ -84,7 +98,664 @@ export function buildAgentCard(origin: string) {
|
|
|
84
98
|
pushNotifications: false,
|
|
85
99
|
},
|
|
86
100
|
defaultInputModes: ["text/plain"],
|
|
87
|
-
defaultOutputModes: [
|
|
101
|
+
defaultOutputModes: [A2A_OUTPUT_MEDIA_TYPE],
|
|
88
102
|
skills,
|
|
89
103
|
}
|
|
90
104
|
}
|
|
105
|
+
|
|
106
|
+
type A2AHttpStatus = 400 | 403 | 404 | 413 | 500 | 503
|
|
107
|
+
|
|
108
|
+
const INT32_MAX = 2_147_483_647
|
|
109
|
+
const metadataSchema = z.record(z.unknown())
|
|
110
|
+
|
|
111
|
+
const nonNegativeInt32Schema = z.preprocess((value) => {
|
|
112
|
+
if (typeof value === "string" && /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) {
|
|
113
|
+
return Number(value)
|
|
114
|
+
}
|
|
115
|
+
return value
|
|
116
|
+
}, z.number().int().nonnegative().max(INT32_MAX))
|
|
117
|
+
|
|
118
|
+
const authenticationInfoSchema = z
|
|
119
|
+
.object({
|
|
120
|
+
scheme: z.string().min(1),
|
|
121
|
+
credentials: z.string().optional(),
|
|
122
|
+
})
|
|
123
|
+
.strict()
|
|
124
|
+
|
|
125
|
+
const taskPushNotificationConfigSchema = z
|
|
126
|
+
.object({
|
|
127
|
+
tenant: z.string().optional(),
|
|
128
|
+
id: z.string().optional(),
|
|
129
|
+
taskId: z.string().optional(),
|
|
130
|
+
url: z.string().url(),
|
|
131
|
+
token: z.string().optional(),
|
|
132
|
+
authentication: authenticationInfoSchema.optional(),
|
|
133
|
+
})
|
|
134
|
+
.strict()
|
|
135
|
+
|
|
136
|
+
const sendMessageConfigurationSchema = z
|
|
137
|
+
.object({
|
|
138
|
+
acceptedOutputModes: z.array(z.string()).optional(),
|
|
139
|
+
taskPushNotificationConfig: taskPushNotificationConfigSchema.optional(),
|
|
140
|
+
historyLength: nonNegativeInt32Schema.optional(),
|
|
141
|
+
returnImmediately: z.boolean().optional(),
|
|
142
|
+
})
|
|
143
|
+
.strict()
|
|
144
|
+
|
|
145
|
+
const partSchema = z
|
|
146
|
+
.object({
|
|
147
|
+
text: z.string().optional(),
|
|
148
|
+
raw: z.string().optional(),
|
|
149
|
+
url: z.string().optional(),
|
|
150
|
+
data: z.unknown().optional(),
|
|
151
|
+
metadata: metadataSchema.optional(),
|
|
152
|
+
filename: z.string().optional(),
|
|
153
|
+
mediaType: z.string().min(1).optional(),
|
|
154
|
+
})
|
|
155
|
+
.strict()
|
|
156
|
+
|
|
157
|
+
const messageSchema = z
|
|
158
|
+
.object({
|
|
159
|
+
messageId: z.string().min(1),
|
|
160
|
+
contextId: z.string().min(1).optional(),
|
|
161
|
+
taskId: z.string().min(1).optional(),
|
|
162
|
+
role: z.enum(["ROLE_UNSPECIFIED", "ROLE_USER", "ROLE_AGENT"]),
|
|
163
|
+
parts: z.array(partSchema).min(1),
|
|
164
|
+
metadata: metadataSchema.optional(),
|
|
165
|
+
extensions: z.array(z.string().min(1)).optional(),
|
|
166
|
+
referenceTaskIds: z.array(z.string().min(1)).optional(),
|
|
167
|
+
})
|
|
168
|
+
.strict()
|
|
169
|
+
|
|
170
|
+
const sendMessageRequestSchema = z
|
|
171
|
+
.object({
|
|
172
|
+
tenant: z.string().optional(),
|
|
173
|
+
message: messageSchema,
|
|
174
|
+
configuration: sendMessageConfigurationSchema.optional(),
|
|
175
|
+
metadata: metadataSchema.optional(),
|
|
176
|
+
})
|
|
177
|
+
.strict()
|
|
178
|
+
|
|
179
|
+
const LIST_TASK_QUERY_FIELDS = new Set([
|
|
180
|
+
"A2A-Version",
|
|
181
|
+
"tenant",
|
|
182
|
+
"contextId",
|
|
183
|
+
"status",
|
|
184
|
+
"pageSize",
|
|
185
|
+
"pageToken",
|
|
186
|
+
"historyLength",
|
|
187
|
+
"statusTimestampAfter",
|
|
188
|
+
"includeArtifacts",
|
|
189
|
+
])
|
|
190
|
+
|
|
191
|
+
const TASK_STATES = new Set([
|
|
192
|
+
"TASK_STATE_SUBMITTED",
|
|
193
|
+
"TASK_STATE_WORKING",
|
|
194
|
+
"TASK_STATE_COMPLETED",
|
|
195
|
+
"TASK_STATE_FAILED",
|
|
196
|
+
"TASK_STATE_CANCELED",
|
|
197
|
+
"TASK_STATE_INPUT_REQUIRED",
|
|
198
|
+
"TASK_STATE_REJECTED",
|
|
199
|
+
"TASK_STATE_AUTH_REQUIRED",
|
|
200
|
+
])
|
|
201
|
+
|
|
202
|
+
interface ParsedA2AMessage {
|
|
203
|
+
contextId?: string
|
|
204
|
+
prompt: string
|
|
205
|
+
outputMode: typeof A2A_OUTPUT_MEDIA_TYPE
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface A2AMessageResponse {
|
|
209
|
+
message: {
|
|
210
|
+
messageId: string
|
|
211
|
+
contextId: string
|
|
212
|
+
role: "ROLE_AGENT"
|
|
213
|
+
parts: Array<{
|
|
214
|
+
text: string
|
|
215
|
+
mediaType: typeof A2A_OUTPUT_MEDIA_TYPE
|
|
216
|
+
}>
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface A2AErrorBody {
|
|
221
|
+
error: {
|
|
222
|
+
code: A2AHttpStatus
|
|
223
|
+
status: string
|
|
224
|
+
message: string
|
|
225
|
+
details: Array<{
|
|
226
|
+
"@type": "type.googleapis.com/google.rpc.ErrorInfo"
|
|
227
|
+
reason: string
|
|
228
|
+
domain: "a2a-protocol.org"
|
|
229
|
+
metadata?: Record<string, string>
|
|
230
|
+
}>
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A protocol-aware error that can be serialized as google.rpc.Status JSON. */
|
|
235
|
+
export class A2AError extends Error {
|
|
236
|
+
constructor(
|
|
237
|
+
readonly statusCode: A2AHttpStatus,
|
|
238
|
+
readonly status: string,
|
|
239
|
+
readonly reason: string | null,
|
|
240
|
+
message: string,
|
|
241
|
+
readonly metadata?: Record<string, string>,
|
|
242
|
+
) {
|
|
243
|
+
super(message)
|
|
244
|
+
this.name = "A2AError"
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Validate the transport headers required by the advertised A2A 1.0 interface. */
|
|
249
|
+
export function validateA2ARequestHeaders(
|
|
250
|
+
contentType: string | null,
|
|
251
|
+
requestedVersion: string | null,
|
|
252
|
+
): void {
|
|
253
|
+
if (contentType) {
|
|
254
|
+
const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase()
|
|
255
|
+
if (mediaType !== "application/json" && mediaType !== A2A_MEDIA_TYPE) {
|
|
256
|
+
throw new A2AError(
|
|
257
|
+
400,
|
|
258
|
+
"INVALID_ARGUMENT",
|
|
259
|
+
"CONTENT_TYPE_NOT_SUPPORTED",
|
|
260
|
+
`Unsupported Content-Type "${contentType}"; expected application/json or ${A2A_MEDIA_TYPE}.`,
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Per A2A 1.0, an omitted version header means the legacy 0.3 protocol.
|
|
266
|
+
const version = requestedVersion?.trim() || "0.3"
|
|
267
|
+
if (version !== A2A_PROTOCOL_VERSION) {
|
|
268
|
+
throw new A2AError(
|
|
269
|
+
400,
|
|
270
|
+
"FAILED_PRECONDITION",
|
|
271
|
+
"VERSION_NOT_SUPPORTED",
|
|
272
|
+
`The requested A2A protocol version "${version}" is not supported.`,
|
|
273
|
+
{ requestedVersion: version, supportedVersions: A2A_PROTOCOL_VERSION },
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Convert an execution or validation failure to the A2A HTTP error envelope. */
|
|
279
|
+
export function toA2AErrorResponse(error: unknown): {
|
|
280
|
+
statusCode: A2AHttpStatus
|
|
281
|
+
body: A2AErrorBody
|
|
282
|
+
} {
|
|
283
|
+
const protocolError =
|
|
284
|
+
error instanceof A2AError
|
|
285
|
+
? error
|
|
286
|
+
: new A2AError(500, "INTERNAL", null, "The agent could not complete the request.")
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
statusCode: protocolError.statusCode,
|
|
290
|
+
body: {
|
|
291
|
+
error: {
|
|
292
|
+
code: protocolError.statusCode,
|
|
293
|
+
status: protocolError.status,
|
|
294
|
+
message: protocolError.message,
|
|
295
|
+
details: protocolError.reason
|
|
296
|
+
? [
|
|
297
|
+
{
|
|
298
|
+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
|
299
|
+
reason: protocolError.reason,
|
|
300
|
+
domain: "a2a-protocol.org",
|
|
301
|
+
...(protocolError.metadata ? { metadata: protocolError.metadata } : {}),
|
|
302
|
+
},
|
|
303
|
+
]
|
|
304
|
+
: [],
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Serialize a failure with the response headers required by the HTTP+JSON binding. */
|
|
311
|
+
export function createA2AErrorResponse(error: unknown): Response {
|
|
312
|
+
const failure = toA2AErrorResponse(error)
|
|
313
|
+
return new Response(JSON.stringify(failure.body), {
|
|
314
|
+
status: failure.statusCode,
|
|
315
|
+
headers: {
|
|
316
|
+
"Content-Type": A2A_MEDIA_TYPE,
|
|
317
|
+
"Cache-Control": "no-store",
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Read and parse a JSON request without buffering more than the A2A body limit. */
|
|
323
|
+
export async function readA2AJsonBody(request: Request): Promise<unknown> {
|
|
324
|
+
const contentLength = request.headers.get("Content-Length")
|
|
325
|
+
if (contentLength !== null) {
|
|
326
|
+
const declaredBytes = Number(contentLength)
|
|
327
|
+
if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0) {
|
|
328
|
+
throw malformed("Content-Length must be a non-negative integer.")
|
|
329
|
+
}
|
|
330
|
+
if (declaredBytes > A2A_MAX_REQUEST_BYTES) {
|
|
331
|
+
throw requestTooLarge()
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!request.body) {
|
|
336
|
+
throw malformed("A JSON payload is required.")
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const bytes = await readLimitedBody(request.body, A2A_MAX_REQUEST_BYTES, requestTooLarge, () =>
|
|
340
|
+
malformed("The JSON payload could not be read."),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))
|
|
345
|
+
} catch {
|
|
346
|
+
throw malformed("Invalid JSON payload.")
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Read a capability response without buffering an unbounded document. */
|
|
351
|
+
export async function readA2ACapabilityText(response: Response): Promise<string> {
|
|
352
|
+
const contentLength = response.headers.get("Content-Length")
|
|
353
|
+
if (contentLength !== null) {
|
|
354
|
+
const declaredBytes = Number(contentLength)
|
|
355
|
+
if (Number.isSafeInteger(declaredBytes) && declaredBytes > A2A_MAX_RESPONSE_BYTES) {
|
|
356
|
+
throw capabilityResponseTooLarge()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!response.body) {
|
|
361
|
+
return ""
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const bytes = await readLimitedBody(
|
|
365
|
+
response.body,
|
|
366
|
+
A2A_MAX_RESPONSE_BYTES,
|
|
367
|
+
capabilityResponseTooLarge,
|
|
368
|
+
() => new A2AError(503, "UNAVAILABLE", null, "The capability response could not be read."),
|
|
369
|
+
)
|
|
370
|
+
try {
|
|
371
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
|
372
|
+
} catch {
|
|
373
|
+
throw new A2AError(503, "UNAVAILABLE", null, "The capability returned invalid UTF-8.")
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Return the canonical error for an operation against unknown stateless task data. */
|
|
378
|
+
export function taskNotFound(taskId: string): A2AError {
|
|
379
|
+
return new A2AError(
|
|
380
|
+
404,
|
|
381
|
+
"NOT_FOUND",
|
|
382
|
+
"TASK_NOT_FOUND",
|
|
383
|
+
`Task "${taskId}" was not created by this stateless agent.`,
|
|
384
|
+
{ taskId },
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Return the canonical error for an operation disabled by the Agent Card. */
|
|
389
|
+
export function unsupportedOperation(operation: string): A2AError {
|
|
390
|
+
return new A2AError(
|
|
391
|
+
400,
|
|
392
|
+
"FAILED_PRECONDITION",
|
|
393
|
+
"UNSUPPORTED_OPERATION",
|
|
394
|
+
`${operation} is not supported by this stateless agent.`,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Return the canonical error for push operations disabled by the Agent Card. */
|
|
399
|
+
export function pushNotificationsNotSupported(): A2AError {
|
|
400
|
+
return new A2AError(
|
|
401
|
+
400,
|
|
402
|
+
"FAILED_PRECONDITION",
|
|
403
|
+
"PUSH_NOTIFICATION_NOT_SUPPORTED",
|
|
404
|
+
"Sosumi does not support A2A push notifications.",
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Validate a ListTasks query and return the page size used by the empty stateless result. */
|
|
409
|
+
export function parseA2AListTasksQuery(query: URLSearchParams): number {
|
|
410
|
+
for (const key of query.keys()) {
|
|
411
|
+
if (!LIST_TASK_QUERY_FIELDS.has(key)) {
|
|
412
|
+
throw malformed(`Unsupported ListTasks query field "${key}".`)
|
|
413
|
+
}
|
|
414
|
+
if (query.getAll(key).length > 1) {
|
|
415
|
+
throw malformed(`ListTasks query field "${key}" must not be repeated.`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (query.get("tenant")) {
|
|
420
|
+
throw malformed("tenant is not supported by this interface.")
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const status = query.get("status")
|
|
424
|
+
if (status !== null && !TASK_STATES.has(status)) {
|
|
425
|
+
throw malformed(`Invalid task status "${status}".`)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
parseIntegerQuery(query.get("historyLength"), "historyLength", 0, INT32_MAX)
|
|
429
|
+
|
|
430
|
+
const timestamp = query.get("statusTimestampAfter")
|
|
431
|
+
if (timestamp !== null && !isValidProtobufTimestamp(timestamp)) {
|
|
432
|
+
throw malformed("statusTimestampAfter must be a valid RFC 3339 protobuf Timestamp.")
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const includeArtifacts = query.get("includeArtifacts")
|
|
436
|
+
if (includeArtifacts !== null && includeArtifacts !== "true" && includeArtifacts !== "false") {
|
|
437
|
+
throw malformed('includeArtifacts must be "true" or "false".')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return parseIntegerQuery(query.get("pageSize"), "pageSize", 1, 100) ?? 50
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Execute a synchronous A2A message using one of Sosumi's existing HTTP
|
|
445
|
+
* capabilities and return a direct Message response (no persistent Task).
|
|
446
|
+
*/
|
|
447
|
+
export async function handleA2AMessage(
|
|
448
|
+
input: unknown,
|
|
449
|
+
invoke: (endpoint: string, outputMode: typeof A2A_OUTPUT_MEDIA_TYPE) => Promise<string>,
|
|
450
|
+
): Promise<A2AMessageResponse> {
|
|
451
|
+
const request = parseA2AMessage(input)
|
|
452
|
+
const endpoint = resolveA2AEndpoint(request.prompt)
|
|
453
|
+
const text = await invoke(endpoint, request.outputMode)
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
message: {
|
|
457
|
+
messageId: crypto.randomUUID(),
|
|
458
|
+
contextId: request.contextId ?? crypto.randomUUID(),
|
|
459
|
+
role: "ROLE_AGENT",
|
|
460
|
+
parts: [{ text, mediaType: request.outputMode }],
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Infer the existing Sosumi capability endpoint described by a text prompt. */
|
|
466
|
+
export function resolveA2AEndpoint(prompt: string): string {
|
|
467
|
+
const fetchTarget = extractFetchTarget(prompt)
|
|
468
|
+
if (fetchTarget) {
|
|
469
|
+
try {
|
|
470
|
+
const endpoint = resolveFetchEndpoint(fetchTarget)
|
|
471
|
+
if (endpoint.startsWith("/external/")) {
|
|
472
|
+
const target = new URL(endpoint.slice("/external/".length))
|
|
473
|
+
target.hash = ""
|
|
474
|
+
return `/external/${encodeURIComponent(target.toString())}`
|
|
475
|
+
}
|
|
476
|
+
return endpoint
|
|
477
|
+
} catch (error) {
|
|
478
|
+
throw malformed(error instanceof Error ? error.message : "Invalid fetch target.")
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const query = prompt
|
|
483
|
+
.replace(
|
|
484
|
+
/^(?:please\s+)?(?:search|find|look up)(?:\s+(?:the\s+)?apple(?: developer)?\s+documentation)?(?:\s+(?:for|about))?\s+/i,
|
|
485
|
+
"",
|
|
486
|
+
)
|
|
487
|
+
.trim()
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
return resolveSearchEndpoint(query || prompt)
|
|
491
|
+
} catch (error) {
|
|
492
|
+
throw malformed(error instanceof Error ? error.message : "Invalid search query.")
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Map an existing capability's HTTP failure into the A2A error envelope. */
|
|
497
|
+
export function capabilityError(statusCode: number, message: string): A2AError {
|
|
498
|
+
if (statusCode === 400) {
|
|
499
|
+
return new A2AError(400, "INVALID_ARGUMENT", null, message)
|
|
500
|
+
}
|
|
501
|
+
if (statusCode === 403) {
|
|
502
|
+
return new A2AError(403, "PERMISSION_DENIED", null, message)
|
|
503
|
+
}
|
|
504
|
+
if (statusCode === 404) {
|
|
505
|
+
return new A2AError(404, "NOT_FOUND", null, message)
|
|
506
|
+
}
|
|
507
|
+
return new A2AError(503, "UNAVAILABLE", null, message)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function parseA2AMessage(input: unknown): ParsedA2AMessage {
|
|
511
|
+
const parsed = sendMessageRequestSchema.safeParse(input)
|
|
512
|
+
if (!parsed.success) {
|
|
513
|
+
const issue = parsed.error.issues[0]
|
|
514
|
+
const field = issue?.path.length ? issue.path.join(".") : "request"
|
|
515
|
+
throw malformed(`${field}: ${issue?.message ?? "Invalid A2A request."}`)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const request = parsed.data
|
|
519
|
+
if (request.tenant) {
|
|
520
|
+
throw malformed("tenant is not supported by this interface.")
|
|
521
|
+
}
|
|
522
|
+
const message = request.message
|
|
523
|
+
if (message.role !== "ROLE_USER") {
|
|
524
|
+
throw malformed('message.role must be "ROLE_USER".')
|
|
525
|
+
}
|
|
526
|
+
if (message.taskId !== undefined) {
|
|
527
|
+
throw taskNotFound(message.taskId)
|
|
528
|
+
}
|
|
529
|
+
const referencedTaskId = message.referenceTaskIds?.[0]
|
|
530
|
+
if (referencedTaskId) {
|
|
531
|
+
throw taskNotFound(referencedTaskId)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const textParts = message.parts.map(parseTextPart)
|
|
535
|
+
const prompt = textParts.join("\n").trim()
|
|
536
|
+
if (!prompt) {
|
|
537
|
+
throw malformed("message.parts must contain non-empty text.")
|
|
538
|
+
}
|
|
539
|
+
if (prompt.length > 10_000) {
|
|
540
|
+
throw malformed("Combined message text must not exceed 10,000 characters.")
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
contextId: message.contextId,
|
|
545
|
+
prompt,
|
|
546
|
+
outputMode: selectOutputMode(request.configuration),
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function parseTextPart(part: z.infer<typeof partSchema>): string {
|
|
551
|
+
const contentFields = ["text", "raw", "url", "data"].filter((field) => field in part)
|
|
552
|
+
if (contentFields.length !== 1 || contentFields[0] !== "text" || typeof part.text !== "string") {
|
|
553
|
+
throw new A2AError(
|
|
554
|
+
400,
|
|
555
|
+
"INVALID_ARGUMENT",
|
|
556
|
+
"CONTENT_TYPE_NOT_SUPPORTED",
|
|
557
|
+
"Sosumi accepts text message parts only.",
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
if (part.mediaType !== undefined && part.mediaType !== "text/plain") {
|
|
561
|
+
throw new A2AError(
|
|
562
|
+
400,
|
|
563
|
+
"INVALID_ARGUMENT",
|
|
564
|
+
"CONTENT_TYPE_NOT_SUPPORTED",
|
|
565
|
+
`Unsupported input media type "${String(part.mediaType)}"; expected text/plain.`,
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return part.text
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function selectOutputMode(
|
|
573
|
+
configuration: z.infer<typeof sendMessageConfigurationSchema> | undefined,
|
|
574
|
+
): typeof A2A_OUTPUT_MEDIA_TYPE {
|
|
575
|
+
if (configuration === undefined) {
|
|
576
|
+
return A2A_OUTPUT_MEDIA_TYPE
|
|
577
|
+
}
|
|
578
|
+
if (configuration.taskPushNotificationConfig !== undefined) {
|
|
579
|
+
throw pushNotificationsNotSupported()
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const modes = configuration.acceptedOutputModes
|
|
583
|
+
if (modes === undefined || (Array.isArray(modes) && modes.length === 0)) {
|
|
584
|
+
return A2A_OUTPUT_MEDIA_TYPE
|
|
585
|
+
}
|
|
586
|
+
if (!Array.isArray(modes) || !modes.every((mode) => typeof mode === "string")) {
|
|
587
|
+
throw malformed("configuration.acceptedOutputModes must be an array of media types.")
|
|
588
|
+
}
|
|
589
|
+
if (modes.includes(A2A_OUTPUT_MEDIA_TYPE)) {
|
|
590
|
+
return A2A_OUTPUT_MEDIA_TYPE
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
throw new A2AError(
|
|
594
|
+
400,
|
|
595
|
+
"INVALID_ARGUMENT",
|
|
596
|
+
"CONTENT_TYPE_NOT_SUPPORTED",
|
|
597
|
+
`Sosumi returns ${A2A_OUTPUT_MEDIA_TYPE} only.`,
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function extractFetchTarget(prompt: string): string | null {
|
|
602
|
+
const url = prompt.match(/https:\/\/[^\s<>"'`]+/i)?.[0]
|
|
603
|
+
if (url) {
|
|
604
|
+
return trimTargetPunctuation(url)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const pathPrefixes = ["/documentation/", "/design/human-interface-guidelines", "/videos/play/"]
|
|
608
|
+
const lowerPrompt = prompt.toLowerCase()
|
|
609
|
+
for (const prefix of pathPrefixes) {
|
|
610
|
+
const index = lowerPrompt.indexOf(prefix)
|
|
611
|
+
if (index >= 0) {
|
|
612
|
+
return trimTargetPunctuation(prompt.slice(index).split(/\s/, 1)[0] ?? "")
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return null
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function trimTargetPunctuation(target: string): string {
|
|
620
|
+
return target.replace(/[`'",.;:!?\])}]+$/g, "")
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function malformed(message: string): A2AError {
|
|
624
|
+
return new A2AError(400, "INVALID_ARGUMENT", null, message)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function requestTooLarge(): A2AError {
|
|
628
|
+
return new A2AError(
|
|
629
|
+
413,
|
|
630
|
+
"RESOURCE_EXHAUSTED",
|
|
631
|
+
null,
|
|
632
|
+
`Request body exceeds the ${A2A_MAX_REQUEST_BYTES}-byte limit.`,
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function parseIntegerQuery(
|
|
637
|
+
value: string | null,
|
|
638
|
+
name: string,
|
|
639
|
+
minimum: number,
|
|
640
|
+
maximum = Number.MAX_SAFE_INTEGER,
|
|
641
|
+
): number | undefined {
|
|
642
|
+
if (value === null) {
|
|
643
|
+
return undefined
|
|
644
|
+
}
|
|
645
|
+
if (!/^\d+$/.test(value)) {
|
|
646
|
+
throw malformed(`${name} must be an integer.`)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const parsed = Number(value)
|
|
650
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
651
|
+
throw malformed(`${name} must be between ${minimum} and ${maximum}.`)
|
|
652
|
+
}
|
|
653
|
+
return parsed
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function isValidProtobufTimestamp(value: string): boolean {
|
|
657
|
+
const match =
|
|
658
|
+
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/.exec(
|
|
659
|
+
value,
|
|
660
|
+
)
|
|
661
|
+
if (!match) {
|
|
662
|
+
return false
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const year = Number(match[1])
|
|
666
|
+
const month = Number(match[2])
|
|
667
|
+
const day = Number(match[3])
|
|
668
|
+
const hour = Number(match[4])
|
|
669
|
+
const minute = Number(match[5])
|
|
670
|
+
const second = Number(match[6])
|
|
671
|
+
const fraction = match[7] ?? ""
|
|
672
|
+
const offsetHour = Number(match[10] ?? 0)
|
|
673
|
+
const offsetMinute = Number(match[11] ?? 0)
|
|
674
|
+
|
|
675
|
+
if (
|
|
676
|
+
year < 1 ||
|
|
677
|
+
month < 1 ||
|
|
678
|
+
month > 12 ||
|
|
679
|
+
day < 1 ||
|
|
680
|
+
day > daysInMonth(year, month) ||
|
|
681
|
+
hour > 23 ||
|
|
682
|
+
minute > 59 ||
|
|
683
|
+
second > 59 ||
|
|
684
|
+
offsetHour > 23 ||
|
|
685
|
+
offsetMinute > 59
|
|
686
|
+
) {
|
|
687
|
+
return false
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const localTime = new Date(0)
|
|
691
|
+
localTime.setUTCFullYear(year, month - 1, day)
|
|
692
|
+
localTime.setUTCHours(hour, minute, second, Number(fraction.padEnd(3, "0").slice(0, 3)))
|
|
693
|
+
|
|
694
|
+
const offsetSign = match[9] === "-" ? -1 : 1
|
|
695
|
+
const offsetMilliseconds = offsetSign * (offsetHour * 60 + offsetMinute) * 60_000
|
|
696
|
+
const instant = localTime.getTime() - offsetMilliseconds
|
|
697
|
+
return instant >= -62_135_596_800_000 && instant <= 253_402_300_799_999
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function daysInMonth(year: number, month: number): number {
|
|
701
|
+
if (month === 2) {
|
|
702
|
+
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
|
|
703
|
+
return leapYear ? 29 : 28
|
|
704
|
+
}
|
|
705
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function capabilityResponseTooLarge(): A2AError {
|
|
709
|
+
return new A2AError(
|
|
710
|
+
503,
|
|
711
|
+
"UNAVAILABLE",
|
|
712
|
+
null,
|
|
713
|
+
`Capability response exceeds the ${A2A_MAX_RESPONSE_BYTES}-byte A2A limit.`,
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function readLimitedBody(
|
|
718
|
+
body: ReadableStream<Uint8Array>,
|
|
719
|
+
maximumBytes: number,
|
|
720
|
+
tooLarge: () => A2AError,
|
|
721
|
+
readFailure: () => A2AError,
|
|
722
|
+
): Promise<Uint8Array> {
|
|
723
|
+
const reader = body.getReader()
|
|
724
|
+
const chunks: Uint8Array[] = []
|
|
725
|
+
let byteLength = 0
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
while (true) {
|
|
729
|
+
const { done, value } = await reader.read()
|
|
730
|
+
if (done) {
|
|
731
|
+
break
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
byteLength += value.byteLength
|
|
735
|
+
if (byteLength > maximumBytes) {
|
|
736
|
+
try {
|
|
737
|
+
await reader.cancel()
|
|
738
|
+
} catch {
|
|
739
|
+
// The bounded error below is more useful than a cancellation failure.
|
|
740
|
+
}
|
|
741
|
+
throw tooLarge()
|
|
742
|
+
}
|
|
743
|
+
chunks.push(value)
|
|
744
|
+
}
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (error instanceof A2AError) {
|
|
747
|
+
throw error
|
|
748
|
+
}
|
|
749
|
+
throw readFailure()
|
|
750
|
+
} finally {
|
|
751
|
+
reader.releaseLock()
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const bytes = new Uint8Array(byteLength)
|
|
755
|
+
let offset = 0
|
|
756
|
+
for (const chunk of chunks) {
|
|
757
|
+
bytes.set(chunk, offset)
|
|
758
|
+
offset += chunk.byteLength
|
|
759
|
+
}
|
|
760
|
+
return bytes
|
|
761
|
+
}
|