@nylorun/runtime 0.43.0-beta → 0.44.1-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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.44.1-beta
4
+
5
+ ### Patch Changes
6
+
7
+ - c9de8a2: A turn that waits for its pod to start on a new Agent version runs as soon as the new pod's engine connects. The start check's pass now wakes the Agent's sessions with a turn waiting, instead of leaving them to the advance's next retry, up to `tenant.unavailableRetryMs` (1 s) later. It saves up to 1 s on each person's first wake after their Agent moves to a new version.
8
+
9
+ ## 0.44.0-beta
10
+
11
+ ### Minor Changes
12
+
13
+ - 1d3a5c5: Queued messages no longer wait for good (Host feature `queue-recovery`). When a turn ends and its
14
+ Agent refuses to start the next one for the session's queued messages (its disk being copied, its
15
+ pod lost, the Agent deleted), the messages wait with the session's `queueStalled`, and the Tenant
16
+ sweep tries the start again (Host tuning `sweep.queueRetryMs`, 30 seconds), as does the end of the
17
+ Agent's disk copy. A message that has waited longer than the new Runtime limit `queuedStartMs`
18
+ (15 minutes) is removed with the new event `queue.failed` (`queue.start_expired`, with the
19
+ refusal's code as `reason`); a refusal no retry changes (a turn request the version no longer
20
+ resolves, another harness) fails the messages at once (`queue.start_refused`), and the messages
21
+ behind them are tried next. Before, nothing started such a queue until another message arrived.
22
+
23
+ On the A2A endpoint (profile v2), a queued task that fails is `TASK_STATE_FAILED` with a status
24
+ message saying why, in `GetTask`, its pushes and a blocking `SendMessage`. `CancelTask` on a queued
25
+ task now takes it out of the queue, whether a task runs in its context or not, and answers
26
+ `TASK_STATE_CANCELED` (the new event `queue.cancelled`), pushed; before, it was
27
+ `TaskNotCancelableError`.
28
+
29
+ ### Patch Changes
30
+
31
+ - 63e0577: Every running pod Agent now has an idle timer, whatever woke it. A pod woken by a disk copy or by commands through the session's sandbox route, and one whose idle timer fired during a turn, no longer runs past its idle limit; the Tenant sweep arms the timer of a pod already running past it.
32
+ - b23a75c: A pod's engine checks its NetworkPolicy quickly on a wake. When the pod's disk already holds the engine's volume id, one 150 ms probe of the API server (`harness.wakeNetworkProbeMs`, `harness.wakeNetworkConfirmations`) replaces three of a second, so a woken computer joins about 2.9 s sooner. A first start keeps the full check, and a wake whose probe connects goes back to it.
33
+ - Pin core to the tested release.
34
+ - Pin harness to the tested release.
35
+ - Updated dependencies [1d3a5c5]
36
+ - Updated dependencies
37
+ - @nylorun/core@0.38.0-beta
38
+ - @nylorun/harness@0.34.1-beta
39
+
3
40
  ## 0.43.0-beta
4
41
 
5
42
  ### Minor Changes
