@nylorun/runtime 0.36.1-beta → 0.38.0-beta
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/CHANGELOG.md +70 -0
- package/README.md +10 -0
- package/dist/api/a2a/card.d.ts +10 -4
- package/dist/api/a2a/card.js +18 -6
- package/dist/api/a2a/endpoint.d.ts +5 -2
- package/dist/api/a2a/endpoint.js +31 -9
- package/dist/api/a2a/extensions.d.ts +41 -0
- package/dist/api/a2a/extensions.js +92 -0
- package/dist/api/a2a/list.d.ts +60 -0
- package/dist/api/a2a/list.js +169 -0
- package/dist/api/a2a/protocol.d.ts +105 -9
- package/dist/api/a2a/protocol.js +169 -18
- package/dist/api/a2a/push.d.ts +76 -0
- package/dist/api/a2a/push.js +305 -0
- package/dist/api/a2a/routes.d.ts +12 -6
- package/dist/api/a2a/routes.js +409 -112
- package/dist/api/a2a/tasks.d.ts +72 -4
- package/dist/api/a2a/tasks.js +305 -27
- package/dist/api/components.d.ts +538 -0
- package/dist/api/components.js +3 -1
- package/dist/api/http/app.js +2 -0
- package/dist/api/http/routes/artifacts.js +19 -0
- package/dist/api/http/routes/turns.d.ts +18 -0
- package/dist/api/http/routes/turns.js +67 -0
- package/dist/api/inbound-files.d.ts +12 -1
- package/dist/api/inbound-files.js +44 -0
- package/dist/api/openapi.js +7 -2
- package/dist/harness/image-engine.d.ts +14 -0
- package/dist/harness/image-engine.js +16 -0
- package/dist/harness/pod.d.ts +2 -0
- package/dist/harness/pod.js +5 -0
- package/dist/harness-api/ws-server.js +7 -1
- package/dist/host/config-for.d.ts +2 -0
- package/dist/host/config-for.js +1 -0
- package/dist/host/main.js +1 -0
- package/dist/host/stack-config.d.ts +16 -0
- package/dist/host/stack-config.js +30 -0
- package/dist/management-openapi.json +37 -2
- package/dist/openapi.json +1485 -738
- package/dist/reads/types.d.ts +19 -0
- package/dist/sandbox/join.d.ts +7 -1
- package/dist/sandbox/join.js +5 -1
- package/dist/sandbox/pods/builtin.d.ts +18 -0
- package/dist/sandbox/pods/builtin.js +33 -0
- package/dist/sandbox/pods/client.d.ts +5 -4
- package/dist/sandbox/pods/spec.js +14 -1
- package/dist/store/postgres/drizzle/0039_a2a_push.sql +32 -0
- package/dist/store/postgres/drizzle/0040_turn_index.sql +23 -0
- package/dist/store/postgres/drizzle/0041_turn_index_backfill.sql +88 -0
- package/dist/store/postgres/drizzle/0042_worker_engine_image.sql +1 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
- package/dist/store/postgres/reads.js +41 -1
- package/dist/store/postgres/schema.d.ts +1314 -533
- package/dist/store/postgres/schema.js +94 -0
- package/dist/store/postgres/store.js +135 -3
- package/dist/store/postgres/turns.d.ts +62 -0
- package/dist/store/postgres/turns.js +153 -0
- package/dist/store/types.d.ts +82 -3
- package/dist/tenant/runtime.js +7 -5
- package/dist/tenant/session-model.d.ts +11 -1
- package/dist/tenant/session-model.js +34 -5
- package/dist/tenant/sessions.d.ts +2 -1
- package/dist/tenant/sessions.js +8 -2
- package/dist/tenant/sweep.d.ts +3 -0
- package/dist/tenant/sweep.js +39 -11
- package/dist/tenant/types.d.ts +23 -0
- package/dist/tuning.d.ts +9 -0
- package/dist/tuning.js +11 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/dist/api/a2a/routes.js
CHANGED
|
@@ -1,28 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The A2A endpoint (Host
|
|
3
|
-
* JSON-RPC for one subject, and `GET
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The A2A endpoint (Host features `a2a-endpoint` and `a2a-profile-v2`): `POST
|
|
3
|
+
* /v1/a2a/agents/:agent` answers A2A 1.0 JSON-RPC for one subject, and `GET
|
|
4
|
+
* /v1/a2a/agents/:agent/card` returns the agent's card. It serves `SendMessage` (blocking up to
|
|
5
|
+
* `a2aWaitMs`, or `returnImmediately`), `GetTask`, `ListTasks`, `CancelTask` and the four push
|
|
6
|
+
* notification config methods; every other operation answers with the error the specification
|
|
7
|
+
* defines for it (docs: `implementation/a2a-implementation.md`, profile v2:
|
|
8
|
+
* `design/runtime/a2a-profile.md`). Tasks are found through the turn index (Host feature
|
|
9
|
+
* `turns`, `list.ts`): a context's session carries `info.a2a`, and a task's events are read from
|
|
10
|
+
* its turn's first event, never the whole history.
|
|
7
11
|
*
|
|
8
12
|
* Every task operation goes through the session contract as the caller: `command` with the
|
|
9
13
|
* caller's scope (limits and owner checks apply), sessions derived from the caller's own
|
|
10
14
|
* subject, history from Durable Streams. A task id from the client only selects a context and
|
|
11
15
|
* a turn within the caller's own sessions, so another subject's task is `TaskNotFoundError`.
|
|
12
16
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
17
|
+
* Profile v2:
|
|
18
|
+
* - A message to a busy context is **queued** (`delivery: "queue"`, protocol 17) and answered
|
|
19
|
+
* with the task that will take it: `SUBMITTED`, its id the queued message's own until a turn
|
|
20
|
+
* takes it, then that turn's state. `metadata.delivery: "reject"` keeps v1's
|
|
21
|
+
* `UnsupportedOperationError` (reason `CONTEXT_BUSY`).
|
|
22
|
+
* - An open interaction shows on the status message under `interactions/v1`; the caller answers
|
|
23
|
+
* on the same task with `{ interactionId, approved }` (the Runtime's `approve`, recorded with
|
|
24
|
+
* the caller as `approvedBy`) or `{ interactionId, value }` (`respond`). Text never approves.
|
|
25
|
+
* - A push config (in `SendMessage`, or the config methods) sends the task's updates to the
|
|
26
|
+
* caller's URL (`push.ts`).
|
|
27
|
+
* - A message's `raw` and `url` file parts are stored as artifacts of the context's session once
|
|
28
|
+
* it is open (`../inbound-files.ts`; a `url` is fetched under the push address policy), and the
|
|
29
|
+
* message goes in as `parts`. The files the turn writes come back as artifacts whose `url`
|
|
30
|
+
* parts point at `/v1/a2a/files/…`, served to the same subject, with `files/v1`.
|
|
16
31
|
*/
|
|
17
|
-
import { randomUUID } from "node:crypto";
|
|
32
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
18
33
|
import { SessionCommandSchema, } from "@nylorun/core/contracts";
|
|
19
34
|
import { commandKey } from "../../core/flow-host.js";
|
|
20
35
|
import { contentTypeFor } from "@nylorun/core/contracts";
|
|
21
|
-
import { discardFiles, inlineFileName, messageParts, } from "../inbound-files.js";
|
|
36
|
+
import { discardFiles, fetchUrlFile, inlineFileName, messageParts, } from "../inbound-files.js";
|
|
22
37
|
import { agentCard, inputModesFor } from "./card.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
38
|
+
import { keepExtensions } from "./extensions.js";
|
|
39
|
+
import { indexedTurnEvents, listTasks, parseListParams } from "./list.js";
|
|
40
|
+
import { A2aError, a2aFail, checkVersion, jsonRpcError, jsonRpcResult, parseCreatePushParams, parseEnvelope, parseListPushParams, parsePushConfigRef, parseSendParams, parseTaskParams, } from "./protocol.js";
|
|
41
|
+
import { configView, kickPushes, pushEnabled, pushPolicy, savePushConfig, } from "./push.js";
|
|
42
|
+
import { ENDED, SETTLED_EVENTS, buildTask, callerTraceId, contextSessionId, newContextId, parseTaskId, pendingInteraction, pendingInteractions, submittedTask, taskIdOf, } from "./tasks.js";
|
|
25
43
|
import { currentModelDescriptor } from "../../model/descriptor.js";
|
|
44
|
+
import { decodeCursor } from "../../record/cursor.js";
|
|
26
45
|
import { accessOf } from "../../tenant/auth.js";
|
|
27
46
|
import { command } from "../../tenant/commands.js";
|
|
28
47
|
import { loadSession, reachesAgent, } from "../../tenant/context.js";
|
|
@@ -31,21 +50,32 @@ import { HttpError, fail } from "../../tenant/http.js";
|
|
|
31
50
|
import { observeSession, readHistory } from "../../tenant/session-streams.js";
|
|
32
51
|
import { putSession } from "../../tenant/sessions.js";
|
|
33
52
|
import { readRuntimeLimits } from "../../tenant/runtime-limits.js";
|
|
53
|
+
import { traceKey } from "../../traces/key.js";
|
|
54
|
+
import { parseTraceparent, traceIdOf } from "../../traces/ids.js";
|
|
55
|
+
/** The A2A endpoint's route for a published agent, from the Runtime's base URL. */
|
|
56
|
+
export function agentEndpoint(baseUrl, agentId) {
|
|
57
|
+
return `${baseUrl}/v1/a2a/agents/${encodeURIComponent(agentId)}`;
|
|
58
|
+
}
|
|
34
59
|
/**
|
|
35
60
|
* `GET /v1/a2a/agents/:agent/card`: the agent's card, for a caller who may use the agent. Its
|
|
36
61
|
* input modes name the files the Tenant's model reads natively: a hint, since any file is
|
|
37
|
-
* accepted and the agent's harness decides how its model sees it (protocol 15).
|
|
62
|
+
* accepted and the agent's harness decides how its model sees it (protocol 15). Its interface is
|
|
63
|
+
* this Runtime's JSON-RPC endpoint at `baseUrl` (a gateway lists its own instead).
|
|
38
64
|
*/
|
|
39
|
-
export async function a2aCard(ctx, scope, agentId) {
|
|
65
|
+
export async function a2aCard(ctx, scope, agentId, baseUrl) {
|
|
40
66
|
const definition = await definitionFor(ctx, agentId, accessOf(scope));
|
|
41
67
|
// The files the Tenant's model reads now.
|
|
42
|
-
return agentCard(agentId, definition, inputModesFor((await currentModelDescriptor(ctx))?.inputs)
|
|
68
|
+
return agentCard(agentId, definition, inputModesFor((await currentModelDescriptor(ctx))?.inputs), {
|
|
69
|
+
url: agentEndpoint(baseUrl, agentId),
|
|
70
|
+
pushNotifications: pushEnabled(ctx),
|
|
71
|
+
});
|
|
43
72
|
}
|
|
44
73
|
/**
|
|
45
74
|
* `POST /v1/a2a/agents/:agent`: one A2A JSON-RPC call, for a subject. JSON-RPC answers (errors
|
|
46
|
-
* included) are the body to send with 200
|
|
47
|
-
* limits) are thrown as `HttpError`
|
|
48
|
-
* known to be the caller's; closing
|
|
75
|
+
* included) are the body to send with 200, without the data of extensions the request did not
|
|
76
|
+
* activate; HTTP-level refusals (unknown agent, no subject, limits) are thrown as `HttpError`
|
|
77
|
+
* like any Tenant route. `body` is read once the agent is known to be the caller's; closing
|
|
78
|
+
* `response` stops a blocking wait, never the task.
|
|
49
79
|
*/
|
|
50
80
|
export async function a2aCall(ctx, scope, agentId, input, response) {
|
|
51
81
|
const access = accessOf(scope);
|
|
@@ -60,11 +90,20 @@ export async function a2aCall(ctx, scope, agentId, input, response) {
|
|
|
60
90
|
// A blocking call stops waiting when the caller hangs up; the task keeps running.
|
|
61
91
|
const hangup = new AbortController();
|
|
62
92
|
response.on("close", () => hangup.abort());
|
|
63
|
-
const caller = {
|
|
93
|
+
const caller = {
|
|
94
|
+
ctx,
|
|
95
|
+
scope,
|
|
96
|
+
access,
|
|
97
|
+
agentId,
|
|
98
|
+
...(input.trace ? { trace: input.trace } : {}),
|
|
99
|
+
extensions: input.extensions,
|
|
100
|
+
baseUrl: input.baseUrl,
|
|
101
|
+
};
|
|
64
102
|
const { id } = envelope.request;
|
|
65
103
|
try {
|
|
66
104
|
checkVersion(input.version);
|
|
67
|
-
|
|
105
|
+
const result = await operation(caller, envelope.request, hangup.signal);
|
|
106
|
+
return jsonRpcResult(id, keepExtensions(result, input.extensions));
|
|
68
107
|
}
|
|
69
108
|
catch (error) {
|
|
70
109
|
if (error instanceof A2aError)
|
|
@@ -91,15 +130,18 @@ async function operation(caller, request, signal) {
|
|
|
91
130
|
case "CancelTask":
|
|
92
131
|
return cancelTask(caller, parseTaskParams(request.params).id);
|
|
93
132
|
case "ListTasks":
|
|
94
|
-
return
|
|
133
|
+
return listTasks(caller, parseListParams(request.params), (row, session, historyLength) => indexedTask(caller, row, session, { historyLength }).then((built) => built?.task));
|
|
95
134
|
case "SendStreamingMessage":
|
|
96
135
|
case "SubscribeToTask":
|
|
97
|
-
return a2aFail("unsupportedOperation", "Streaming is not supported: the Agent Card declares capabilities.streaming false");
|
|
136
|
+
return a2aFail("unsupportedOperation", "Streaming is not supported: the Agent Card declares capabilities.streaming false; register a push notification config");
|
|
98
137
|
case "CreateTaskPushNotificationConfig":
|
|
138
|
+
return createPushConfig(caller, request.params);
|
|
99
139
|
case "GetTaskPushNotificationConfig":
|
|
140
|
+
return getPushConfig(caller, request.params);
|
|
100
141
|
case "ListTaskPushNotificationConfigs":
|
|
142
|
+
return listPushConfigs(caller, request.params);
|
|
101
143
|
case "DeleteTaskPushNotificationConfig":
|
|
102
|
-
return
|
|
144
|
+
return deletePushConfig(caller, request.params);
|
|
103
145
|
case "GetExtendedAgentCard":
|
|
104
146
|
return a2aFail("unsupportedOperation", "This agent has no extended Agent Card");
|
|
105
147
|
default:
|
|
@@ -135,30 +177,96 @@ async function contextSession(caller, sessionId) {
|
|
|
135
177
|
throw error;
|
|
136
178
|
}
|
|
137
179
|
}
|
|
138
|
-
/**
|
|
139
|
-
async function
|
|
140
|
-
|
|
141
|
-
|
|
180
|
+
/** Every event of a session, in stream order: only to find a queued message no turn took yet. */
|
|
181
|
+
async function sessionEvents(ctx, sessionId) {
|
|
182
|
+
return (await readHistory(ctx, sessionId, undefined, undefined)).items;
|
|
183
|
+
}
|
|
184
|
+
/** `telemetry/v1`'s trace id for a turn: the caller's `traceparent`'s, else the Runtime's own. */
|
|
185
|
+
async function turnTraceId(ctx, sessionId, turnId, events) {
|
|
186
|
+
return callerTraceId(events) ?? traceIdOf(await traceKey(ctx.store), ctx.store.tenantId, sessionId, turnId);
|
|
142
187
|
}
|
|
188
|
+
const payloadOf = (event) => (event.payload ?? {});
|
|
189
|
+
/**
|
|
190
|
+
* The task of turn index row `row` as the caller sees it (`taskId`: the id the caller knows it
|
|
191
|
+
* by), built from that turn's own events (`indexedTurnEvents`): what `GetTask` and `ListTasks`
|
|
192
|
+
* answer.
|
|
193
|
+
*/
|
|
194
|
+
async function indexedTask(caller, row, session, options = {}) {
|
|
195
|
+
const contextId = row.a2aContextId ?? parseTaskId(options.taskId ?? "")?.contextId;
|
|
196
|
+
if (contextId === undefined)
|
|
197
|
+
return undefined;
|
|
198
|
+
const events = await indexedTurnEvents(caller.ctx, row);
|
|
199
|
+
const ended = events.some((event) => ENDED.has(event.type));
|
|
200
|
+
const task = buildTask({
|
|
201
|
+
contextId,
|
|
202
|
+
turnId: row.turnId,
|
|
203
|
+
...(options.taskId !== undefined ? { taskId: options.taskId } : {}),
|
|
204
|
+
session,
|
|
205
|
+
events,
|
|
206
|
+
baseUrl: caller.baseUrl,
|
|
207
|
+
...(ended ? { traceId: await turnTraceId(caller.ctx, row.sessionId, row.turnId, events) } : {}),
|
|
208
|
+
...(options.historyLength !== undefined ? { historyLength: options.historyLength } : {}),
|
|
209
|
+
});
|
|
210
|
+
return task ? { task, events } : undefined;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The caller's task `taskId`, found through the turn index. Its last part is a turn, or a queued
|
|
214
|
+
* message's id: while the message waits the task is `SUBMITTED`, and once a turn took it (the
|
|
215
|
+
* index keeps the queued messages each turn took) the task is that turn's, under the id the
|
|
216
|
+
* caller knows. Only a message still in the queue is looked for in the session's history.
|
|
217
|
+
*/
|
|
143
218
|
async function ownedTask(caller, taskId, historyLength) {
|
|
144
219
|
const ids = parseTaskId(taskId) ?? taskNotFound(taskId);
|
|
145
220
|
const sessionId = contextSessionId(caller.access.owner, caller.agentId, ids.contextId);
|
|
146
221
|
const session = (await contextSession(caller, sessionId)) ?? taskNotFound(taskId);
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
222
|
+
const found = await caller.ctx.store.tx(async (t) => {
|
|
223
|
+
const turn = await t.turn(sessionId, ids.turnId);
|
|
224
|
+
if (turn)
|
|
225
|
+
return { turn };
|
|
226
|
+
// Still queued, or taken: the queue is read first, so a message taken meanwhile is found.
|
|
227
|
+
if ((await t.queuedMessages(sessionId)).some((row) => row.id === ids.turnId))
|
|
228
|
+
return { queued: true };
|
|
229
|
+
const taken = await t.turnOfQueuedMessage(sessionId, ids.turnId);
|
|
230
|
+
return taken ? { turn: taken } : {};
|
|
231
|
+
});
|
|
232
|
+
if (found.queued) {
|
|
233
|
+
const queued = (await sessionEvents(caller.ctx, sessionId)).find((event) => event.type === "message.queued" && payloadOf(event).queuedMessageId === ids.turnId);
|
|
234
|
+
if (queued)
|
|
235
|
+
return {
|
|
236
|
+
task: submittedTask(ids.contextId, taskId, queued),
|
|
237
|
+
session,
|
|
238
|
+
sessionId,
|
|
239
|
+
contextId: ids.contextId,
|
|
240
|
+
turnId: ids.turnId,
|
|
241
|
+
events: [],
|
|
242
|
+
queued,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const row = found.turn ?? taskNotFound(taskId);
|
|
246
|
+
const built = (await indexedTask(caller, { ...row, a2aContextId: ids.contextId }, session, {
|
|
247
|
+
taskId,
|
|
151
248
|
...(historyLength !== undefined ? { historyLength } : {}),
|
|
152
|
-
}) ?? taskNotFound(taskId);
|
|
153
|
-
return {
|
|
249
|
+
})) ?? taskNotFound(taskId);
|
|
250
|
+
return {
|
|
251
|
+
task: built.task,
|
|
252
|
+
session,
|
|
253
|
+
sessionId,
|
|
254
|
+
contextId: ids.contextId,
|
|
255
|
+
turnId: row.turnId,
|
|
256
|
+
events: built.events,
|
|
257
|
+
};
|
|
154
258
|
}
|
|
155
259
|
/** Runs a session command as the caller, mapping the Runtime's refusals to A2A errors. */
|
|
156
260
|
async function run(caller, sessionId, input,
|
|
157
261
|
/** The error for a `409` conflict: the session is busy or no longer waiting. */
|
|
158
|
-
conflict
|
|
262
|
+
conflict,
|
|
263
|
+
/** What a message to a busy session does: queued (profile v2), or `409`. */
|
|
264
|
+
delivery = "reject") {
|
|
159
265
|
try {
|
|
160
|
-
const accepted = (await command(caller.ctx, sessionId, SessionCommandSchema.parse(input), caller.scope, caller.trace ? { trace: caller.trace } : {}));
|
|
161
|
-
return
|
|
266
|
+
const accepted = (await command(caller.ctx, sessionId, SessionCommandSchema.parse(input), caller.scope, { ...(caller.trace ? { trace: caller.trace } : {}), delivery }));
|
|
267
|
+
return accepted.status === "queued"
|
|
268
|
+
? { queuedMessageId: accepted.queuedMessageId, cursor: accepted.cursor }
|
|
269
|
+
: { turnId: accepted.turnId, cursor: accepted.cursor };
|
|
162
270
|
}
|
|
163
271
|
catch (error) {
|
|
164
272
|
if (!(error instanceof HttpError))
|
|
@@ -170,59 +278,56 @@ conflict) {
|
|
|
170
278
|
throw await conflict();
|
|
171
279
|
}
|
|
172
280
|
}
|
|
173
|
-
/**
|
|
174
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Opens the caller's session for a context: created when missing, with `info.a2a` naming the
|
|
283
|
+
* context and the subject, so the turn index can list its tasks (`ListTasks`); the session id
|
|
284
|
+
* stays the one-way `contextSessionId`. A context opened before `info.a2a` gets it now, for the
|
|
285
|
+
* turns it starts from here on. Nothing else changes after.
|
|
286
|
+
*/
|
|
287
|
+
async function openContext(caller, sessionId, contextId) {
|
|
288
|
+
const a2a = { contextId, subject: caller.access.owner };
|
|
175
289
|
const existing = await contextSession(caller, sessionId);
|
|
176
|
-
if (existing)
|
|
177
|
-
|
|
290
|
+
if (existing) {
|
|
291
|
+
if (existing.info?.a2a?.contextId === contextId)
|
|
292
|
+
return existing;
|
|
293
|
+
return caller.ctx.store.tx(async (t) => {
|
|
294
|
+
const session = (await t.lockSession(sessionId));
|
|
295
|
+
if (session.info?.a2a?.contextId !== contextId) {
|
|
296
|
+
session.info = { ...(session.info ?? {}), a2a };
|
|
297
|
+
await t.put("sessions", sessionId, session);
|
|
298
|
+
}
|
|
299
|
+
return session;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
178
302
|
return putSession(caller.ctx, sessionId, {
|
|
179
303
|
requestId: randomUUID(),
|
|
180
304
|
agent: caller.agentId,
|
|
181
305
|
ownerUserId: caller.access.owner,
|
|
306
|
+
info: { a2a },
|
|
182
307
|
}, caller.access, agentGrantsOf(caller.scope) === undefined ? {} : { agentGrants: agentGrantsOf(caller.scope) });
|
|
183
308
|
}
|
|
184
309
|
async function sendMessage(caller, params, signal) {
|
|
185
310
|
const send = parseSendParams(params);
|
|
311
|
+
if (send.push && !pushEnabled(caller.ctx))
|
|
312
|
+
a2aFail("pushNotSupported", "Push notifications are not configured on this Runtime");
|
|
186
313
|
const idempotencyKey = `a2a:${send.messageId}`;
|
|
187
314
|
let contextId;
|
|
188
315
|
let sessionId;
|
|
189
316
|
let accepted;
|
|
190
317
|
if (send.taskId !== undefined) {
|
|
191
|
-
// A reply on an existing task: only a question it is waiting on can take it.
|
|
192
318
|
const ids = parseTaskId(send.taskId) ?? taskNotFound(send.taskId);
|
|
193
|
-
const value = "content" in send.input
|
|
194
|
-
? send.input.content
|
|
195
|
-
: "data" in send.input
|
|
196
|
-
? send.input.data
|
|
197
|
-
: a2aFail("contentTypeNotSupported", "A reply takes text or one data part; send files in a new message", { accepted: "text/plain,application/json" });
|
|
198
319
|
if (send.contextId !== undefined && send.contextId !== ids.contextId)
|
|
199
320
|
a2aFail("invalidParams", "message.contextId does not match the task's context", {}, "message.contextId");
|
|
200
321
|
contextId = ids.contextId;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// A retried reply replays through `command`, which compares it with the first one.
|
|
205
|
-
const interactionId = prior?.command.type === "respond"
|
|
206
|
-
? prior.command.interactionId
|
|
207
|
-
: prior
|
|
208
|
-
? a2aFail("invalidParams", "message.messageId was already used for a different message", {}, "message.messageId")
|
|
209
|
-
: await replyTarget(caller, session, sessionId, ids.turnId, send.taskId);
|
|
210
|
-
accepted = await run(caller, sessionId, {
|
|
211
|
-
type: "respond",
|
|
212
|
-
requestId: randomUUID(),
|
|
213
|
-
idempotencyKey,
|
|
214
|
-
interactionId,
|
|
215
|
-
value,
|
|
216
|
-
}, async () => new A2aError("unsupportedOperation", "The task is no longer waiting for input", {
|
|
217
|
-
taskId: send.taskId,
|
|
218
|
-
}));
|
|
219
|
-
accepted = { ...accepted, turnId: ids.turnId };
|
|
322
|
+
const owned = await ownedTask(caller, send.taskId, 0);
|
|
323
|
+
sessionId = owned.sessionId;
|
|
324
|
+
accepted = await reply(caller, send, owned, idempotencyKey);
|
|
220
325
|
}
|
|
221
326
|
else {
|
|
222
327
|
contextId =
|
|
223
328
|
send.contextId ?? newContextId(caller.access.owner, caller.agentId, send.messageId);
|
|
224
329
|
sessionId = contextSessionId(caller.access.owner, caller.agentId, contextId);
|
|
225
|
-
await openContext(caller, sessionId);
|
|
330
|
+
await openContext(caller, sessionId, contextId);
|
|
226
331
|
const context = contextId;
|
|
227
332
|
const { body, stored } = await messageBody(caller, sessionId, idempotencyKey, send.input);
|
|
228
333
|
accepted = await run(caller, sessionId, {
|
|
@@ -234,40 +339,144 @@ async function sendMessage(caller, params, signal) {
|
|
|
234
339
|
const active = (await contextSession(caller, sessionId))?.activeTurnId;
|
|
235
340
|
const taskId = active ? taskIdOf(context, active) : undefined;
|
|
236
341
|
return taskId
|
|
237
|
-
? new A2aError("unsupportedOperation", `The context has a task in progress (${taskId}); reply to it or
|
|
238
|
-
: new A2aError("unsupportedOperation", "The context has unresolved work; start a new context");
|
|
239
|
-
}).catch(async (error) => {
|
|
342
|
+
? new A2aError("unsupportedOperation", `The context has a task in progress (${taskId}); reply to it, cancel it, or send without delivery "reject" to queue the message`, { taskId }, undefined, "CONTEXT_BUSY")
|
|
343
|
+
: new A2aError("unsupportedOperation", "The context has unresolved work; start a new context", {}, undefined, "CONTEXT_BUSY");
|
|
344
|
+
}, send.delivery).catch(async (error) => {
|
|
240
345
|
await discardFiles(caller.ctx, stored, caller.access);
|
|
241
346
|
throw error;
|
|
242
347
|
});
|
|
243
348
|
}
|
|
349
|
+
const turnId = accepted.turnId ?? accepted.queuedMessageId;
|
|
350
|
+
const taskId = send.taskId ?? taskIdOf(contextId, turnId);
|
|
351
|
+
if (send.push)
|
|
352
|
+
await registerPush(caller, send.push, {
|
|
353
|
+
sessionId,
|
|
354
|
+
contextId,
|
|
355
|
+
taskId,
|
|
356
|
+
turnId: accepted.turnId ?? accepted.queuedMessageId,
|
|
357
|
+
nextSeq: decodeCursor(sessionId, accepted.cursor),
|
|
358
|
+
...(accepted.turnId !== undefined ? { traceId: await sendTraceId(caller, sessionId, accepted.turnId) } : {}),
|
|
359
|
+
});
|
|
244
360
|
if (!send.returnImmediately)
|
|
245
|
-
await settled(caller.ctx, sessionId, accepted
|
|
246
|
-
const { task } = await ownedTask(caller,
|
|
361
|
+
await settled(caller.ctx, sessionId, accepted, signal);
|
|
362
|
+
const { task } = await ownedTask(caller, taskId, send.historyLength);
|
|
247
363
|
return { task };
|
|
248
364
|
}
|
|
365
|
+
/** The trace id of a turn this request started: its `traceparent`'s, else the Runtime's own. */
|
|
366
|
+
async function sendTraceId(caller, sessionId, turnId) {
|
|
367
|
+
const parsed = caller.trace ? parseTraceparent(caller.trace.traceparent) : undefined;
|
|
368
|
+
return parsed?.traceId ?? traceIdOf(await traceKey(caller.ctx.store), caller.ctx.store.tenantId, sessionId, turnId);
|
|
369
|
+
}
|
|
249
370
|
/**
|
|
250
|
-
* A
|
|
251
|
-
*
|
|
252
|
-
*
|
|
371
|
+
* A message on an existing task: an answer to one of its open interactions (`interactions/v1`),
|
|
372
|
+
* or a text or data reply to the question it waits on. A retried answer replays through
|
|
373
|
+
* `command`, which compares it with the first one.
|
|
374
|
+
*/
|
|
375
|
+
async function reply(caller, send, owned, idempotencyKey) {
|
|
376
|
+
const { session, sessionId, turnId } = owned;
|
|
377
|
+
const taskId = owned.task.id;
|
|
378
|
+
const prior = await caller.ctx.store.tx((t) => t.get("commands", commandKey(sessionId, idempotencyKey)));
|
|
379
|
+
const reused = () => a2aFail("invalidParams", "message.messageId was already used for a different message", {}, "message.messageId");
|
|
380
|
+
let answer;
|
|
381
|
+
if (send.answer) {
|
|
382
|
+
const kind = prior?.command.type === "approve" || prior?.command.type === "respond"
|
|
383
|
+
? prior.command.type
|
|
384
|
+
: prior
|
|
385
|
+
? reused()
|
|
386
|
+
: answerTarget(owned, send.answer).engineKind === "approval"
|
|
387
|
+
? "approve"
|
|
388
|
+
: "respond";
|
|
389
|
+
answer = answerCommand(kind, send.answer, idempotencyKey);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
const value = "content" in send.input
|
|
393
|
+
? send.input.content
|
|
394
|
+
: "data" in send.input
|
|
395
|
+
? send.input.data
|
|
396
|
+
: a2aFail("contentTypeNotSupported", "A reply takes text or one data part; send files in a new message", { accepted: "text/plain,application/json" });
|
|
397
|
+
const interactionId = prior?.command.type === "respond"
|
|
398
|
+
? prior.command.interactionId
|
|
399
|
+
: prior
|
|
400
|
+
? reused()
|
|
401
|
+
: replyTarget(owned);
|
|
402
|
+
answer = { type: "respond", requestId: randomUUID(), idempotencyKey, interactionId, value };
|
|
403
|
+
}
|
|
404
|
+
const accepted = await run(caller, sessionId, answer, async () => new A2aError("unsupportedOperation", "The task is no longer waiting for input", { taskId }));
|
|
405
|
+
return { turnId, cursor: accepted.cursor };
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The command an `interactions/v1` answer is: `approve` with `approved`, or `respond` with
|
|
409
|
+
* `value` (an `approved` answer to a question or connection is its value).
|
|
410
|
+
*/
|
|
411
|
+
function answerCommand(kind, answer, idempotencyKey) {
|
|
412
|
+
const base = { requestId: randomUUID(), idempotencyKey, interactionId: answer.interactionId };
|
|
413
|
+
if (kind === "approve") {
|
|
414
|
+
const approved = "approved" in answer
|
|
415
|
+
? answer.approved
|
|
416
|
+
: typeof answer.value === "boolean"
|
|
417
|
+
? answer.value
|
|
418
|
+
: a2aFail("invalidParams", "An approval is answered with approved: true or false", {}, "message.metadata");
|
|
419
|
+
return { type: "approve", ...base, approved };
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
type: "respond",
|
|
423
|
+
...base,
|
|
424
|
+
value: "approved" in answer ? answer.approved : (answer.value ?? null),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** A reply to a task that is not active: it is queued, or in a terminal state. */
|
|
428
|
+
function notActive(owned) {
|
|
429
|
+
const taskId = owned.task.id;
|
|
430
|
+
if (owned.queued)
|
|
431
|
+
return a2aFail("unsupportedOperation", "The task is queued and waits for nothing yet", { taskId });
|
|
432
|
+
return a2aFail("unsupportedOperation", "The task is in a terminal state and accepts no more messages; send a new message without taskId", { taskId });
|
|
433
|
+
}
|
|
434
|
+
/** The task must be active and waiting on the interaction the answer names. */
|
|
435
|
+
function answerTarget(owned, answer) {
|
|
436
|
+
const { session } = owned;
|
|
437
|
+
if (owned.queued || session.activeTurnId !== owned.turnId)
|
|
438
|
+
return notActive(owned);
|
|
439
|
+
const open = session.status === "paused" ? pendingInteractions(session) : [];
|
|
440
|
+
return (open.find((interaction) => interaction.id === answer.interactionId) ??
|
|
441
|
+
a2aFail("invalidParams", open.length === 0
|
|
442
|
+
? "The task is not waiting for an answer"
|
|
443
|
+
: `The task does not wait on interaction ${answer.interactionId}`, { taskId: owned.task.id }, "message.metadata"));
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* A new message's body: its text, its JSON value, or parts with its `raw` and `url` files stored
|
|
447
|
+
* as artifacts of the session (`stored`: the ones this call created). A file the Runtime cannot
|
|
448
|
+
* fetch or store is `InvalidParamsError`; one past a limit stays an HTTP `413`.
|
|
253
449
|
*/
|
|
254
450
|
async function messageBody(caller, sessionId, idempotencyKey, input) {
|
|
255
451
|
if (!("files" in input))
|
|
256
452
|
return { body: input, stored: [] };
|
|
257
|
-
let files = 0;
|
|
258
|
-
const inbound = input.files.map((part) => {
|
|
259
|
-
if ("text" in part)
|
|
260
|
-
return { type: "text", text: part.text };
|
|
261
|
-
const index = files++;
|
|
262
|
-
const mediaType = part.mediaType ?? (part.filename !== undefined ? contentTypeFor(part.filename) : "application/octet-stream");
|
|
263
|
-
return {
|
|
264
|
-
type: "inline",
|
|
265
|
-
name: part.filename ?? inlineFileName(mediaType, index),
|
|
266
|
-
mediaType,
|
|
267
|
-
data: part.raw,
|
|
268
|
-
};
|
|
269
|
-
});
|
|
270
453
|
try {
|
|
454
|
+
let files = 0;
|
|
455
|
+
const inbound = [];
|
|
456
|
+
for (const part of input.files) {
|
|
457
|
+
if ("text" in part) {
|
|
458
|
+
inbound.push({ type: "text", text: part.text });
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const index = files++;
|
|
462
|
+
if ("url" in part) {
|
|
463
|
+
inbound.push({
|
|
464
|
+
type: "inline",
|
|
465
|
+
...(await fetchUrlFile(caller.ctx, part.url, {
|
|
466
|
+
...(part.mediaType !== undefined ? { mediaType: part.mediaType } : {}),
|
|
467
|
+
...(part.filename !== undefined ? { filename: part.filename } : {}),
|
|
468
|
+
}, pushPolicy(caller.ctx, part.url))),
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const mediaType = part.mediaType ?? (part.filename !== undefined ? contentTypeFor(part.filename) : "application/octet-stream");
|
|
473
|
+
inbound.push({
|
|
474
|
+
type: "inline",
|
|
475
|
+
name: part.filename ?? inlineFileName(mediaType, index),
|
|
476
|
+
mediaType,
|
|
477
|
+
data: part.raw,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
271
480
|
const { parts, stored } = await messageParts(caller.ctx, sessionId, idempotencyKey, inbound, caller.access);
|
|
272
481
|
return { body: { parts }, stored };
|
|
273
482
|
}
|
|
@@ -277,29 +486,29 @@ async function messageBody(caller, sessionId, idempotencyKey, input) {
|
|
|
277
486
|
throw error;
|
|
278
487
|
}
|
|
279
488
|
}
|
|
280
|
-
/**
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
});
|
|
290
|
-
if (!task)
|
|
291
|
-
return taskNotFound(taskId);
|
|
292
|
-
return a2aFail("unsupportedOperation", "The task is in a terminal state and accepts no more messages; send a new message without taskId", { taskId });
|
|
293
|
-
}
|
|
489
|
+
/**
|
|
490
|
+
* The question a text or data reply answers: the task must be active and paused on one. An
|
|
491
|
+
* approval takes only an `interactions/v1` answer: text never approves (profile v2 §1).
|
|
492
|
+
*/
|
|
493
|
+
function replyTarget(owned) {
|
|
494
|
+
const { session } = owned;
|
|
495
|
+
const taskId = owned.task.id;
|
|
496
|
+
if (owned.queued || session.activeTurnId !== owned.turnId)
|
|
497
|
+
return notActive(owned);
|
|
294
498
|
const pending = session.status === "paused" ? pendingInteraction(session) : undefined;
|
|
295
499
|
if (!pending)
|
|
296
500
|
return a2aFail("unsupportedOperation", "The task is working and not waiting for input; wait for it or cancel it", { taskId });
|
|
297
|
-
if (pending.
|
|
298
|
-
return a2aFail("unsupportedOperation", "The task waits for an approval, which
|
|
501
|
+
if (pending.engineKind === "approval")
|
|
502
|
+
return a2aFail("unsupportedOperation", "The task waits for an approval, which text never gives: answer it under the interactions/v1 extension ({ interactionId, approved }), or cancel the task", { taskId, interactionId: pending.id });
|
|
299
503
|
return pending.id;
|
|
300
504
|
}
|
|
301
505
|
async function cancelTask(caller, taskId) {
|
|
302
506
|
const owned = await ownedTask(caller, taskId);
|
|
507
|
+
if (owned.queued)
|
|
508
|
+
return a2aFail("taskNotCancelable", "The task is queued and starts when the context's running task ends; cancel it then", {
|
|
509
|
+
taskId,
|
|
510
|
+
state: owned.task.status.state,
|
|
511
|
+
});
|
|
303
512
|
if (owned.session.activeTurnId !== owned.turnId) {
|
|
304
513
|
if (owned.task.status.state === "TASK_STATE_CANCELED")
|
|
305
514
|
return owned.task;
|
|
@@ -322,14 +531,95 @@ async function cancelTask(caller, taskId) {
|
|
|
322
531
|
}
|
|
323
532
|
return (await ownedTask(caller, taskId)).task;
|
|
324
533
|
}
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Push notification configs
|
|
536
|
+
/** A config's id when the caller names none: one per task. */
|
|
537
|
+
const DEFAULT_PUSH_CONFIG = "default";
|
|
538
|
+
/** Stores a push config for a task this request started or answered, then pumps the session. */
|
|
539
|
+
async function registerPush(caller, config, start) {
|
|
540
|
+
const configId = config.id ?? DEFAULT_PUSH_CONFIG;
|
|
541
|
+
const saved = await caller.ctx.store.tx(async (t) => {
|
|
542
|
+
// A retried message keeps its config where it is, so updates are not sent again.
|
|
543
|
+
const existing = await t.pushConfig(start.sessionId, start.taskId, configId);
|
|
544
|
+
if (existing && sameConfig(existing, config))
|
|
545
|
+
return configView(existing);
|
|
546
|
+
return savePushConfig(caller.ctx, t, { ...start, baseUrl: caller.baseUrl, extensions: caller.extensions }, config, configId);
|
|
547
|
+
});
|
|
548
|
+
void kickPushes(caller.ctx, start.sessionId);
|
|
549
|
+
return saved;
|
|
550
|
+
}
|
|
551
|
+
function sameConfig(row, config) {
|
|
552
|
+
return (row.url === config.url &&
|
|
553
|
+
(row.token ?? undefined) === config.token &&
|
|
554
|
+
JSON.stringify(row.authentication ?? null) === JSON.stringify(config.authentication ?? null));
|
|
555
|
+
}
|
|
556
|
+
function requirePush(caller) {
|
|
557
|
+
if (!pushEnabled(caller.ctx))
|
|
558
|
+
a2aFail("pushNotSupported", "Push notifications are not configured on this Runtime");
|
|
559
|
+
}
|
|
325
560
|
/**
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* running, and the caller polls `GetTask`. Follows the session's shared stream from the command's
|
|
329
|
-
* cursor, so the events the task is built from are there when it returns. A feed that ends
|
|
330
|
-
* (reset, deleted, closing) ends the wait: the task says what the session says then.
|
|
561
|
+
* `CreateTaskPushNotificationConfig`: from the task's next event on. A config on a finished task
|
|
562
|
+
* is stored and sends nothing.
|
|
331
563
|
*/
|
|
332
|
-
async function
|
|
564
|
+
async function createPushConfig(caller, params) {
|
|
565
|
+
requirePush(caller);
|
|
566
|
+
const { taskId, config } = parseCreatePushParams(params);
|
|
567
|
+
const owned = await ownedTask(caller, taskId);
|
|
568
|
+
const last = owned.queued ?? owned.events.at(-1);
|
|
569
|
+
const ended = owned.events.some((event) => ENDED.has(event.type));
|
|
570
|
+
const configId = config.id ?? createHash("sha256").update(`${config.url}\u0000${config.token ?? ""}`).digest("hex").slice(0, 16);
|
|
571
|
+
return registerPush(caller, { ...config, id: configId }, {
|
|
572
|
+
sessionId: owned.sessionId,
|
|
573
|
+
contextId: owned.contextId,
|
|
574
|
+
taskId: owned.task.id,
|
|
575
|
+
turnId: owned.turnId,
|
|
576
|
+
nextSeq: last ? last.seq + 1 : 0,
|
|
577
|
+
...(owned.queued ? {} : { traceId: await turnTraceId(caller.ctx, owned.sessionId, owned.turnId, owned.events) }),
|
|
578
|
+
done: ended,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
async function getPushConfig(caller, params) {
|
|
582
|
+
requirePush(caller);
|
|
583
|
+
const ref = parsePushConfigRef(params);
|
|
584
|
+
const owned = await ownedTask(caller, ref.taskId, 0);
|
|
585
|
+
const row = await caller.ctx.store.tx((t) => t.pushConfig(owned.sessionId, owned.task.id, ref.id));
|
|
586
|
+
return row ? configView(row) : a2aFail("taskNotFound", "Push notification config not found", { taskId: ref.taskId, id: ref.id });
|
|
587
|
+
}
|
|
588
|
+
async function listPushConfigs(caller, params) {
|
|
589
|
+
requirePush(caller);
|
|
590
|
+
const query = parseListPushParams(params);
|
|
591
|
+
const owned = await ownedTask(caller, query.taskId, 0);
|
|
592
|
+
let after;
|
|
593
|
+
if (query.pageToken !== undefined) {
|
|
594
|
+
after = Buffer.from(query.pageToken, "base64url").toString("utf8");
|
|
595
|
+
if (after === "")
|
|
596
|
+
a2aFail("invalidParams", "pageToken is not one this agent issued", {}, "pageToken");
|
|
597
|
+
}
|
|
598
|
+
const rows = await caller.ctx.store.tx((t) => t.pushConfigs(owned.sessionId, owned.task.id, { ...(after !== undefined ? { after } : {}), limit: query.pageSize + 1 }));
|
|
599
|
+
const page = rows.slice(0, query.pageSize);
|
|
600
|
+
return {
|
|
601
|
+
configs: page.map(configView),
|
|
602
|
+
nextPageToken: rows.length > query.pageSize ? Buffer.from(page.at(-1).configId, "utf8").toString("base64url") : "",
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
async function deletePushConfig(caller, params) {
|
|
606
|
+
requirePush(caller);
|
|
607
|
+
const ref = parsePushConfigRef(params);
|
|
608
|
+
const owned = await ownedTask(caller, ref.taskId, 0);
|
|
609
|
+
// Idempotent: a config already gone is deleted.
|
|
610
|
+
await caller.ctx.store.tx((t) => t.deletePushConfig(owned.sessionId, owned.task.id, ref.id));
|
|
611
|
+
return {};
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Waits until the task's turn ends, pauses or has an uncertain effect, the caller hangs up, or
|
|
615
|
+
* the Tenant's Runtime limit `a2aWaitMs` passes: after it, the task is returned as it is
|
|
616
|
+
* (`WORKING`, or `SUBMITTED` while queued) and keeps running, and the caller polls `GetTask` or
|
|
617
|
+
* waits for pushes. A queued message's task is followed to the turn that takes it. Follows the
|
|
618
|
+
* session's shared stream from the command's cursor, so the events the task is built from are
|
|
619
|
+
* there when it returns. A feed that ends (reset, deleted, closing) ends the wait: the task says
|
|
620
|
+
* what the session says then.
|
|
621
|
+
*/
|
|
622
|
+
async function settled(ctx, sessionId, accepted, signal) {
|
|
333
623
|
const stop = new AbortController();
|
|
334
624
|
const { a2aWaitMs } = await ctx.store.tx((t) => readRuntimeLimits(t));
|
|
335
625
|
const timer = setTimeout(() => stop.abort(), a2aWaitMs);
|
|
@@ -338,12 +628,19 @@ async function settled(ctx, sessionId, turnId, cursor, signal) {
|
|
|
338
628
|
signal.addEventListener("abort", hangup, { once: true });
|
|
339
629
|
if (signal.aborted)
|
|
340
630
|
stop.abort();
|
|
631
|
+
let turnId = accepted.turnId;
|
|
341
632
|
try {
|
|
342
|
-
for await (const event of observeSession(ctx, sessionId, cursor, {
|
|
633
|
+
for await (const event of observeSession(ctx, sessionId, accepted.cursor, {
|
|
343
634
|
signal: stop.signal,
|
|
344
|
-
}))
|
|
345
|
-
if (
|
|
635
|
+
})) {
|
|
636
|
+
if (turnId === undefined &&
|
|
637
|
+
event.type === "command.message" &&
|
|
638
|
+
event.turnId &&
|
|
639
|
+
payloadOf(event).queuedMessageId === accepted.queuedMessageId)
|
|
640
|
+
turnId = event.turnId;
|
|
641
|
+
if (turnId !== undefined && event.turnId === turnId && SETTLED_EVENTS.has(event.type))
|
|
346
642
|
return;
|
|
643
|
+
}
|
|
347
644
|
}
|
|
348
645
|
finally {
|
|
349
646
|
clearTimeout(timer);
|