@nylorun/runtime 0.49.0-beta → 0.49.2-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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.49.2-beta
4
+
5
+ ### Patch Changes
6
+
7
+ - bf9d614: Pods on Agent Substrate (preview):
8
+
9
+ - **A Runtime upgrade no longer strands existing pods.** Agent Substrate repoints an actor only to a template with the same volumes, and the engine is an image volume pinned by the Runtime image's digest. So after an upgrade, every existing pod's start failed and was sent again every 2 seconds without end, and only a reset got out, at the cost of the pod's disk. An actor now keeps the engine it was created with. An upgrade keeps the pod, its `/workspace` and `/harness`, and it starts as before. The pod runs its first engine until a new incarnation (a reset). The Runtime's Harness API check at `hello` still guards it.
10
+ - **A change Substrate can't make to an existing pod fails its start with the reason.** This is any other volume change, such as switching between the image's engine and the Runtime's, or a template Substrate refuses. The sandbox reports `RepointRefused` with why, the Runtime's ready deadline fails the start, and the request is not sent again.
11
+ - **A crashed actor is reverted before its template changes**, as Substrate requires. Before, a new spec for a crashed pod failed without end.
12
+
13
+ ## 0.49.1-beta
14
+
15
+ ### Patch Changes
16
+
17
+ - 99a8301: A model call whose stream was cut before its terminal chunk is tried again. Vercel AI Gateway ends a stream with `Upstream stream ended before terminal chunk` when the provider behind it fails after the stream began (a provider's 503, `finish_reason: stream_terminated`). pi-ai's retry list doesn't know that wording, so the call failed `invalid_request` on its first attempt and ended the turn: on beta, six Worker turns of 76 s to 13 minutes were lost this way to one provider's 503. The gate now retries it like any transient error, up to the Tenant's `modelAttempts` (3 by default) with `modelRetryDelayMs` between attempts. A call cut on every attempt fails `transient` (`retryable: true`), not `invalid_request`.
18
+
19
+ Retries are visible: the gates service logs a `model_retry` line for each one (session, effect, attempt, delay and the provider's error, cut to 300 characters), and its `model_call` line carries the call's `attempts`.
20
+
3
21
  ## 0.49.0-beta
4
22
 
5
23
  ### Minor Changes
@@ -3,7 +3,8 @@
3
3
  * Tenant; a call naming another is refused) and calls the model (the fixture model while the
4
4
  * Tenant's `model.fixture` is on, R4.3) through `inProcessModelGate`, the same code the loop ran before the gate, so outcomes,
5
5
  * retries, classification and redaction are unchanged. The meter records the call's usage in
6
- * the Tenant's ledger. Logs one line per call, never the prompt, the output or a credential.
6
+ * the Tenant's ledger. Logs one line per call, with its attempts, and one per retry, never the
7
+ * prompt, the output or a credential.
7
8
  */
8
9
  import { classifyThrown } from "../model/classify.js";
9
10
  import { callCaller, callFiles, inProcessModelGate, modelCallSettings, serve } from "./in-process.js";
@@ -17,6 +18,8 @@ export function createModelCallHandler(options) {
17
18
  const started = Date.now();
18
19
  let request = named;
19
20
  let outcome;
21
+ // Attempts the call made, the first included: more than 1 when it was retried.
22
+ let attempts = 1;
20
23
  try {
21
24
  const vault = await vaults.open(named.tenantId);
22
25
  const call = { ...named, tenantId: vault.tenantId };
@@ -29,12 +32,22 @@ export function createModelCallHandler(options) {
29
32
  settings: await modelCallSettings(vault.store),
30
33
  ...(blobs ? { files: callFiles(vault.store, blobs) } : {}),
31
34
  caller: callCaller(vault.store),
35
+ onRetry: (_request, retry) => {
36
+ attempts = retry.attempt;
37
+ logger.info("model_retry", {
38
+ ...fields(call),
39
+ attempt: retry.attempt,
40
+ of: retry.maxAttempts,
41
+ delayMs: retry.delayMs,
42
+ error: retry.errorMessage.slice(0, 300),
43
+ });
44
+ },
32
45
  });
33
46
  outcome = await meter.call(vault.store, call, () => serve(vault.store, gate, call, signal));
34
47
  }
35
48
  catch (error) {
36
49
  if (signal.aborted) {
37
- logger.info("model_call", { ...fields(request), ms: Date.now() - started, outcome: "aborted" });
50
+ logger.info("model_call", { ...fields(request), ms: Date.now() - started, attempts, outcome: "aborted" });
38
51
  throw error;
39
52
  }
40
53
  outcome = error instanceof GateRefusal ? error.outcome : classifyThrown(error);
@@ -42,6 +55,7 @@ export function createModelCallHandler(options) {
42
55
  logger.info("model_call", {
43
56
  ...fields(request),
44
57
  ms: Date.now() - started,
58
+ attempts,
45
59
  outcome: typeof outcome === "object" && outcome !== null && "kind" in outcome
46
60
  ? outcome.code
47
61
  : "ok",
@@ -12,6 +12,7 @@ import type { ModelGate, ModelGateRequest } from "./model-gate.js";
12
12
  import { type FileResolver } from "../artifacts/files.js";
13
13
  import type { BlobStore } from "../blob/index.js";
14
14
  import type { ModelCaller } from "../model/call-headers.js";
15
+ import type { ModelRetry } from "../model/retry.js";
15
16
  export interface InProcessModelGateOptions {
16
17
  /** The Tenant's home: `piModel` redacts secrets it finds there from failure messages. */
17
18
  readonly root?: string;
@@ -24,6 +25,8 @@ export interface InProcessModelGateOptions {
24
25
  readonly files?: (request: ModelGateRequest) => FileResolver;
25
26
  /** Reads who makes a call, for a custom endpoint's per-call headers (#493). */
26
27
  readonly caller?: (request: ModelGateRequest) => Promise<ModelCaller | undefined>;
28
+ /** Hears each retry of a call (`piModel`'s `onRetry`): the gates service logs it. */
29
+ readonly onRetry?: (request: ModelGateRequest, retry: ModelRetry) => void;
27
30
  }
28
31
  /**
29
32
  * The files a call names, from the Tenant's artifacts, limited to the call's session: in
@@ -85,6 +85,7 @@ export function inProcessModelGate(options) {
85
85
  ...(options.settings ? { settings: options.settings } : {}),
86
86
  ...(options.files ? { files: options.files(request) } : {}),
87
87
  ...(options.caller ? { caller: () => options.caller(request) } : {}),
88
+ ...(options.onRetry ? { onRetry: (retry) => options.onRetry(request, retry) } : {}),
88
89
  });
89
90
  return adapter(request.call, {
90
91
  // The Runtime never journals the provider request (harness `durable.ts`), and
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Management API",
5
- "version": "0.49.0-beta",
5
+ "version": "0.49.2-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
@@ -1,4 +1,5 @@
1
1
  import { isContextOverflow, isRetryableAssistantError, } from "@earendil-works/pi-ai";
2
+ import { STREAM_CUT } from "./retry.js";
2
3
  const RATE_LIMITED = /\b429\b|rate.?limit|too many requests/i;
3
4
  const QUOTA = /insufficient_quota|quota exceeded|out of budget|billing|usage limit/i;
4
5
  const OVERLOADED = /overloaded|\b529\b|\b503\b|service.?unavailable|high demand/i;
@@ -22,6 +23,10 @@ export function classifyAssistantError(message, contextWindow) {
22
23
  return failure("overloaded", text, true);
23
24
  if (TIMEOUT.test(text))
24
25
  return failure("timeout", text, true);
26
+ // A stream cut before its last chunk (a provider failing behind a gateway): nothing of the
27
+ // step ran, so it is the provider's transient failure, not the request's.
28
+ if (STREAM_CUT.test(text))
29
+ return failure("transient", text, true);
25
30
  if (CONTENT_POLICY.test(text))
26
31
  return failure("content_policy", text, false);
27
32
  if (AUTH.test(text))
@@ -3,6 +3,7 @@ import type { RuntimeModelAdapter } from "../contracts.js";
3
3
  import { type RuntimeLimits } from "@nylorun/core/contracts";
4
4
  import { type FileResolver } from "../artifacts/files.js";
5
5
  import type { HostModelSecret } from "../vault/service.js";
6
+ import { type ModelRetry } from "./retry.js";
6
7
  import { type ModelCaller } from "./call-headers.js";
7
8
  export interface ModelPreview {
8
9
  readonly invocationId: string;
@@ -45,6 +46,8 @@ export interface PiModelOptions {
45
46
  * come from it. Without it, a call sends none of them.
46
47
  */
47
48
  readonly caller?: () => Promise<ModelCaller | undefined>;
49
+ /** Hears each retry of a call before its delay: the gate logs it. Never changes the call. */
50
+ readonly onRetry?: (retry: ModelRetry) => void;
48
51
  }
49
52
  /**
50
53
  * Node model adapter. Local provider configuration is read only when invoked.
@@ -1,10 +1,11 @@
1
- import { retryAssistantCall, } from "@earendil-works/pi-ai";
1
+ import {} from "@earendil-works/pi-ai";
2
2
  import { RUNTIME_LIMIT_DEFAULTS } from "@nylorun/core/contracts";
3
3
  import { mediaReferenceOf } from "@nylorun/core/define";
4
4
  import { FileUnavailableError, } from "../artifacts/files.js";
5
5
  import { essence, inputLabel, isModelImage, modelInputOf } from "@nylorun/core/contracts";
6
6
  import { scrub } from "../redact.js";
7
7
  import { classifyAssistantError, failure } from "./classify.js";
8
+ import { retryModelCall } from "./retry.js";
8
9
  import { addedInputsOf, describeModel, selectionOf } from "./descriptor.js";
9
10
  import { modelsFor } from "./models.js";
10
11
  import { callHeaders } from "./call-headers.js";
@@ -199,11 +200,7 @@ export function piModel(options = {}) {
199
200
  clearTimeout(timer);
200
201
  }
201
202
  };
202
- const response = await retryAssistantCall(attempt, {
203
- enabled: settings.attempts > 1,
204
- maxRetries: settings.attempts - 1,
205
- baseDelayMs: settings.retryBaseDelayMs,
206
- }, context.signal);
203
+ const response = await retryModelCall(attempt, { attempts: settings.attempts, baseDelayMs: settings.retryBaseDelayMs }, context.signal, options.onRetry);
207
204
  // An abort is never a failure outcome: the host decides what it means.
208
205
  context.signal.throwIfAborted();
209
206
  if (natives.error)
@@ -0,0 +1,36 @@
1
+ /**
2
+ * One model call's attempts (Model Calls §6): pi-ai's transient errors, plus a stream the provider
3
+ * or a gateway cut before its last chunk, are tried again with pi-ai's backoff, up to the Tenant's
4
+ * `modelAttempts`. Each retry is reported, so a call that needed one is visible in the gate's log.
5
+ */
6
+ import { type AssistantMessage } from "@earendil-works/pi-ai";
7
+ /**
8
+ * A stream that ended before its terminal chunk: Vercel AI Gateway's `Upstream stream ended before
9
+ * terminal chunk` (its `finish_reason: stream_terminated`) when the provider behind it fails after
10
+ * the stream began. Nothing of the step ran, so it is safe to call again; pi-ai's own list doesn't
11
+ * know this wording.
12
+ */
13
+ export declare const STREAM_CUT: RegExp;
14
+ /** True for a failed message worth another attempt. */
15
+ export declare function isRetryableModelError(message: AssistantMessage): boolean;
16
+ /** One retry, as `onRetry` hears it: before the delay is waited. */
17
+ export interface ModelRetry {
18
+ /** The attempt about to start, the first counted as 1. */
19
+ readonly attempt: number;
20
+ readonly maxAttempts: number;
21
+ readonly delayMs: number;
22
+ /** The failed attempt's error, as the provider or pi-ai worded it. */
23
+ readonly errorMessage: string;
24
+ }
25
+ export interface ModelRetryPolicy {
26
+ /** Attempts, the first included. 1 means no retry. */
27
+ readonly attempts: number;
28
+ /** The delay before the second attempt; it doubles each time (pi-ai's `retryDelayMs`). */
29
+ readonly baseDelayMs: number;
30
+ }
31
+ /**
32
+ * Runs `produce` until it answers, fails in a way no retry changes, or uses up its attempts.
33
+ * An abort is never retried: an aborted message comes back as it is, and an abort during the
34
+ * delay comes back as the last failure marked `aborted`, as pi-ai's `retryAssistantCall` does.
35
+ */
36
+ export declare function retryModelCall(produce: () => Promise<AssistantMessage>, policy: ModelRetryPolicy, signal: AbortSignal, onRetry?: (retry: ModelRetry) => void): Promise<AssistantMessage>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * One model call's attempts (Model Calls §6): pi-ai's transient errors, plus a stream the provider
3
+ * or a gateway cut before its last chunk, are tried again with pi-ai's backoff, up to the Tenant's
4
+ * `modelAttempts`. Each retry is reported, so a call that needed one is visible in the gate's log.
5
+ */
6
+ import { isRetryableAssistantError, retryDelayMs, } from "@earendil-works/pi-ai";
7
+ /**
8
+ * A stream that ended before its terminal chunk: Vercel AI Gateway's `Upstream stream ended before
9
+ * terminal chunk` (its `finish_reason: stream_terminated`) when the provider behind it fails after
10
+ * the stream began. Nothing of the step ran, so it is safe to call again; pi-ai's own list doesn't
11
+ * know this wording.
12
+ */
13
+ export const STREAM_CUT = /stream ended before (?:its |the |a )?terminal chunk|upstream stream ended|stream[_ ]terminated/i;
14
+ /** True for a failed message worth another attempt. */
15
+ export function isRetryableModelError(message) {
16
+ if (message.stopReason !== "error")
17
+ return false;
18
+ if (isRetryableAssistantError(message))
19
+ return true;
20
+ return STREAM_CUT.test(message.errorMessage ?? "");
21
+ }
22
+ /**
23
+ * Runs `produce` until it answers, fails in a way no retry changes, or uses up its attempts.
24
+ * An abort is never retried: an aborted message comes back as it is, and an abort during the
25
+ * delay comes back as the last failure marked `aborted`, as pi-ai's `retryAssistantCall` does.
26
+ */
27
+ export async function retryModelCall(produce, policy, signal, onRetry) {
28
+ const maxAttempts = Math.max(1, policy.attempts);
29
+ for (let attempt = 1;; attempt += 1) {
30
+ const response = await produce();
31
+ if (response.stopReason !== "error")
32
+ return response;
33
+ if (attempt >= maxAttempts || !isRetryableModelError(response))
34
+ return response;
35
+ const delayMs = retryDelayMs({ baseDelayMs: policy.baseDelayMs }, attempt);
36
+ try {
37
+ onRetry?.({ attempt: attempt + 1, maxAttempts, delayMs, errorMessage: response.errorMessage ?? "Unknown error" });
38
+ }
39
+ catch {
40
+ /* Reporting never changes the call. */
41
+ }
42
+ if (!(await wait(delayMs, signal))) {
43
+ const { errorMessage: _errorMessage, ...rest } = response;
44
+ return { ...rest, stopReason: "aborted" };
45
+ }
46
+ }
47
+ }
48
+ /** Resolves true after `ms`, or false as soon as `signal` aborts. */
49
+ function wait(ms, signal) {
50
+ if (signal.aborted)
51
+ return Promise.resolve(false);
52
+ return new Promise((resolve) => {
53
+ const timer = setTimeout(() => {
54
+ signal.removeEventListener("abort", onAbort);
55
+ resolve(true);
56
+ }, ms);
57
+ const onAbort = () => {
58
+ clearTimeout(timer);
59
+ resolve(false);
60
+ };
61
+ signal.addEventListener("abort", onAbort, { once: true });
62
+ });
63
+ }
package/dist/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Runtime API",
5
- "version": "0.49.0-beta",
5
+ "version": "0.49.2-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.49.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.49.2-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/server/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.49.0-beta";
3
+ export const RUNTIME_VERSION = "0.49.2-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.49.0-beta",
3
+ "version": "0.49.2-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",