@@ -72,7 +72,7 @@ export function a2aRoutes(api) {
72
72
  path: "/v1/a2a/agents/{agentId}",
73
73
  tags: TAGS,
74
74
  summary: "Call an agent over A2A",
75
- description: "A2A 1.0 JSON-RPC, for a subject, under the Nylorun profile v2 (Host feature `a2a-profile-v2`). `SendMessage` blocks up to the Runtime limit `a2aWaitMs` (5 minutes by default; then the task is answered still `WORKING`) unless `returnImmediately`; `GetTask` (with `historyLength`), `CancelTask` and `ListTasks`; and the four push notification config methods, plus `configuration.taskPushNotificationConfig` in `SendMessage`: each update is POSTed as one `StreamResponse`, signed with Standard Webhooks (`webhook-id`, `webhook-timestamp`, `webhook-signature` with the Runtime's `a2a.push.secret`) and `X-A2A-Notification-Token`, at least once, retried for up to 24 hours, to hosts the Runtime allows (`a2a.push.allowHosts` for private ones). A message to a busy context is queued and answered with the task that will take it (`TASK_STATE_SUBMITTED`); `message.metadata.delivery: \"reject\"` asks for `UnsupportedOperationError` (reason `CONTEXT_BUSY`) instead. `message.metadata.removeTools` (Host feature `remove-tools`) names the tools a new message's turn may not use, as a session message's `removeTools`. A message is text, one data part, or text with `raw` files (stored as artifacts of the context's session) and `url` files (fetched by the Runtime), each at most the Runtime limit `inlineFileBytes` (20 MiB by default). An approval, question or connection the task waits for (`AUTH_REQUIRED` or `INPUT_REQUIRED`) is shown under `interactions/v1` and answered on the same task with `{ interactionId, approved }` or `{ interactionId, value }` under that URI in the message's `metadata`; text never approves. Artifacts carry the turn's output and the files it wrote, as `url` parts at `/v1/a2a/files/…` with `files/v1`; the task's metadata carries `progress/v1`, `telemetry/v1` and `effects/v1`. Extension data is answered only for the extensions activated with `A2A-Extensions`. A context is one session per subject, agent and `contextId`; a task is one turn. JSON-RPC errors are answered with 200; limits, scopes and unavailable streams stay HTTP errors. Not served: streaming, `GetExtendedAgentCard`.",
75
+ description: "A2A 1.0 JSON-RPC, for a subject, under the Nylorun profile v2 (Host feature `a2a-profile-v2`). `SendMessage` blocks up to the Runtime limit `a2aWaitMs` (5 minutes by default; then the task is answered still `WORKING`) unless `returnImmediately`; `GetTask` (with `historyLength`), `CancelTask` and `ListTasks`; and the four push notification config methods, plus `configuration.taskPushNotificationConfig` in `SendMessage`: each update is POSTed as one `StreamResponse`, signed with Standard Webhooks (`webhook-id`, `webhook-timestamp`, `webhook-signature` with the Runtime's `a2a.push.secret`) and `X-A2A-Notification-Token`, at least once, retried for up to 24 hours, to hosts the Runtime allows (`a2a.push.allowHosts` for private ones). A message to a busy context is queued and answered with the task that will take it (`TASK_STATE_SUBMITTED`); `message.metadata.delivery: \"reject\"` asks for `UnsupportedOperationError` (reason `CONTEXT_BUSY`) instead. A queued task never waits for good (Host feature `queue-recovery`): when its Agent refuses its turn's start (a disk copy, a lost pod), the start is tried again until the Runtime limit `queuedStartMs` (15 minutes by default), and then, or at once for a refusal no retry changes, the task is `TASK_STATE_FAILED` with a status message saying why; `CancelTask` on a queued task takes it out of the queue, a task running in the context or not, and answers `TASK_STATE_CANCELED`. Both are pushed. `message.metadata.removeTools` (Host feature `remove-tools`) names the tools a new message's turn may not use, as a session message's `removeTools`. A message is text, one data part, or text with `raw` files (stored as artifacts of the context's session) and `url` files (fetched by the Runtime), each at most the Runtime limit `inlineFileBytes` (20 MiB by default). An approval, question or connection the task waits for (`AUTH_REQUIRED` or `INPUT_REQUIRED`) is shown under `interactions/v1` and answered on the same task with `{ interactionId, approved }` or `{ interactionId, value }` under that URI in the message's `metadata`; text never approves. Artifacts carry the turn's output and the files it wrote, as `url` parts at `/v1/a2a/files/…` with `files/v1`; the task's metadata carries `progress/v1`, `telemetry/v1` and `effects/v1`. Extension data is answered only for the extensions activated with `A2A-Extensions`. A context is one session per subject, agent and `contextId`; a task is one turn. JSON-RPC errors are answered with 200; limits, scopes and unavailable streams stay HTTP errors. Not served: streaming, `GetExtendedAgentCard`.",
76
76
  externalDocs: A2A_SPECIFICATION,
77
77
  request: {
78
78
  params: agentId,
@@ -5,7 +5,7 @@ import { traceIdOf } from "../../traces/ids.js";
5
5
  import { hostTuning } from "../../tuning.js";
6
6
  import { ALL_EXTENSIONS, keepExtensions } from "./extensions.js";
7
7
  import { a2aFail, } from "./protocol.js";
8
- import { ENDED, callerTraceId, updatesOf } from "./tasks.js";
8
+ import { ENDED, callerTraceId, endsQueued, updatesOf } from "./tasks.js";
9
9
  /** The most push configs one task keeps. A constant of the profile, not a setting. */
10
10
  export const MAX_PUSH_CONFIGS = 10;
11
11
  /** Events one pump reads at a time. */
@@ -172,6 +172,19 @@ export async function pumpSession(ctx, sessionId) {
172
172
  // A queued message's task follows the turn that takes it.
173
173
  if (event.type === "command.message" && event.turnId && payload.queuedMessageId === turnId)
174
174
  turnId = event.turnId;
175
+ // Or ends without one: it failed or was cancelled while queued (`queue-recovery`).
176
+ if (endsQueued(event, turnId)) {
177
+ const view = { contextId: config.contextId, taskId: config.taskId, baseUrl: config.baseUrl };
178
+ for (const update of updatesOf(event, view))
179
+ await t.enqueuePush({
180
+ sessionId,
181
+ taskId: config.taskId,
182
+ configId: config.configId,
183
+ body: JSON.stringify(keepExtensions(update, new Set(config.extensions))),
184
+ });
185
+ done = true;
186
+ break;
187
+ }
175
188
  if (event.turnId !== turnId)
176
189
  continue;
177
190
  if (event.type === "command.message" && traceId === null)
@@ -18,7 +18,10 @@
18
18
  * - A message to a busy context is **queued** (`delivery: "queue"`, protocol 17) and answered
19
19
  * with the task that will take it: `SUBMITTED`, its id the queued message's own until a turn
20
20
  * takes it, then that turn's state. `metadata.delivery: "reject"` keeps v1's
21
- * `UnsupportedOperationError` (reason `CONTEXT_BUSY`).
21
+ * `UnsupportedOperationError` (reason `CONTEXT_BUSY`). A queued task never waits for good (Host
22
+ * feature `queue-recovery`): one whose turn the Agent refused to start for longer than the
23
+ * Runtime limit `queuedStartMs`, or for good, is `FAILED`; `CancelTask` takes one out of the
24
+ * queue as `CANCELED`. Both are pushed.
22
25
  * - An open interaction shows on the status message under `interactions/v1`; the caller answers
23
26
  * on the same task with `{ interactionId, approved }` (the Runtime's `approve`, recorded with
24
27
  * the caller as `approvedBy`) or `{ interactionId, value }` (`respond`). Text never approves.
@@ -39,11 +42,11 @@ import { keepExtensions } from "./extensions.js";
39
42
  import { indexedTurnEvents, listTasks, parseListParams } from "./list.js";
40
43
  import { A2aError, a2aFail, checkVersion, jsonRpcError, jsonRpcResult, parseCreatePushParams, parseEnvelope, parseListPushParams, parsePushConfigRef, parseSendParams, parseTaskParams, } from "./protocol.js";
41
44
  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";
45
+ import { ENDED, SETTLED_EVENTS, buildTask, callerTraceId, contextSessionId, droppedTask, endsQueued, newContextId, parseTaskId, pendingInteraction, pendingInteractions, submittedTask, taskIdOf, } from "./tasks.js";
43
46
  import { currentModelDescriptor } from "../../model/descriptor.js";
44
47
  import { decodeCursor } from "../../record/cursor.js";
45
48
  import { accessOf } from "../../tenant/auth.js";
46
- import { command } from "../../tenant/commands.js";
49
+ import { cancelQueuedMessage, command } from "../../tenant/commands.js";
47
50
  import { loadSession, reachesAgent, } from "../../tenant/context.js";
48
51
  import { agentGrantsOf, liveEnvironment } from "../../tenant/agents.js";
49
52
  import { HttpError, fail } from "../../tenant/http.js";
@@ -213,7 +216,9 @@ async function indexedTask(caller, row, session, options = {}) {
213
216
  * The caller's task `taskId`, found through the turn index. Its last part is a turn, or a queued
214
217
  * message's id: while the message waits the task is `SUBMITTED`, and once a turn took it (the
215
218
  * 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.
219
+ * caller knows. A queued message that failed or was cancelled before a turn took it (Host feature
220
+ * `queue-recovery`) is `FAILED` or `CANCELED`, from the event that ended it. Only a message still
221
+ * in the queue, or no longer in it and taken by no turn, is looked for in the session's history.
217
222
  */
218
223
  async function ownedTask(caller, taskId, historyLength) {
219
224
  const ids = parseTaskId(taskId) ?? taskNotFound(taskId);
@@ -229,18 +234,16 @@ async function ownedTask(caller, taskId, historyLength) {
229
234
  const taken = await t.turnOfQueuedMessage(sessionId, ids.turnId);
230
235
  return taken ? { turn: taken } : {};
231
236
  });
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
- };
237
+ if (found.turn === undefined) {
238
+ // Queued, or a queued message that failed or was cancelled: its history says which.
239
+ const events = await sessionEvents(caller.ctx, sessionId);
240
+ const queued = events.find((event) => event.type === "message.queued" && payloadOf(event).queuedMessageId === ids.turnId);
241
+ const dropped = queued ? events.find((event) => endsQueued(event, ids.turnId)) : undefined;
242
+ const owned = { session, sessionId, contextId: ids.contextId, turnId: ids.turnId, events: [] };
243
+ if (dropped)
244
+ return { ...owned, task: droppedTask(ids.contextId, taskId, dropped), dropped };
245
+ if (queued && found.queued)
246
+ return { ...owned, task: submittedTask(ids.contextId, taskId, queued), queued };
244
247
  }
245
248
  const row = found.turn ?? taskNotFound(taskId);
246
249
  const built = (await indexedTask(caller, { ...row, a2aContextId: ids.contextId }, session, {
@@ -503,13 +506,21 @@ function replyTarget(owned) {
503
506
  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 });
504
507
  return pending.id;
505
508
  }
506
- async function cancelTask(caller, taskId) {
509
+ /**
510
+ * `CancelTask`. A queued task (`SUBMITTED`) leaves its context's queue and is `CANCELED`
511
+ * (`queue.cancelled`, pushed), whether a task runs in the context or not (Host feature
512
+ * `queue-recovery`); one a turn took meanwhile is cancelled as that turn. A running or waiting
513
+ * task's turn is cancelled. An ended task is not cancelable, unless it is `CANCELED` already.
514
+ */
515
+ async function cancelTask(caller, taskId, again = true) {
507
516
  const owned = await ownedTask(caller, taskId);
508
- if (owned.queued)
509
- return a2aFail("taskNotCancelable", "The task is queued and starts when the context's running task ends; cancel it then", {
510
- taskId,
511
- state: owned.task.status.state,
512
- });
517
+ if (owned.queued) {
518
+ const cancelled = await cancelQueuedMessage(caller.ctx, owned.sessionId, owned.turnId, caller.scope, "Canceled by the A2A client");
519
+ // Gone from the queue meanwhile: a turn took it, or it failed. Answer as the task is now.
520
+ if (!cancelled && again)
521
+ return cancelTask(caller, taskId, false);
522
+ return (await ownedTask(caller, taskId)).task;
523
+ }
513
524
  if (owned.session.activeTurnId !== owned.turnId) {
514
525
  if (owned.task.status.state === "TASK_STATE_CANCELED")
515
526
  return owned.task;
@@ -566,8 +577,8 @@ async function createPushConfig(caller, params) {
566
577
  requirePush(caller);
567
578
  const { taskId, config } = parseCreatePushParams(params);
568
579
  const owned = await ownedTask(caller, taskId);
569
- const last = owned.queued ?? owned.events.at(-1);
570
- const ended = owned.events.some((event) => ENDED.has(event.type));
580
+ const last = owned.dropped ?? owned.queued ?? owned.events.at(-1);
581
+ const ended = owned.dropped !== undefined || owned.events.some((event) => ENDED.has(event.type));
571
582
  const configId = config.id ?? createHash("sha256").update(`${config.url}\u0000${config.token ?? ""}`).digest("hex").slice(0, 16);
572
583
  return registerPush(caller, { ...config, id: configId }, {
573
584
  sessionId: owned.sessionId,
@@ -575,7 +586,7 @@ async function createPushConfig(caller, params) {
575
586
  taskId: owned.task.id,
576
587
  turnId: owned.turnId,
577
588
  nextSeq: last ? last.seq + 1 : 0,
578
- ...(owned.queued ? {} : { traceId: await turnTraceId(caller.ctx, owned.sessionId, owned.turnId, owned.events) }),
589
+ ...(owned.queued || owned.dropped ? {} : { traceId: await turnTraceId(caller.ctx, owned.sessionId, owned.turnId, owned.events) }),
579
590
  done: ended,
580
591
  });
581
592
  }
@@ -615,7 +626,8 @@ async function deletePushConfig(caller, params) {
615
626
  * Waits until the task's turn ends, pauses or has an uncertain effect, the caller hangs up, or
616
627
  * the Tenant's Runtime limit `a2aWaitMs` passes: after it, the task is returned as it is
617
628
  * (`WORKING`, or `SUBMITTED` while queued) and keeps running, and the caller polls `GetTask` or
618
- * waits for pushes. A queued message's task is followed to the turn that takes it. Follows the
629
+ * waits for pushes. A queued message's task is followed to the turn that takes it, or to its
630
+ * `queue.failed` or `queue.cancelled`. Follows the
619
631
  * session's shared stream from the command's cursor, so the events the task is built from are
620
632
  * there when it returns. A feed that ends (reset, deleted, closing) ends the wait: the task says
621
633
  * what the session says then.
@@ -639,6 +651,8 @@ async function settled(ctx, sessionId, accepted, signal) {
639
651
  event.turnId &&
640
652
  payloadOf(event).queuedMessageId === accepted.queuedMessageId)
641
653
  turnId = event.turnId;
654
+ if (turnId === undefined && endsQueued(event, accepted.queuedMessageId))
655
+ return;
642
656
  if (turnId !== undefined && event.turnId === turnId && SETTLED_EVENTS.has(event.type))
643
657
  return;
644
658
  }
@@ -48,6 +48,14 @@ export declare function interruptedState(interaction: PendingInteraction): TaskS
48
48
  /** The status message that shows an open interaction, with its `interactions/v1` data. */
49
49
  export declare function interactionMessage(interaction: PendingInteraction, contextId: string, taskId: string): Message;
50
50
  export declare const ENDED: ReadonlySet<string>;
51
+ /**
52
+ * Events that end a queued message's task without a turn (Host feature `queue-recovery`): its
53
+ * start was refused past the Runtime limit `queuedStartMs`, or for good (`queue.failed`), or a
54
+ * client cancelled it (`queue.cancelled`). Each names the queued messages it ends.
55
+ */
56
+ export declare const QUEUE_ENDED: ReadonlySet<string>;
57
+ /** Whether `event` ends queued message `queuedMessageId`'s task (`QUEUE_ENDED`). */
58
+ export declare function endsQueued(event: LiveEvent, queuedMessageId: string): boolean;
51
59
  /** Events after which a blocking `SendMessage` returns: the turn ended or is interrupted. */
52
60
  export declare const SETTLED_EVENTS: ReadonlySet<string>;
53
61
  /** The turn's conversation as A2A messages: what the caller sent and what the agent said. */
@@ -116,3 +124,9 @@ export declare function buildTask(input: BuildTaskInput): Task | undefined;
116
124
  * event, until a turn takes it.
117
125
  */
118
126
  export declare function submittedTask(contextId: string, taskId: string, queued: LiveEvent): Task;
127
+ /**
128
+ * A queued message's task that ended without a turn (Host feature `queue-recovery`), from the
129
+ * `queue.failed` or `queue.cancelled` event that names it: `FAILED`, saying why it never started,
130
+ * or `CANCELED`.
131
+ */
132
+ export declare function droppedTask(contextId: string, taskId: string, ended: LiveEvent): Task;
@@ -112,6 +112,19 @@ export function interactionMessage(interaction, contextId, taskId) {
112
112
  };
113
113
  }
114
114
  export const ENDED = new Set(["turn.completed", "turn.failed", "turn.cancelled"]);
115
+ /**
116
+ * Events that end a queued message's task without a turn (Host feature `queue-recovery`): its
117
+ * start was refused past the Runtime limit `queuedStartMs`, or for good (`queue.failed`), or a
118
+ * client cancelled it (`queue.cancelled`). Each names the queued messages it ends.
119
+ */
120
+ export const QUEUE_ENDED = new Set(["queue.failed", "queue.cancelled"]);
121
+ /** Whether `event` ends queued message `queuedMessageId`'s task (`QUEUE_ENDED`). */
122
+ export function endsQueued(event, queuedMessageId) {
123
+ if (!QUEUE_ENDED.has(event.type))
124
+ return false;
125
+ const ids = event.payload?.queuedMessageIds;
126
+ return Array.isArray(ids) && ids.includes(queuedMessageId);
127
+ }
115
128
  /** Events after which a blocking `SendMessage` returns: the turn ended or is interrupted. */
116
129
  export const SETTLED_EVENTS = new Set([
117
130
  ...ENDED,
@@ -170,6 +183,11 @@ export function historyOf(events, contextId, taskId) {
170
183
  function agentMessage(messageId, text, contextId, taskId) {
171
184
  return { messageId, role: "ROLE_AGENT", parts: [{ text }], contextId, taskId };
172
185
  }
186
+ /** What a failed queued message's task says: why its turn never started. */
187
+ function queueFailureText(payload) {
188
+ const reason = typeof payload.error?.message === "string" ? ` ${payload.error.message}` : "";
189
+ return `The message never started a turn.${reason}`;
190
+ }
173
191
  function failureText(code) {
174
192
  return typeof code === "string" && code !== ""
175
193
  ? `The agent failed (${code}).`
@@ -319,6 +337,15 @@ export function updatesOf(event, view) {
319
337
  const file = fileArtifact(payload, view.baseUrl);
320
338
  return file ? [artifact(file)] : [];
321
339
  }
340
+ case "queue.failed":
341
+ // A queued message's task that no turn will take (Host feature `queue-recovery`).
342
+ return [
343
+ status("TASK_STATE_FAILED", {
344
+ message: agentMessage(`${taskId}:failed`, queueFailureText(payload), contextId, taskId),
345
+ }),
346
+ ];
347
+ case "queue.cancelled":
348
+ return [status("TASK_STATE_CANCELED")];
322
349
  case "turn.completed":
323
350
  case "turn.failed":
324
351
  case "turn.cancelled": {
@@ -468,3 +495,18 @@ export function submittedTask(contextId, taskId, queued) {
468
495
  history: [],
469
496
  };
470
497
  }
498
+ /**
499
+ * A queued message's task that ended without a turn (Host feature `queue-recovery`), from the
500
+ * `queue.failed` or `queue.cancelled` event that names it: `FAILED`, saying why it never started,
501
+ * or `CANCELED`.
502
+ */
503
+ export function droppedTask(contextId, taskId, ended) {
504
+ const [update] = updatesOf(ended, { contextId, taskId });
505
+ const status = update && "statusUpdate" in update ? update.statusUpdate.status : undefined;
506
+ return {
507
+ id: taskId,
508
+ contextId,
509
+ status: status ?? { state: "TASK_STATE_FAILED", timestamp: ended.time },
510
+ history: [],
511
+ };
512
+ }