@nylorun/runtime 0.13.0-beta → 0.14.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.
Files changed (223) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/README.md +69 -15
  3. package/dist/adapters/execution/restate.d.ts +7 -2
  4. package/dist/adapters/execution/restate.js +38 -8
  5. package/dist/adapters/replication/pgoutput.d.ts +16 -0
  6. package/dist/adapters/replication/pgoutput.js +169 -0
  7. package/dist/adapters/streams/s2.js +37 -20
  8. package/dist/admin-openapi.json +1071 -0
  9. package/dist/api/a2a/card.d.ts +15 -0
  10. package/dist/api/a2a/card.js +24 -0
  11. package/dist/api/a2a/endpoint.d.ts +8 -0
  12. package/dist/api/a2a/endpoint.js +81 -0
  13. package/dist/api/a2a/protocol.d.ts +144 -0
  14. package/dist/api/a2a/protocol.js +188 -0
  15. package/dist/api/a2a/routes.d.ts +19 -0
  16. package/dist/api/a2a/routes.js +289 -0
  17. package/dist/api/a2a/tasks.d.ts +50 -0
  18. package/dist/api/a2a/tasks.js +186 -0
  19. package/dist/api/ag-ui/endpoint.d.ts +8 -0
  20. package/dist/api/ag-ui/endpoint.js +126 -0
  21. package/dist/api/ag-ui/history.d.ts +7 -0
  22. package/dist/api/ag-ui/history.js +60 -0
  23. package/dist/api/ag-ui/routes.d.ts +32 -0
  24. package/dist/api/ag-ui/routes.js +305 -0
  25. package/dist/api/ag-ui/session-id.d.ts +1 -0
  26. package/dist/api/ag-ui/session-id.js +12 -0
  27. package/dist/api/ag-ui/sse.d.ts +9 -0
  28. package/dist/api/ag-ui/sse.js +7 -0
  29. package/dist/api/ag-ui/translate.d.ts +38 -0
  30. package/dist/api/ag-ui/translate.js +222 -0
  31. package/dist/api/components.d.ts +7825 -0
  32. package/dist/api/components.js +83 -0
  33. package/dist/api/http/app.d.ts +18 -0
  34. package/dist/api/http/app.js +58 -0
  35. package/dist/api/http/body.d.ts +4 -0
  36. package/dist/api/http/body.js +37 -0
  37. package/dist/api/http/define.d.ts +39 -0
  38. package/dist/api/http/define.js +130 -0
  39. package/dist/api/http/respond.d.ts +9 -0
  40. package/dist/api/http/respond.js +44 -0
  41. package/dist/api/http/routes/access.d.ts +24 -0
  42. package/dist/api/http/routes/access.js +259 -0
  43. package/dist/api/http/routes/actions.d.ts +8 -0
  44. package/dist/api/http/routes/actions.js +76 -0
  45. package/dist/api/http/routes/endpoints.d.ts +7 -0
  46. package/dist/api/http/routes/endpoints.js +100 -0
  47. package/dist/api/http/routes/sessions.d.ts +3 -0
  48. package/dist/api/http/routes/sessions.js +205 -0
  49. package/dist/api/http/routes/tenant.d.ts +10 -0
  50. package/dist/api/http/routes/tenant.js +172 -0
  51. package/dist/api/http/routes/vaults.d.ts +8 -0
  52. package/dist/api/http/routes/vaults.js +167 -0
  53. package/dist/api/openapi.d.ts +19 -0
  54. package/dist/api/openapi.js +138 -0
  55. package/dist/api/route.d.ts +12 -0
  56. package/dist/api/route.js +4 -0
  57. package/dist/core/bearer.d.ts +8 -0
  58. package/dist/core/bearer.js +11 -0
  59. package/dist/core/flow-host.d.ts +7 -15
  60. package/dist/core/flow-host.js +16 -41
  61. package/dist/core/sandbox-routes.d.ts +12 -7
  62. package/dist/core/sandbox-routes.js +24 -25
  63. package/dist/execution/memory.d.ts +8 -5
  64. package/dist/execution/memory.js +33 -13
  65. package/dist/execution/types.d.ts +38 -6
  66. package/dist/execution/types.js +12 -3
  67. package/dist/host/admin-api.d.ts +22 -0
  68. package/dist/host/admin-api.js +178 -0
  69. package/dist/host/app.d.ts +34 -0
  70. package/dist/host/app.js +185 -0
  71. package/dist/host/config-for.d.ts +2 -0
  72. package/dist/host/config-for.js +1 -0
  73. package/dist/host/config.d.ts +11 -0
  74. package/dist/host/cors.d.ts +27 -0
  75. package/dist/host/cors.js +64 -0
  76. package/dist/host/create-host.d.ts +31 -3
  77. package/dist/host/create-host.js +168 -327
  78. package/dist/host/http.d.ts +14 -3
  79. package/dist/host/http.js +64 -23
  80. package/dist/host/main.js +55 -4
  81. package/dist/host/stack-config.d.ts +20 -0
  82. package/dist/host/stack-config.js +55 -0
  83. package/dist/mcp/pool.d.ts +19 -1
  84. package/dist/mcp/pool.js +60 -10
  85. package/dist/model/catalog.d.ts +2 -10
  86. package/dist/model/classify.d.ts +13 -0
  87. package/dist/model/classify.js +61 -0
  88. package/dist/model/models.d.ts +10 -1
  89. package/dist/model/models.js +14 -6
  90. package/dist/model/pi-model.d.ts +17 -2
  91. package/dist/model/pi-model.js +366 -222
  92. package/dist/openapi.json +25696 -0
  93. package/dist/sandbox/manager.d.ts +13 -1
  94. package/dist/sandbox/manager.js +8 -11
  95. package/dist/sandbox/resolve.d.ts +33 -0
  96. package/dist/sandbox/resolve.js +81 -0
  97. package/dist/sandbox/session-sandbox.d.ts +11 -0
  98. package/dist/sandbox/session-sandbox.js +51 -0
  99. package/dist/sandbox/share.d.ts +14 -2
  100. package/dist/sandbox/share.js +33 -3
  101. package/dist/sandbox/tenant-config.d.ts +37 -0
  102. package/dist/sandbox/tenant-config.js +51 -0
  103. package/dist/sandbox/tools.d.ts +7 -9
  104. package/dist/sandbox/tools.js +11 -3
  105. package/dist/sandbox/types.d.ts +8 -2
  106. package/dist/store/event.d.ts +19 -0
  107. package/dist/store/event.js +49 -0
  108. package/dist/store/memory.d.ts +20 -8
  109. package/dist/store/memory.js +265 -100
  110. package/dist/store/postgres/connect.d.ts +13 -0
  111. package/dist/store/postgres/connect.js +5 -0
  112. package/dist/store/postgres/migrations/003_subject_tokens.d.ts +8 -0
  113. package/dist/store/postgres/migrations/003_subject_tokens.js +42 -0
  114. package/dist/store/postgres/migrations/004_publishable_keys.d.ts +6 -0
  115. package/dist/store/postgres/migrations/004_publishable_keys.js +18 -0
  116. package/dist/store/postgres/migrations/005_action_endpoints.d.ts +6 -0
  117. package/dist/store/postgres/migrations/005_action_endpoints.js +32 -0
  118. package/dist/store/postgres/migrations/006_drop_executors.d.ts +11 -0
  119. package/dist/store/postgres/migrations/006_drop_executors.js +32 -0
  120. package/dist/store/postgres/migrations/007_event_time.d.ts +7 -0
  121. package/dist/store/postgres/migrations/007_event_time.js +14 -0
  122. package/dist/store/postgres/migrations/008_durable_streams.d.ts +13 -0
  123. package/dist/store/postgres/migrations/008_durable_streams.js +35 -0
  124. package/dist/store/postgres/migrations/index.d.ts +5 -1
  125. package/dist/store/postgres/migrations/index.js +22 -2
  126. package/dist/store/postgres/migrations/shared/index.d.ts +31 -0
  127. package/dist/store/postgres/migrations/shared/index.js +92 -0
  128. package/dist/store/postgres/record.d.ts +11 -0
  129. package/dist/store/postgres/record.js +64 -0
  130. package/dist/store/postgres/store.js +337 -95
  131. package/dist/store/postgres/tenants.js +14 -0
  132. package/dist/store/types.d.ts +176 -81
  133. package/dist/streams/basin.d.ts +18 -1
  134. package/dist/streams/basin.js +34 -2
  135. package/dist/streams/control.d.ts +18 -0
  136. package/dist/streams/control.js +29 -0
  137. package/dist/streams/memory.d.ts +3 -1
  138. package/dist/streams/memory.js +14 -8
  139. package/dist/streams/relay/core.d.ts +35 -0
  140. package/dist/streams/relay/core.js +366 -0
  141. package/dist/streams/relay/memory.d.ts +69 -0
  142. package/dist/streams/relay/memory.js +176 -0
  143. package/dist/streams/relay/types.d.ts +77 -0
  144. package/dist/streams/relay/types.js +7 -0
  145. package/dist/streams/types.d.ts +66 -36
  146. package/dist/streams/types.js +18 -53
  147. package/dist/tenant/access-policy.d.ts +30 -0
  148. package/dist/tenant/access-policy.js +52 -0
  149. package/dist/tenant/actions.d.ts +10 -149
  150. package/dist/tenant/actions.js +5 -167
  151. package/dist/tenant/advance.js +32 -0
  152. package/dist/tenant/auth.d.ts +19 -17
  153. package/dist/tenant/auth.js +90 -93
  154. package/dist/tenant/browser.d.ts +15 -0
  155. package/dist/tenant/browser.js +39 -0
  156. package/dist/tenant/commands.d.ts +18 -2
  157. package/dist/tenant/commands.js +107 -113
  158. package/dist/tenant/context.d.ts +75 -18
  159. package/dist/tenant/context.js +14 -10
  160. package/dist/tenant/delivery-token.d.ts +41 -0
  161. package/dist/tenant/delivery-token.js +105 -0
  162. package/dist/tenant/delivery.d.ts +62 -0
  163. package/dist/tenant/delivery.js +476 -0
  164. package/dist/tenant/effects.d.ts +5 -0
  165. package/dist/tenant/effects.js +78 -37
  166. package/dist/tenant/endpoints.d.ts +67 -0
  167. package/dist/tenant/endpoints.js +90 -0
  168. package/dist/tenant/ephemeral.d.ts +6 -0
  169. package/dist/tenant/ephemeral.js +60 -36
  170. package/dist/tenant/http.d.ts +8 -9
  171. package/dist/tenant/http.js +13 -27
  172. package/dist/tenant/jwt.d.ts +16 -0
  173. package/dist/tenant/jwt.js +50 -0
  174. package/dist/tenant/module.d.ts +3 -0
  175. package/dist/tenant/module.js +6 -12
  176. package/dist/tenant/outbound.d.ts +44 -0
  177. package/dist/tenant/outbound.js +127 -0
  178. package/dist/tenant/pool.d.ts +0 -2
  179. package/dist/tenant/pool.js +0 -18
  180. package/dist/tenant/reset.d.ts +0 -4
  181. package/dist/tenant/reset.js +0 -5
  182. package/dist/tenant/runtime.d.ts +10 -20
  183. package/dist/tenant/runtime.js +59 -42
  184. package/dist/tenant/scheduler.d.ts +7 -2
  185. package/dist/tenant/scheduler.js +7 -3
  186. package/dist/tenant/session-streams.d.ts +125 -0
  187. package/dist/tenant/session-streams.js +399 -0
  188. package/dist/tenant/sessions.d.ts +28 -4
  189. package/dist/tenant/sessions.js +118 -24
  190. package/dist/tenant/signing-keys.d.ts +55 -0
  191. package/dist/tenant/signing-keys.js +172 -0
  192. package/dist/tenant/slim.d.ts +8 -0
  193. package/dist/tenant/slim.js +21 -0
  194. package/dist/tenant/status.d.ts +0 -3
  195. package/dist/tenant/status.js +19 -12
  196. package/dist/tenant/streams.d.ts +60 -67
  197. package/dist/tenant/streams.js +140 -208
  198. package/dist/tenant/subject-limits.d.ts +16 -0
  199. package/dist/tenant/subject-limits.js +45 -0
  200. package/dist/tenant/sweep.d.ts +1 -10
  201. package/dist/tenant/sweep.js +29 -69
  202. package/dist/tenant/tokens.d.ts +14 -0
  203. package/dist/tenant/tokens.js +178 -0
  204. package/dist/tenant/transcript.d.ts +48 -5
  205. package/dist/tenant/transcript.js +58 -1
  206. package/dist/tenant/types.d.ts +29 -7
  207. package/dist/tenant/worker.d.ts +3 -1
  208. package/dist/tenant/worker.js +9 -3
  209. package/dist/vault/service.d.ts +18 -2
  210. package/dist/vault/service.js +57 -13
  211. package/dist/version.d.ts +1 -1
  212. package/dist/version.js +1 -1
  213. package/package.json +20 -8
  214. package/dist/core/executors.d.ts +0 -44
  215. package/dist/core/executors.js +0 -95
  216. package/dist/streams/relay.d.ts +0 -37
  217. package/dist/streams/relay.js +0 -254
  218. package/dist/tenant/live.d.ts +0 -92
  219. package/dist/tenant/live.js +0 -290
  220. package/dist/tenant/routes-tenant.d.ts +0 -12
  221. package/dist/tenant/routes-tenant.js +0 -137
  222. package/dist/tenant/routes.d.ts +0 -9
  223. package/dist/tenant/routes.js +0 -151
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The A2A endpoint (Host feature `a2a-endpoint`): `POST /v1/a2a/agents/:agent` answers A2A 1.0
3
+ * JSON-RPC for one subject, and `GET /v1/a2a/agents/:agent/card` returns the agent's card
4
+ * without interfaces. v1 serves `SendMessage` (blocking, or `returnImmediately`), `GetTask`
5
+ * and `CancelTask`; every other operation answers with the error the specification defines
6
+ * for it (docs: `implementation/a2a-implementation.md`).
7
+ *
8
+ * Every task operation goes through the session contract as the caller: `command` with the
9
+ * caller's scope (limits and owner checks apply), sessions derived from the caller's own
10
+ * subject, history from Durable Streams. A task id from the client only selects a context and
11
+ * a turn within the caller's own sessions, so another subject's task is `TaskNotFoundError`.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { SessionCommandSchema, } from "@nylorun/core/contracts";
15
+ import { commandKey } from "../../core/flow-host.js";
16
+ import { agentCard } from "./card.js";
17
+ import { A2aError, a2aFail, checkVersion, jsonRpcError, jsonRpcResult, parseEnvelope, parseSendParams, parseTaskParams, } from "./protocol.js";
18
+ import { SETTLED_EVENTS, buildTask, contextSessionId, newContextId, parseTaskId, pendingInteraction, taskIdOf, } from "./tasks.js";
19
+ import { accessOf } from "../../tenant/auth.js";
20
+ import { command } from "../../tenant/commands.js";
21
+ import { loadSession, } from "../../tenant/context.js";
22
+ import { HttpError, fail } from "../../tenant/http.js";
23
+ import { observeSession, readHistory } from "../../tenant/session-streams.js";
24
+ import { putSession } from "../../tenant/sessions.js";
25
+ /**
26
+ * How long a blocking `SendMessage` waits for the task to end or ask for input. After it, the
27
+ * task is returned `WORKING` and keeps running; the caller polls `GetTask`.
28
+ */
29
+ export const A2A_BLOCKING_WAIT_MS = 5 * 60_000;
30
+ /** `GET /v1/a2a/agents/:agent/card`: the agent's card, for a caller who may use the agent. */
31
+ export async function a2aCard(ctx, scope, agentId) {
32
+ return agentCard(agentId, await definitionFor(ctx, agentId, accessOf(scope)));
33
+ }
34
+ /**
35
+ * `POST /v1/a2a/agents/:agent`: one A2A JSON-RPC call, for a subject. JSON-RPC answers (errors
36
+ * included) are the body to send with 200; HTTP-level refusals (unknown agent, no subject,
37
+ * limits) are thrown as `HttpError` like any Tenant route. `body` is read once the agent is
38
+ * known to be the caller's; closing `response` stops a blocking wait, never the task.
39
+ */
40
+ export async function a2aCall(ctx, scope, agentId, input, response) {
41
+ const access = accessOf(scope);
42
+ if (!access)
43
+ return fail(400, "Acting for a subject is required: send Nylorun-Subject and Nylorun-Scopes", {
44
+ code: "subject_required",
45
+ });
46
+ await definitionFor(ctx, agentId, access);
47
+ const envelope = parseEnvelope(await input.body());
48
+ if (!envelope.ok)
49
+ return jsonRpcError(envelope.id, envelope.error);
50
+ // A blocking call stops waiting when the caller hangs up; the task keeps running.
51
+ const hangup = new AbortController();
52
+ response.on("close", () => hangup.abort());
53
+ const caller = { ctx, scope, access, agentId };
54
+ const { id } = envelope.request;
55
+ try {
56
+ checkVersion(input.version);
57
+ return jsonRpcResult(id, await operation(caller, envelope.request, hangup.signal));
58
+ }
59
+ catch (error) {
60
+ if (error instanceof A2aError)
61
+ return jsonRpcError(id, error);
62
+ // Limits, scopes and unavailable streams stay HTTP answers, as on every Tenant route.
63
+ if (error instanceof HttpError && [403, 429, 503].includes(error.status))
64
+ throw error;
65
+ ctx.config.logger.warn("a2a request failed", {
66
+ agentId,
67
+ method: envelope.request.method,
68
+ message: error instanceof Error ? error.message : String(error),
69
+ });
70
+ return jsonRpcError(id, new A2aError("internal", "Internal error"));
71
+ }
72
+ }
73
+ async function operation(caller, request, signal) {
74
+ switch (request.method) {
75
+ case "SendMessage":
76
+ return sendMessage(caller, request.params, signal);
77
+ case "GetTask": {
78
+ const params = parseTaskParams(request.params);
79
+ return (await ownedTask(caller, params.id, params.historyLength)).task;
80
+ }
81
+ case "CancelTask":
82
+ return cancelTask(caller, parseTaskParams(request.params).id);
83
+ case "ListTasks":
84
+ return a2aFail("unsupportedOperation", "ListTasks is not supported by this agent yet");
85
+ case "SendStreamingMessage":
86
+ case "SubscribeToTask":
87
+ return a2aFail("unsupportedOperation", "Streaming is not supported: the Agent Card declares capabilities.streaming false");
88
+ case "CreateTaskPushNotificationConfig":
89
+ case "GetTaskPushNotificationConfig":
90
+ case "ListTaskPushNotificationConfigs":
91
+ case "DeleteTaskPushNotificationConfig":
92
+ return a2aFail("pushNotSupported", "Push notifications are not supported");
93
+ case "GetExtendedAgentCard":
94
+ return a2aFail("unsupportedOperation", "This agent has no extended Agent Card");
95
+ default:
96
+ return a2aFail("methodNotFound", `Method ${request.method} not found`);
97
+ }
98
+ }
99
+ /** The agent's definition, or a `404` when it is missing or the caller may not use it. */
100
+ async function definitionFor(ctx, agentId, access) {
101
+ if (access?.agents !== undefined && !access.agents.has(agentId))
102
+ return fail(404, "Agent not found");
103
+ const definition = await ctx.store.tx((t) => t.get("definitions", agentId));
104
+ return definition ?? fail(404, "Agent not found");
105
+ }
106
+ const taskNotFound = (taskId) => a2aFail("taskNotFound", "Task not found", { taskId });
107
+ /** The caller's session for a context, or undefined when it does not exist yet. */
108
+ async function contextSession(caller, sessionId) {
109
+ try {
110
+ return await loadSession(caller.ctx, sessionId, caller.access);
111
+ }
112
+ catch (error) {
113
+ if (error instanceof HttpError && error.status === 404)
114
+ return undefined;
115
+ throw error;
116
+ }
117
+ }
118
+ /** The events of one turn, in stream order. */
119
+ async function turnEvents(ctx, sessionId, turnId) {
120
+ const { items } = await readHistory(ctx, sessionId, undefined, undefined);
121
+ return items.filter((event) => event.turnId === turnId);
122
+ }
123
+ async function ownedTask(caller, taskId, historyLength) {
124
+ const ids = parseTaskId(taskId) ?? taskNotFound(taskId);
125
+ const sessionId = contextSessionId(caller.access.owner, caller.agentId, ids.contextId);
126
+ const session = (await contextSession(caller, sessionId)) ?? taskNotFound(taskId);
127
+ const task = buildTask({
128
+ ...ids,
129
+ session,
130
+ events: await turnEvents(caller.ctx, sessionId, ids.turnId),
131
+ ...(historyLength !== undefined ? { historyLength } : {}),
132
+ }) ?? taskNotFound(taskId);
133
+ return { task, session, sessionId, ...ids };
134
+ }
135
+ /** Runs a session command as the caller, mapping the Runtime's refusals to A2A errors. */
136
+ async function run(caller, sessionId, input,
137
+ /** The error for a `409` conflict: the session is busy or no longer waiting. */
138
+ conflict) {
139
+ try {
140
+ const accepted = (await command(caller.ctx, sessionId, SessionCommandSchema.parse(input), caller.scope));
141
+ return { turnId: accepted.turnId, cursor: accepted.cursor };
142
+ }
143
+ catch (error) {
144
+ if (!(error instanceof HttpError) || error.status !== 409)
145
+ throw error;
146
+ if (error.message.startsWith("Idempotency key"))
147
+ return a2aFail("invalidParams", "message.messageId was already used for a different message", {}, "message.messageId");
148
+ throw await conflict();
149
+ }
150
+ }
151
+ /** Opens the caller's session for a context: created when missing, never changed after. */
152
+ async function openContext(caller, sessionId) {
153
+ const existing = await contextSession(caller, sessionId);
154
+ if (existing)
155
+ return existing;
156
+ return putSession(caller.ctx, sessionId, {
157
+ requestId: randomUUID(),
158
+ agentId: caller.agentId,
159
+ ownerUserId: caller.access.owner,
160
+ }, caller.access);
161
+ }
162
+ async function sendMessage(caller, params, signal) {
163
+ const send = parseSendParams(params);
164
+ const idempotencyKey = `a2a:${send.messageId}`;
165
+ const value = "content" in send.input ? send.input.content : send.input.data;
166
+ let contextId;
167
+ let sessionId;
168
+ let accepted;
169
+ if (send.taskId !== undefined) {
170
+ // A reply on an existing task: only a question it is waiting on can take it.
171
+ const ids = parseTaskId(send.taskId) ?? taskNotFound(send.taskId);
172
+ if (send.contextId !== undefined && send.contextId !== ids.contextId)
173
+ a2aFail("invalidParams", "message.contextId does not match the task's context", {}, "message.contextId");
174
+ contextId = ids.contextId;
175
+ sessionId = contextSessionId(caller.access.owner, caller.agentId, contextId);
176
+ const session = (await contextSession(caller, sessionId)) ?? taskNotFound(send.taskId);
177
+ const prior = await caller.ctx.store.tx((t) => t.get("commands", commandKey(sessionId, idempotencyKey)));
178
+ // A retried reply replays through `command`, which compares it with the first one.
179
+ const interactionId = prior?.command.type === "respond"
180
+ ? prior.command.interactionId
181
+ : prior
182
+ ? a2aFail("invalidParams", "message.messageId was already used for a different message", {}, "message.messageId")
183
+ : await replyTarget(caller, session, sessionId, ids.turnId, send.taskId);
184
+ accepted = await run(caller, sessionId, {
185
+ type: "respond",
186
+ requestId: randomUUID(),
187
+ idempotencyKey,
188
+ interactionId,
189
+ value,
190
+ }, async () => new A2aError("unsupportedOperation", "The task is no longer waiting for input", {
191
+ taskId: send.taskId,
192
+ }));
193
+ accepted = { ...accepted, turnId: ids.turnId };
194
+ }
195
+ else {
196
+ contextId =
197
+ send.contextId ?? newContextId(caller.access.owner, caller.agentId, send.messageId);
198
+ sessionId = contextSessionId(caller.access.owner, caller.agentId, contextId);
199
+ await openContext(caller, sessionId);
200
+ const context = contextId;
201
+ accepted = await run(caller, sessionId, {
202
+ type: "message",
203
+ requestId: randomUUID(),
204
+ idempotencyKey,
205
+ ...send.input,
206
+ }, async () => {
207
+ const active = (await contextSession(caller, sessionId))?.activeTurnId;
208
+ const taskId = active ? taskIdOf(context, active) : undefined;
209
+ return taskId
210
+ ? new A2aError("unsupportedOperation", `The context has a task in progress (${taskId}); reply to it or cancel it`, { taskId })
211
+ : new A2aError("unsupportedOperation", "The context has unresolved work; start a new context");
212
+ });
213
+ }
214
+ if (!send.returnImmediately)
215
+ await settled(caller.ctx, sessionId, accepted.turnId, accepted.cursor, signal);
216
+ const { task } = await ownedTask(caller, taskIdOf(contextId, accepted.turnId), send.historyLength);
217
+ return { task };
218
+ }
219
+ /** The interaction a reply answers: the task must be active and paused on a question. */
220
+ async function replyTarget(caller, session, sessionId, turnId, taskId) {
221
+ if (session.activeTurnId !== turnId) {
222
+ // Ended, or never this caller's: a terminal task takes no more messages.
223
+ const task = buildTask({
224
+ contextId: parseTaskId(taskId).contextId,
225
+ turnId,
226
+ session,
227
+ events: await turnEvents(caller.ctx, sessionId, turnId),
228
+ });
229
+ if (!task)
230
+ return taskNotFound(taskId);
231
+ return a2aFail("unsupportedOperation", "The task is in a terminal state and accepts no more messages; send a new message without taskId", { taskId });
232
+ }
233
+ const pending = session.status === "paused" ? pendingInteraction(session) : undefined;
234
+ if (!pending)
235
+ return a2aFail("unsupportedOperation", "The task is working and not waiting for input; wait for it or cancel it", { taskId });
236
+ if (pending.kind === "approval")
237
+ return a2aFail("unsupportedOperation", "The task waits for an approval, which A2A callers cannot give yet; cancel the task instead", { taskId });
238
+ return pending.id;
239
+ }
240
+ async function cancelTask(caller, taskId) {
241
+ const owned = await ownedTask(caller, taskId);
242
+ if (owned.session.activeTurnId !== owned.turnId) {
243
+ if (owned.task.status.state === "TASK_STATE_CANCELED")
244
+ return owned.task;
245
+ return a2aFail("taskNotCancelable", "The task has already finished and cannot be canceled", {
246
+ taskId,
247
+ state: owned.task.status.state,
248
+ });
249
+ }
250
+ try {
251
+ await command(caller.ctx, owned.sessionId, {
252
+ type: "cancel",
253
+ requestId: randomUUID(),
254
+ idempotencyKey: `a2a-cancel:${owned.turnId}`,
255
+ reason: "Canceled by the A2A client",
256
+ }, caller.scope);
257
+ }
258
+ catch (error) {
259
+ if (!(error instanceof HttpError) || error.status !== 409)
260
+ throw error;
261
+ }
262
+ return (await ownedTask(caller, taskId)).task;
263
+ }
264
+ /**
265
+ * Waits until the turn ends, pauses or has an uncertain effect, the caller hangs up, or
266
+ * `A2A_BLOCKING_WAIT_MS` passes. Follows the session's shared stream from the command's
267
+ * cursor, so the events the task is built from are there when it returns. A feed that ends
268
+ * (reset, deleted, closing) ends the wait: the task says what the session says then.
269
+ */
270
+ async function settled(ctx, sessionId, turnId, cursor, signal) {
271
+ const stop = new AbortController();
272
+ const timer = setTimeout(() => stop.abort(), A2A_BLOCKING_WAIT_MS);
273
+ timer.unref();
274
+ const hangup = () => stop.abort();
275
+ signal.addEventListener("abort", hangup, { once: true });
276
+ if (signal.aborted)
277
+ stop.abort();
278
+ try {
279
+ for await (const event of observeSession(ctx, sessionId, cursor, {
280
+ signal: stop.signal,
281
+ }))
282
+ if (event.turnId === turnId && SETTLED_EVENTS.has(event.type))
283
+ return;
284
+ }
285
+ finally {
286
+ clearTimeout(timer);
287
+ signal.removeEventListener("abort", hangup);
288
+ }
289
+ }
@@ -0,0 +1,50 @@
1
+ import type { LiveEvent } from "@nylorun/core/contracts";
2
+ import type { Message, Task } from "./protocol.js";
3
+ export declare function taskIdOf(contextId: string, turnId: string): string;
4
+ /** The context and turn a task id names, or undefined for anything this Runtime never issued. */
5
+ export declare function parseTaskId(id: string): {
6
+ contextId: string;
7
+ turnId: string;
8
+ } | undefined;
9
+ /** The session behind a subject's A2A context with one agent. */
10
+ export declare function contextSessionId(subject: string, agentId: string, contextId: string): string;
11
+ /**
12
+ * The context the Runtime generates for a message that names none: derived from the message
13
+ * id, so a retried message lands in the same context and replays instead of starting a task.
14
+ * UUID-shaped, and opaque to clients.
15
+ */
16
+ export declare function newContextId(subject: string, agentId: string, messageId: string): string;
17
+ /** The session fields a task's state comes from (`Session` in `tenant/context.ts`). */
18
+ export interface SessionSnapshot {
19
+ readonly status: string;
20
+ readonly activeTurnId: string | null;
21
+ readonly lastTurnId?: string;
22
+ readonly lastOutput?: unknown;
23
+ readonly waits?: unknown;
24
+ }
25
+ /** An interaction the paused turn waits for. */
26
+ export interface PendingInteraction {
27
+ readonly id: string;
28
+ readonly kind: string;
29
+ readonly prompt: string;
30
+ }
31
+ /** The first interaction a paused session waits for. */
32
+ export declare function pendingInteraction(session: SessionSnapshot): PendingInteraction | undefined;
33
+ /** Events after which a blocking `SendMessage` returns: the turn ended or is interrupted. */
34
+ export declare const SETTLED_EVENTS: ReadonlySet<string>;
35
+ /** The turn's conversation as A2A messages: what the caller sent and what the agent said. */
36
+ export declare function historyOf(events: readonly LiveEvent[], contextId: string, taskId: string): Message[];
37
+ export interface BuildTaskInput {
38
+ readonly contextId: string;
39
+ readonly turnId: string;
40
+ readonly session: SessionSnapshot;
41
+ /** Every event of the turn, in stream order. */
42
+ readonly events: readonly LiveEvent[];
43
+ readonly historyLength?: number;
44
+ }
45
+ /**
46
+ * The task for one turn, or undefined when the session never had that turn. An active turn
47
+ * takes its state from the session row; an ended one from its terminal event, or from the row
48
+ * while that event has not reached the stream yet.
49
+ */
50
+ export declare function buildTask(input: BuildTaskInput): Task | undefined;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * A2A tasks over Nylorun sessions: a context is one session per subject, agent and
3
+ * `contextId`; a task is one turn of it. The task id names both (`t1.<context>.<turn>`) until
4
+ * the turn index lets a turn id stand alone; it is parsed, never trusted, because the session
5
+ * is always derived from the caller's own subject. `buildTask` reads the state from the session
6
+ * row and the turn's events; nothing here touches the store.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ const TASK_ID_PREFIX = "t1";
10
+ export function taskIdOf(contextId, turnId) {
11
+ return `${TASK_ID_PREFIX}.${Buffer.from(contextId, "utf8").toString("base64url")}.${turnId}`;
12
+ }
13
+ /** The context and turn a task id names, or undefined for anything this Runtime never issued. */
14
+ export function parseTaskId(id) {
15
+ const [prefix, context, turnId, ...rest] = id.split(".");
16
+ if (prefix !== TASK_ID_PREFIX || !context || !turnId || rest.length > 0)
17
+ return undefined;
18
+ if (!/^[A-Za-z0-9_-]+$/.test(context) || !/^[A-Za-z0-9-]{1,64}$/.test(turnId))
19
+ return undefined;
20
+ const contextId = Buffer.from(context, "base64url").toString("utf8");
21
+ // Only the canonical encoding: two ids must never name one task.
22
+ if (taskIdOf(contextId, turnId) !== id)
23
+ return undefined;
24
+ return { contextId, turnId };
25
+ }
26
+ /** The session behind a subject's A2A context with one agent. */
27
+ export function contextSessionId(subject, agentId, contextId) {
28
+ return createHash("sha256")
29
+ .update(`a2a\u0000${subject}\u0000${agentId}\u0000${contextId}`)
30
+ .digest("hex")
31
+ .slice(0, 32);
32
+ }
33
+ /**
34
+ * The context the Runtime generates for a message that names none: derived from the message
35
+ * id, so a retried message lands in the same context and replays instead of starting a task.
36
+ * UUID-shaped, and opaque to clients.
37
+ */
38
+ export function newContextId(subject, agentId, messageId) {
39
+ const hex = createHash("sha256")
40
+ .update(`a2a-context\u0000${subject}\u0000${agentId}\u0000${messageId}`)
41
+ .digest("hex");
42
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
43
+ }
44
+ /** The first interaction a paused session waits for. */
45
+ export function pendingInteraction(session) {
46
+ if (!Array.isArray(session.waits))
47
+ return undefined;
48
+ for (const call of session.waits) {
49
+ const interaction = call?.interaction;
50
+ if (call?.status !== "interaction" || typeof interaction?.id !== "string")
51
+ continue;
52
+ return {
53
+ id: interaction.id,
54
+ kind: typeof interaction.kind === "string" ? interaction.kind : "response",
55
+ prompt: typeof interaction.prompt === "string" ? interaction.prompt : "",
56
+ };
57
+ }
58
+ return undefined;
59
+ }
60
+ const ENDED = new Set(["turn.completed", "turn.failed", "turn.cancelled"]);
61
+ /** Events after which a blocking `SendMessage` returns: the turn ended or is interrupted. */
62
+ export const SETTLED_EVENTS = new Set([
63
+ ...ENDED,
64
+ "turn.paused",
65
+ "effect.uncertain",
66
+ ]);
67
+ /** A turn output as one part: text for a string, JSON data for anything else. */
68
+ function outputPart(output) {
69
+ return typeof output === "string"
70
+ ? { text: output }
71
+ : { data: output, mediaType: "application/json" };
72
+ }
73
+ function valuePart(value) {
74
+ return typeof value === "string" ? { text: value } : { data: value ?? null };
75
+ }
76
+ /** The client's own message id when an A2A caller sent the command. */
77
+ function clientMessageId(event) {
78
+ const key = event.payload?.idempotencyKey;
79
+ return typeof key === "string" && key.startsWith("a2a:") ? key.slice(4) : event.eventId;
80
+ }
81
+ /** The turn's conversation as A2A messages: what the caller sent and what the agent said. */
82
+ export function historyOf(events, contextId, taskId) {
83
+ const messages = [];
84
+ const add = (messageId, role, parts) => messages.push({ messageId, role, parts, contextId, taskId });
85
+ for (const event of events) {
86
+ const payload = (event.payload ?? {});
87
+ switch (event.type) {
88
+ case "command.message":
89
+ add(clientMessageId(event), "ROLE_USER", ["content" in payload ? { text: String(payload.content) } : valuePart(payload.data)]);
90
+ break;
91
+ case "command.respond":
92
+ add(clientMessageId(event), "ROLE_USER", [valuePart(payload.value)]);
93
+ break;
94
+ case "message.assistant":
95
+ // Agents used as tools speak inside the turn; only the agent's own words are history.
96
+ if (!payload.agent && typeof payload.text === "string" && payload.text.trim() !== "")
97
+ add(event.eventId, "ROLE_AGENT", [{ text: payload.text }]);
98
+ break;
99
+ case "turn.paused":
100
+ for (const entry of Array.isArray(payload.interactions) ? payload.interactions : []) {
101
+ const interaction = entry?.interaction;
102
+ if (typeof interaction?.id === "string" && typeof interaction.prompt === "string")
103
+ add(interaction.id, "ROLE_AGENT", [{ text: interaction.prompt }]);
104
+ }
105
+ break;
106
+ }
107
+ }
108
+ return messages;
109
+ }
110
+ function agentMessage(messageId, text, contextId, taskId) {
111
+ return { messageId, role: "ROLE_AGENT", parts: [{ text }], contextId, taskId };
112
+ }
113
+ function failureText(code) {
114
+ return typeof code === "string" && code !== ""
115
+ ? `The agent failed (${code}).`
116
+ : "The agent failed.";
117
+ }
118
+ /**
119
+ * The task for one turn, or undefined when the session never had that turn. An active turn
120
+ * takes its state from the session row; an ended one from its terminal event, or from the row
121
+ * while that event has not reached the stream yet.
122
+ */
123
+ export function buildTask(input) {
124
+ const { contextId, turnId, session, events } = input;
125
+ const taskId = taskIdOf(contextId, turnId);
126
+ const active = session.activeTurnId === turnId;
127
+ const terminal = [...events].reverse().find((event) => ENDED.has(event.type));
128
+ const latest = session.lastTurnId === turnId && !active;
129
+ if (!active && !terminal && !latest && events.length === 0)
130
+ return undefined;
131
+ let state;
132
+ let message;
133
+ let artifacts;
134
+ const complete = (output) => {
135
+ state = "TASK_STATE_COMPLETED";
136
+ if (output !== undefined && output !== null)
137
+ artifacts = [{ artifactId: "output", name: "output", parts: [outputPart(output)] }];
138
+ };
139
+ if (active) {
140
+ const pending = session.status === "paused" ? pendingInteraction(session) : undefined;
141
+ if (pending) {
142
+ state = "TASK_STATE_INPUT_REQUIRED";
143
+ message = agentMessage(pending.id, pending.prompt, contextId, taskId);
144
+ }
145
+ else {
146
+ state = "TASK_STATE_WORKING";
147
+ if (session.status === "uncertain")
148
+ message = agentMessage(`${turnId}:uncertain`, "An action's outcome is uncertain; the agent's operator must resolve it before the task continues.", contextId, taskId);
149
+ }
150
+ }
151
+ else if (terminal) {
152
+ const payload = (terminal.payload ?? {});
153
+ if (terminal.type === "turn.completed")
154
+ complete(payload.output);
155
+ else if (terminal.type === "turn.cancelled")
156
+ state = "TASK_STATE_CANCELED";
157
+ else {
158
+ state = "TASK_STATE_FAILED";
159
+ message = agentMessage(`${turnId}:failed`, failureText(payload.error?.code), contextId, taskId);
160
+ }
161
+ }
162
+ else if (latest && session.status === "completed")
163
+ complete(session.lastOutput);
164
+ else if (latest && session.status === "cancelled")
165
+ state = "TASK_STATE_CANCELED";
166
+ else if (latest && session.status === "failed") {
167
+ state = "TASK_STATE_FAILED";
168
+ message = agentMessage(`${turnId}:failed`, failureText(undefined), contextId, taskId);
169
+ }
170
+ else
171
+ state = "TASK_STATE_WORKING";
172
+ const timestamp = events.at(-1)?.time;
173
+ const history = historyOf(events, contextId, taskId);
174
+ const limit = input.historyLength;
175
+ return {
176
+ id: taskId,
177
+ contextId,
178
+ status: {
179
+ state: state,
180
+ ...(message ? { message } : {}),
181
+ ...(timestamp ? { timestamp } : {}),
182
+ },
183
+ ...(artifacts ? { artifacts } : {}),
184
+ ...(limit === 0 ? {} : { history: limit === undefined ? history : history.slice(-limit) }),
185
+ };
186
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The AG-UI endpoint's routes (`/v1/ag-ui/agents/{agentId}/**`), declared for serving and for
3
+ * the Runtime's OpenAPI document. What each does is in `routes.ts`; a run and a reattached run
4
+ * stream AG-UI events on the Node response, as server-sent events.
5
+ */
6
+ import type { OpenAPIHono } from "@hono/zod-openapi";
7
+ import type { TenantEnv } from "../http/app.js";
8
+ export declare function agUiRoutes(api: OpenAPIHono<TenantEnv>): void;
@@ -0,0 +1,126 @@
1
+ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
2
+ import { z } from "zod";
3
+ import { EventSchemas, MessageSchema, RunAgentInputSchema } from "@ag-ui/core/schemas";
4
+ import { readJson } from "../http/body.js";
5
+ import { tenantRoute } from "../http/define.js";
6
+ import { jsonResponse } from "../http/respond.js";
7
+ import { agUiCaller, cancelRun, reattachRun, startRun, threadMessages } from "./routes.js";
8
+ import { sessionIdFor } from "./session-id.js";
9
+ /**
10
+ * AG-UI's own schemas (`@ag-ui/core`), as components of the document. They are converted by
11
+ * Zod itself: zod-to-openapi does not know some of the types they use.
12
+ */
13
+ const COMPONENTS = {
14
+ AgUiRunAgentInput: RunAgentInputSchema,
15
+ AgUiEvent: EventSchemas,
16
+ AgUiMessage: MessageSchema,
17
+ };
18
+ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
19
+ function registerComponents(api) {
20
+ for (const [name, schema] of Object.entries(COMPONENTS)) {
21
+ const { $schema: _dialect, ...component } = z.toJSONSchema(schema, {
22
+ io: "input",
23
+ reused: "inline",
24
+ unrepresentable: "any",
25
+ });
26
+ api.openAPIRegistry.registerComponent("schemas", name, component);
27
+ }
28
+ }
29
+ const FOR_A_PERSON = {
30
+ credentials: ["subject", "token"],
31
+ scopes: ["sessions:own"],
32
+ browser: true,
33
+ };
34
+ const events = (description) => ({
35
+ description,
36
+ content: { "text/event-stream": { itemSchema: ref("AgUiEvent") } },
37
+ });
38
+ const agentId = z.object({ agentId: z.string() });
39
+ const thread = agentId.extend({ threadId: z.string() });
40
+ const TAGS = ["AG-UI"];
41
+ /** The person the request acts for, who may use the agent. */
42
+ function callerOf(c) {
43
+ const scope = c.get("scope");
44
+ return { scope, ...agUiCaller(scope, c.req.param("agentId")) };
45
+ }
46
+ export function agUiRoutes(api) {
47
+ registerComponents(api);
48
+ tenantRoute(api, FOR_A_PERSON, {
49
+ method: "post",
50
+ path: "/v1/ag-ui/agents/{agentId}",
51
+ tags: TAGS,
52
+ summary: "Run an agent",
53
+ description: "For a person, named by a subject token or by `Nylorun-Subject`. The thread is one session per person, agent and thread, created on its first run with `forwardedProps.nylorun.session`. The trailing user message starts a turn (its id is the idempotency key); `resume` answers interrupts. A busy session or a turn limit is a `RUN_ERROR` in the stream.",
54
+ request: {
55
+ params: agentId,
56
+ body: {
57
+ required: true,
58
+ content: { "application/json": { schema: ref("AgUiRunAgentInput") } },
59
+ },
60
+ },
61
+ responses: { 200: events("The run's events, from RUN_STARTED to its end") },
62
+ }, async (c) => {
63
+ const { scope, subject, access } = callerOf(c);
64
+ await startRun(c.env.tenant, scope, c.req.param("agentId"), { subject, access }, await readJson(c.req.raw), c.env.outgoing);
65
+ return RESPONSE_ALREADY_SENT;
66
+ });
67
+ tenantRoute(api, FOR_A_PERSON, {
68
+ method: "get",
69
+ path: "/v1/ag-ui/agents/{agentId}/threads/{threadId}/messages",
70
+ tags: TAGS,
71
+ summary: "Get a thread's messages",
72
+ request: { params: thread },
73
+ responses: {
74
+ 200: {
75
+ description: "The thread's messages; none before its first run",
76
+ content: {
77
+ "application/json": { schema: { type: "array", items: ref("AgUiMessage") } },
78
+ },
79
+ },
80
+ },
81
+ }, async (c) => {
82
+ const { subject, access } = callerOf(c);
83
+ const id = sessionIdFor(subject, c.req.param("agentId"), c.req.param("threadId"));
84
+ return jsonResponse(200, await threadMessages(c.env.tenant, id, access));
85
+ });
86
+ tenantRoute(api, FOR_A_PERSON, {
87
+ method: "get",
88
+ path: "/v1/ag-ui/agents/{agentId}/threads/{threadId}/events",
89
+ tags: TAGS,
90
+ summary: "Reattach to a run",
91
+ description: "The rest of a run after a dropped connection, from `Last-Event-ID` or `cursor`.",
92
+ request: {
93
+ params: thread,
94
+ query: z.object({
95
+ cursor: z.string().optional(),
96
+ runId: z.string().optional().meta({ description: "The run to report events as" }),
97
+ }),
98
+ },
99
+ responses: {
100
+ 200: events("The run's remaining events"),
101
+ 204: { description: "Nothing to send: no run is going, and none ended after the cursor" },
102
+ },
103
+ }, async (c) => {
104
+ const { scope, subject, access } = callerOf(c);
105
+ const threadId = c.req.param("threadId");
106
+ const header = c.env.incoming.headers["last-event-id"];
107
+ await reattachRun(c.env.tenant, scope, access, { threadId, id: sessionIdFor(subject, c.req.param("agentId"), threadId) }, {
108
+ lastEventId: typeof header === "string" ? header : undefined,
109
+ cursor: c.req.query("cursor"),
110
+ runId: c.req.query("runId"),
111
+ }, c.env.outgoing);
112
+ return RESPONSE_ALREADY_SENT;
113
+ });
114
+ tenantRoute(api, FOR_A_PERSON, {
115
+ method: "post",
116
+ path: "/v1/ag-ui/agents/{agentId}/threads/{threadId}/cancel",
117
+ tags: TAGS,
118
+ summary: "Cancel a thread's running turn",
119
+ request: { params: thread },
120
+ responses: { 204: { description: "Cancelled" } },
121
+ }, async (c) => {
122
+ const { scope, subject } = callerOf(c);
123
+ await cancelRun(c.env.tenant, sessionIdFor(subject, c.req.param("agentId"), c.req.param("threadId")), scope);
124
+ return new Response(null, { status: 204 });
125
+ });
126
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * An AG-UI transcript rebuilt from a session's log, with the ids a live run gave each
3
+ * message (see `translate.ts`), usable as `HttpAgent`'s `initialMessages`.
4
+ */
5
+ import type { Message } from "@ag-ui/core";
6
+ import { type LiveEvent } from "@nylorun/core/contracts";
7
+ export declare function messagesFromEvents(items: readonly LiveEvent[]): Message[];