@nylorun/runtime 0.20.0-beta → 0.21.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +19 -12
  3. package/dist/adapters/execution/restate.js +26 -1
  4. package/dist/api/components.d.ts +155 -36
  5. package/dist/api/components.js +3 -3
  6. package/dist/api/http/app.js +2 -0
  7. package/dist/api/http/routes/mcp.d.ts +9 -0
  8. package/dist/api/http/routes/mcp.js +32 -0
  9. package/dist/api/http/routes/vaults.d.ts +1 -9
  10. package/dist/api/http/routes/vaults.js +3 -103
  11. package/dist/api/openapi.js +6 -3
  12. package/dist/artifacts/media-types.d.ts +2 -0
  13. package/dist/artifacts/media-types.js +5 -0
  14. package/dist/core/flow-host.d.ts +2 -3
  15. package/dist/core/flow-host.js +10 -7
  16. package/dist/execution/types.d.ts +13 -5
  17. package/dist/execution/types.js +13 -5
  18. package/dist/gates/http-tool.d.ts +7 -0
  19. package/dist/gates/http-tool.js +34 -3
  20. package/dist/gates/mcp-handler.d.ts +4 -1
  21. package/dist/gates/mcp-handler.js +64 -8
  22. package/dist/gates/tenant-vaults.d.ts +9 -15
  23. package/dist/gates/tenant-vaults.js +9 -17
  24. package/dist/gates/tool-client.js +36 -17
  25. package/dist/gates/tool-contract.d.ts +19 -3
  26. package/dist/gates/tool-contract.js +9 -3
  27. package/dist/gates/tool-gate.d.ts +2 -2
  28. package/dist/harness/calls.d.ts +2 -0
  29. package/dist/harness/calls.js +13 -3
  30. package/dist/harness-api/record.d.ts +7 -6
  31. package/dist/harness-api/record.js +99 -17
  32. package/dist/harness-api/server.js +35 -27
  33. package/dist/harness-api/start.js +2 -1
  34. package/dist/host/config-for.d.ts +0 -2
  35. package/dist/host/config-for.js +0 -1
  36. package/dist/host/create-host.d.ts +1 -1
  37. package/dist/host/create-host.js +2 -1
  38. package/dist/host/gates.js +0 -1
  39. package/dist/host/http.d.ts +0 -1
  40. package/dist/host/http.js +0 -6
  41. package/dist/host/main.js +13 -3
  42. package/dist/host/stack-config.d.ts +7 -8
  43. package/dist/host/stack-config.js +50 -92
  44. package/dist/index.d.ts +0 -1
  45. package/dist/keys/client.js +1 -9
  46. package/dist/keys/keys.d.ts +11 -13
  47. package/dist/keys/keys.js +5 -7
  48. package/dist/management-openapi.json +315 -345
  49. package/dist/mcp/connect.d.ts +159 -13
  50. package/dist/mcp/connect.js +538 -55
  51. package/dist/mcp/pool.d.ts +11 -2
  52. package/dist/mcp/pool.js +74 -27
  53. package/dist/mcp/preview.d.ts +35 -0
  54. package/dist/mcp/preview.js +215 -0
  55. package/dist/mcp/snapshot.d.ts +79 -5
  56. package/dist/mcp/snapshot.js +145 -27
  57. package/dist/model/catalog.d.ts +14 -0
  58. package/dist/model/catalog.js +14 -1
  59. package/dist/model/pi-model.js +36 -2
  60. package/dist/openapi.json +362 -123
  61. package/dist/redact.d.ts +19 -1
  62. package/dist/redact.js +42 -13
  63. package/dist/sandbox/session-sandbox.d.ts +7 -0
  64. package/dist/sandbox/session-sandbox.js +89 -28
  65. package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
  66. package/dist/store/postgres/drizzle/0017_wake_outbox.sql +9 -0
  67. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  68. package/dist/store/postgres/schema.d.ts +105 -261
  69. package/dist/store/postgres/schema.js +18 -36
  70. package/dist/store/postgres/store.js +54 -21
  71. package/dist/store/postgres/tenant.d.ts +14 -0
  72. package/dist/store/postgres/tenant.js +22 -2
  73. package/dist/store/types.d.ts +48 -13
  74. package/dist/tenant/advance.js +1 -4
  75. package/dist/tenant/artifact-tool.d.ts +9 -1
  76. package/dist/tenant/artifact-tool.js +113 -9
  77. package/dist/tenant/commands.js +5 -5
  78. package/dist/tenant/context.d.ts +15 -13
  79. package/dist/tenant/effects.d.ts +2 -2
  80. package/dist/tenant/effects.js +20 -5
  81. package/dist/tenant/ephemeral.d.ts +0 -6
  82. package/dist/tenant/ephemeral.js +0 -1
  83. package/dist/tenant/outbound.d.ts +14 -5
  84. package/dist/tenant/outbound.js +21 -9
  85. package/dist/tenant/runtime.js +11 -15
  86. package/dist/tenant/sessions.js +20 -3
  87. package/dist/tenant/store-pg.js +9 -0
  88. package/dist/tenant/sweep.d.ts +14 -0
  89. package/dist/tenant/sweep.js +69 -10
  90. package/dist/tenant/tool-results.d.ts +57 -0
  91. package/dist/tenant/tool-results.js +298 -0
  92. package/dist/tenant/tool-search.d.ts +43 -0
  93. package/dist/tenant/tool-search.js +100 -0
  94. package/dist/tenant/transcript.d.ts +108 -0
  95. package/dist/tenant/transcript.js +13 -0
  96. package/dist/tenant/types.d.ts +0 -14
  97. package/dist/vault/crypto.d.ts +0 -7
  98. package/dist/vault/crypto.js +0 -16
  99. package/dist/vault/headers.d.ts +17 -0
  100. package/dist/vault/headers.js +61 -0
  101. package/dist/vault/service.d.ts +11 -68
  102. package/dist/vault/service.js +83 -438
  103. package/dist/vault/sources.d.ts +20 -48
  104. package/dist/vault/sources.js +25 -189
  105. package/dist/version.d.ts +1 -1
  106. package/dist/version.js +1 -1
  107. package/package.json +3 -3
  108. package/dist/vault/oauth.d.ts +0 -55
  109. package/dist/vault/oauth.js +0 -186
@@ -5,7 +5,6 @@ import { tenantModelGate } from "../gates/in-process.js";
5
5
  import { createKekFile, readVaultKek } from "../vault/kek.js";
6
6
  import { SigningKeys } from "./signing-keys.js";
7
7
  import { VaultService } from "../vault/service.js";
8
- import { CredentialSources } from "../vault/sources.js";
9
8
  import { McpPool } from "../mcp/pool.js";
10
9
  import { SandboxManager } from "../sandbox/manager.js";
11
10
  import { storeSandboxRecords } from "../sandbox/records.js";
@@ -22,7 +21,6 @@ import { sweep } from "./sweep.js";
22
21
  import { TenantWorkers, WORKER_ID, } from "./worker.js";
23
22
  import { authorize } from "./effects.js";
24
23
  import { inProcessToolGate } from "../gates/tool-gate.js";
25
- import { guardedFetch } from "./outbound.js";
26
24
  import { inProcessKeys } from "../keys/keys.js";
27
25
  import { tenantApi } from "../api/http/app.js";
28
26
  import { createFsBlobStore } from "../blob/index.js";
@@ -118,16 +116,7 @@ export class TenantRuntime {
118
116
  const opened = store;
119
117
  // A seeded Tenant setting wins over the Host default (A18).
120
118
  const seededBackend = await opened.tx((t) => t.getSetting("sandbox.backend"));
121
- const vault = new VaultService({
122
- store: opened,
123
- kek: ensureKek,
124
- // OAuth refresh and connect follow the Host's address policy (F9 C2).
125
- fetch: config.vaultFetch ?? guardedFetch(config.delivery ?? {}),
126
- });
127
- const credentials = new CredentialSources({
128
- vault,
129
- ...(config.resolver ? { resolver: config.resolver } : {}),
130
- });
119
+ const vault = new VaultService({ store: opened, kek: ensureKek });
131
120
  // `ctx` is assigned below; these callbacks only run once the Tenant is open.
132
121
  let ctx;
133
122
  const toolGate = hooks.toolGate ??
@@ -137,7 +126,14 @@ export class TenantRuntime {
137
126
  logger: config.logger,
138
127
  });
139
128
  const signingKeys = new SigningKeys({ tenantId: config.tenantId, kek: ensureKek });
140
- const keys = hooks.keys ?? inProcessKeys({ store: opened, vault, signingKeys, kek: ensureKek });
129
+ const keys = hooks.keys ??
130
+ inProcessKeys({
131
+ store: opened,
132
+ vault,
133
+ signingKeys,
134
+ kek: ensureKek,
135
+ policy: config.delivery ?? {},
136
+ });
141
137
  const harnessMode = hooks.harness ?? "memory";
142
138
  const inProcess = harnessMode === "memory" || harnessMode === "json";
143
139
  const preference = seededBackend ?? config.sandbox.backend;
@@ -242,7 +238,6 @@ export class TenantRuntime {
242
238
  reads: hooks.reads,
243
239
  store: opened,
244
240
  vault,
245
- credentials,
246
241
  ...(mcp ? { mcp } : {}),
247
242
  sandbox,
248
243
  flowLimits,
@@ -266,8 +261,9 @@ export class TenantRuntime {
266
261
  ownerLeaseMs: config.ownerLeaseMs ?? DEFAULT_OWNER_LEASE_MS,
267
262
  wake: async (sessionId, wake) => {
268
263
  if (ctx.closing || ctx.closed)
269
- return;
264
+ return false;
270
265
  await execution.wake(config.tenantId, sessionId, wake);
266
+ return true;
271
267
  },
272
268
  abortLocal: (sessionId, turnId) => abortLocal(ctx, sessionId, "cancel", turnId),
273
269
  ...(hooks.pods ? { pods: hooks.pods } : {}),
@@ -10,7 +10,7 @@ import { aggregateWaits, isWorkflowManifest } from "../core/flow-host.js";
10
10
  import { canonical } from "../store/canonical.js";
11
11
  import { validateSandboxAttach } from "../core/sandbox-routes.js";
12
12
  import { resolveSandbox } from "../sandbox/resolve.js";
13
- import { withSandboxCapability } from "../sandbox/session-sandbox.js";
13
+ import { withPlatformTools, withSandboxCapability } from "../sandbox/session-sandbox.js";
14
14
  import { declaredSandboxes, sandboxSpecOf, sessionSandboxSpec, } from "../sandbox/share.js";
15
15
  import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
16
16
  import { checkPlacement, requirePods } from "../sandbox/placement.js";
@@ -156,12 +156,14 @@ export async function putSession(ctx, id, body, access, options = {}) {
156
156
  checkPlacement(await readSandboxConfig(t), sandbox.kind);
157
157
  if (sandbox.kind === "pod")
158
158
  await requirePods(ctx.pods, `Sandbox ${sandbox.sandboxId}`);
159
+ const pinned = sandbox.manifest === undefined ? platformTools(definition) : sandbox;
159
160
  const created = {
160
161
  id,
161
162
  agentId: body.agentId,
162
163
  ownerUserId: body.ownerUserId,
163
- manifest: sandbox.manifest ?? definition.manifest,
164
- manifestHash: sandbox.manifestHash ?? definition.manifestHash,
164
+ manifest: pinned.manifest ?? definition.manifest,
165
+ manifestHash: pinned.manifestHash ?? definition.manifestHash,
166
+ definitionHash: definition.manifestHash,
165
167
  implementationVersion: definition.implementationVersion,
166
168
  info: body.info,
167
169
  status: "idle",
@@ -232,6 +234,20 @@ async function sessionSandbox(t, body, definition, options) {
232
234
  return fail(resolved.status, resolved.errors.join(" "));
233
235
  return pin(definition, resolved.spec, resolved.source);
234
236
  }
237
+ /**
238
+ * The manifest a session without a sandbox pins: its agents' `read_artifact` (R2b C11), when
239
+ * one has a remote MCP server or an HTTP tool; nothing otherwise, and for a workflow.
240
+ */
241
+ function platformTools(definition) {
242
+ if (isWorkflowManifest(definition.manifest))
243
+ return {};
244
+ const pinned = withPlatformTools(definition.manifest);
245
+ if (pinned === undefined)
246
+ return {};
247
+ if (!pinned.ok)
248
+ return fail(400, pinned.message);
249
+ return { manifest: pinned.manifest, manifestHash: pinned.manifestHash };
250
+ }
235
251
  async function pin(definition, spec, source) {
236
252
  // A workflow's manifest stays as registered: its agents inherit the sandbox when they start.
237
253
  if (isWorkflowManifest(definition.manifest))
@@ -269,6 +285,7 @@ export async function sessionView(t, s) {
269
285
  agentId: s.agentId,
270
286
  ownerUserId: s.ownerUserId,
271
287
  manifestHash: s.manifestHash,
288
+ ...(s.definitionHash === undefined ? {} : { definitionHash: s.definitionHash }),
272
289
  implementationVersion: s.implementationVersion,
273
290
  status: s.status,
274
291
  activeTurnId: s.activeTurnId,
@@ -55,6 +55,15 @@ export function createPostgresTenantOpener(options) {
55
55
  from: opened.migrated.from,
56
56
  to: opened.migrated.to,
57
57
  });
58
+ // Protocol 10 removed the vault's `oauth` credential type: the migration deleted them.
59
+ for (const removed of opened.removedCredentials)
60
+ logger?.warn("oauth_credential_removed", {
61
+ tenantId,
62
+ vaultId: removed.vaultId,
63
+ credentialId: removed.credentialId,
64
+ url: removed.url,
65
+ message: "MCP OAuth credentials are gone (protocol 10): store a key for this server in a vault, or reach it through a gateway",
66
+ });
58
67
  let reads;
59
68
  try {
60
69
  reads = createPostgresReadStore(options.sql, tenantId);
@@ -1,5 +1,19 @@
1
1
  import type { TenantContext } from "./context.js";
2
+ /**
3
+ * How old a wake outbox row is before the sweep delivers it: longer than a delivery after
4
+ * commit takes, so the sweep seldom sends a wake its commit is still sending. Sending one
5
+ * twice is harmless either way (the idempotency key).
6
+ */
7
+ export declare const WAKE_GRACE_MS = 2000;
8
+ /** How often the orphan scan runs after the first pass of a Tenant opened on this process. */
9
+ export declare const ORPHAN_SCAN_MS = 60000;
2
10
  export declare function sweep(ctx: TenantContext): Promise<void>;
11
+ /**
12
+ * Deliver the wake outbox rows written before `before` (oldest first, one batch) and delete
13
+ * the delivered ones. A row the Tenant declines (it is closing) stays. Returns the ids of the
14
+ * rows delivered.
15
+ */
16
+ export declare function deliverPendingWakes(ctx: Pick<TenantContext, "store" | "wake">, before?: Date): Promise<string[]>;
3
17
  /** Settle pending workflow `agent` effects whose linked turn already finished. */
4
18
  export declare function reconcileLinkedAgents(ctx: Pick<TenantContext, "store" | "wake">): Promise<void>;
5
19
  /** Wake `running`/`runnable` sessions without a live owner. Returns the ids woken. */
@@ -1,21 +1,32 @@
1
1
  /**
2
2
  * The Tenant sweep (architecture §12.3, "Timers"): one self-re-arming pass per Tenant, armed
3
3
  * with `DurableExecution.armSweep` when the Tenant opens. It replaces every in-process timer
4
- * and startup scan the Tenant runtime used to keep:
4
+ * and startup scan the Tenant runtime used to keep. It reconciles the Session Store with what
5
+ * Durable Session Execution was asked to do; it never decides when a session advances:
5
6
  *
6
- * 1. **Linked agents.** Pending workflow `agent` effects whose linked turn already settled
7
+ * 1. **Wakes.** Wake outbox rows (`Tx.wake`) are delivered again and deleted: a delivery
8
+ * after commit that failed, or that a crash between the commit and the send cut off. The
9
+ * first pass on this process takes every row, so a previous process's are not left
10
+ * waiting; later passes take the rows older than `WAKE_GRACE_MS`. Each row carries the
11
+ * idempotency key it was first sent with, so a wake the execution did accept before the
12
+ * crash, or one still being sent after its commit, causes no second advance.
13
+ * 2. **Linked agents.** Pending workflow `agent` effects whose linked turn already settled
7
14
  * are completed and the workflow is woken (a crash between the two, or data written by an
8
- * older Runtime). One transaction per effect, child session locked before its workflow.
9
- * 2. **Orphaned sessions.** `running` or `runnable` sessions with no owner, or an owner whose
10
- * lease expired, are woken with reason `recover`: a wake lost between commit and send, or
11
- * a Worker that died mid-advance (the advance then takes over, §11.4).
12
- * 3. **Sandboxes.** Idle sandboxes are stopped, records of compute no longer held are marked
15
+ * older Runtime).
16
+ * 3. **Orphaned sessions.** On this process's first pass for the Tenant, then at most every
17
+ * `ORPHAN_SCAN_MS`: `running` or `runnable` sessions with no owner, or an owner whose lease
18
+ * expired, are woken with reason `recover`. Neither a lost wake (step 1) nor a Worker that
19
+ * died mid-advance (the execution retries that advance, which takes over, §11.4) needs it:
20
+ * it is the backstop for what the execution itself lost or ended without an advance, such
21
+ * as an in-process execution's queue when its process stopped, an advance for a Tenant not
22
+ * open on its Worker, an invocation an operator killed, or Restate's state wiped (§17.11).
23
+ * 4. **Sandboxes.** Idle sandboxes are stopped, records of compute no longer held are marked
13
24
  * stopped, and sandboxes whose session or sandbox resource is gone are removed, by the
14
25
  * workspace capability (`harness-api/workspace.ts`: here, or in the harness that serves
15
26
  * workspaces). Idle MCP connections of the in-process harness are closed.
16
- * 4. **Control signals.** Signals on the control bus older than `SIGNAL_RETENTION_MS` are
27
+ * 5. **Control signals.** Signals on the control bus older than `SIGNAL_RETENTION_MS` are
17
28
  * deleted: followers read back only the last two minutes (`store/postgres/control.ts`).
18
- * 5. **Hooks.** Callbacks registered with `ctx.onSweep`.
29
+ * 6. **Hooks.** Callbacks registered with `ctx.onSweep`.
19
30
  *
20
31
  * Every step runs one transaction per session it changes (a linked agent and its workflow
21
32
  * share one, child first), so the sweep follows the lock order in `store/types.ts`. A failing
@@ -26,13 +37,41 @@ import { pendingAgentEffects, reconcilePendingAgentEffect, } from "../core/flow-
26
37
  const BATCH = 100;
27
38
  /** How long a control signal is kept after it was written. */
28
39
  const SIGNAL_RETENTION_MS = 60 * 60 * 1000;
40
+ /**
41
+ * How old a wake outbox row is before the sweep delivers it: longer than a delivery after
42
+ * commit takes, so the sweep seldom sends a wake its commit is still sending. Sending one
43
+ * twice is harmless either way (the idempotency key).
44
+ */
45
+ export const WAKE_GRACE_MS = 2000;
46
+ /** How often the orphan scan runs after the first pass of a Tenant opened on this process. */
47
+ export const ORPHAN_SCAN_MS = 60_000;
48
+ /** Each open Tenant's sweep state on this process: when its orphan scan last ran. */
49
+ const passes = new WeakMap();
29
50
  export async function sweep(ctx) {
30
51
  if (ctx.closing || ctx.closed)
31
52
  return;
32
53
  const now = new Date();
54
+ const first = !passes.has(ctx);
55
+ const pass = passes.get(ctx) ?? {};
56
+ passes.set(ctx, pass);
57
+ const scanOrphans = pass.orphansAt === undefined || now.getTime() - pass.orphansAt >= ORPHAN_SCAN_MS;
33
58
  const steps = [
59
+ [
60
+ "wakes",
61
+ () => deliverPendingWakes(ctx, first ? now : new Date(now.getTime() - WAKE_GRACE_MS)),
62
+ ],
34
63
  ["linked", () => reconcileLinkedAgents(ctx)],
35
- ["orphans", () => wakeOrphanedSessions(ctx, now)],
64
+ ...(scanOrphans
65
+ ? [
66
+ [
67
+ "orphans",
68
+ async () => {
69
+ await wakeOrphanedSessions(ctx, now);
70
+ pass.orphansAt = now.getTime();
71
+ },
72
+ ],
73
+ ]
74
+ : []),
36
75
  [
37
76
  "sandboxes",
38
77
  () => ctx.sandbox.sweep({
@@ -68,6 +107,26 @@ export async function sweep(ctx) {
68
107
  if (failure)
69
108
  throw failure.error;
70
109
  }
110
+ /**
111
+ * Deliver the wake outbox rows written before `before` (oldest first, one batch) and delete
112
+ * the delivered ones. A row the Tenant declines (it is closing) stays. Returns the ids of the
113
+ * rows delivered.
114
+ */
115
+ export async function deliverPendingWakes(ctx, before = new Date()) {
116
+ const pending = await ctx.store.tx((t) => t.pendingWakes(before, BATCH));
117
+ const delivered = [];
118
+ try {
119
+ for (const row of pending)
120
+ if ((await ctx.wake(row.sessionId, row.wake)) !== false)
121
+ delivered.push(row.id);
122
+ }
123
+ finally {
124
+ // Delivered ones go even when a later one fails, so they are not sent again.
125
+ if (delivered.length > 0)
126
+ await ctx.store.tx((t) => t.deleteWakes(delivered));
127
+ }
128
+ return delivered;
129
+ }
71
130
  /** Settle pending workflow `agent` effects whose linked turn already finished. */
72
131
  export async function reconcileLinkedAgents(ctx) {
73
132
  const { store } = ctx;
@@ -0,0 +1,57 @@
1
+ import type { HostEffect } from "@nylorun/harness/run";
2
+ import type { TenantContext } from "./context.js";
3
+ /** The most of one tool result that is recorded inline (Q22): 32 KiB, as JSON. */
4
+ export declare const TOOL_RESULT_INLINE_BYTES: number;
5
+ /** How much of a stored result's start, and of its end, the preview keeps. */
6
+ export declare const PREVIEW_HEAD_BYTES: number;
7
+ export declare const PREVIEW_TAIL_BYTES = 1024;
8
+ /**
9
+ * The most one step's results may hold together: a step's results are one transcript entry, so
10
+ * one event, which must stay well under S2's 1 MiB record.
11
+ */
12
+ export declare const STEP_RESULTS_BUDGET_BYTES: number;
13
+ /** A result's cap once its step's budget is spent: a stub naming its artifact. */
14
+ export declare const TOOL_RESULT_STUB_BYTES = 512;
15
+ /** A result this small is never shaped, nor counted against its step's budget. */
16
+ export declare const TOOL_RESULT_SMALL_BYTES = 1024;
17
+ /** The call whose outcome is shaped, and the session its artifacts belong to. */
18
+ export interface ShapedCall {
19
+ readonly request: HostEffect;
20
+ readonly session: {
21
+ readonly id: string;
22
+ readonly ownerUserId: string;
23
+ };
24
+ /** A remote MCP tool's: its output may be the server's content parts. */
25
+ readonly mcp: boolean;
26
+ /** This result's cap (`resultLimit`). Default `TOOL_RESULT_INLINE_BYTES`. */
27
+ readonly limit?: number;
28
+ }
29
+ /** A result's cap when the other results of its step already took `used` bytes. */
30
+ export declare function resultLimit(used: number): number;
31
+ /** The JSON size of `value` in bytes: what it adds to an event or a prompt. */
32
+ export declare function jsonBytes(value: unknown): number;
33
+ /**
34
+ * What a tool outcome's result weighs in its step's transcript entry: a completed one's output,
35
+ * a failed one's message; 0 for anything else, such as a model call's outcome.
36
+ */
37
+ export declare function resultBytes(value: unknown): number;
38
+ /**
39
+ * False for an outcome `shapeToolOutcome` returns as it is, whatever made it: a model call's, a
40
+ * small tool result with no parts. Spares a look at the effect for most outcomes.
41
+ */
42
+ export declare function mayNeedShaping(value: unknown): boolean;
43
+ /**
44
+ * The outcome `value` of `call`, shaped to fit its cap (see the module comment). An outcome that
45
+ * fits and has no binary part comes back as it is.
46
+ */
47
+ export declare function shapeToolOutcome(ctx: TenantContext, call: ShapedCall, value: unknown, signal: AbortSignal): Promise<unknown>;
48
+ /**
49
+ * The start and end of `text` in whole characters, `budget` bytes split four to one: by default
50
+ * the first `PREVIEW_HEAD_BYTES` and the last `PREVIEW_TAIL_BYTES`.
51
+ */
52
+ export declare function previewOf(text: string, budget?: number): string;
53
+ /**
54
+ * The nearest character boundary of the UTF-8 `bytes` at or before (`down`) or at or after
55
+ * (`up`) byte `at`: never inside a character.
56
+ */
57
+ export declare function utf8Boundary(bytes: Uint8Array, at: number, direction: "down" | "up"): number;
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Tool results that fit (R2b C11, Q22–Q24): core shapes what an agent's remote MCP tool or HTTP
3
+ * tool returned before it records the outcome (`harness-api/record.ts`), so no journal row,
4
+ * event or prompt holds more of one result than the inline cap. The gate already refused an
5
+ * answer past 8 MiB (`mcp.too-large`, `http.too-large`).
6
+ *
7
+ * - An `image`, `audio` or blob `resource` part becomes a file artifact of the session, and the
8
+ * part says `{artifactId, version, contentType, size}`. An image also goes to the model as a
9
+ * file (`ToolOutcome.files`), which the model call shows to a model that reads images (Q23).
10
+ * - Text or JSON past `TOOL_RESULT_INLINE_BYTES` (32 KiB, Q22) becomes a file artifact, and the
11
+ * model gets `{truncated: true, artifactId, size, preview}`: the first 4 KiB and the last 1 KiB.
12
+ * In a result of several parts each part is shaped alone, the largest text first, until the
13
+ * whole fits; one that still does not is stored whole.
14
+ * - A `resource_link` stays a link, never fetched, and a text resource that fits stays inline.
15
+ * - A failed outcome's message is cut to its start and end.
16
+ * - When an artifact cannot be stored (the Tenant's artifact limits, an Object store that
17
+ * fails), the preview stays and `dropped` says why the rest is gone: a result never passes
18
+ * the cap. Only an abort throws.
19
+ * - **One step's results share a budget** (`STEP_RESULTS_BUDGET_BYTES`, 256 KiB), since the
20
+ * transcript keeps a step's results in one entry, one event: the record seam counts what the
21
+ * step's other results took, under the session lock, and gives this one the rest as its cap
22
+ * (`resultLimit`). Once the budget is spent, a result past `TOOL_RESULT_SMALL_BYTES` becomes a
23
+ * stub naming its artifact, its preview cut to fit `TOOL_RESULT_STUB_BYTES`.
24
+ *
25
+ * The model reads a stored result with `read_artifact` (`artifact-tool.ts`). Artifacts are the
26
+ * session's, saved by our engine (`source: "engine"`), with the turn and the tool call on their
27
+ * `artifact.created` event.
28
+ */
29
+ import { READ_ARTIFACT_MAX_BYTES } from "@nylorun/core/define";
30
+ import { MODEL_FILE_MAX_BYTES, extensionFor, isModelImage, isText, normalizeContentType, } from "../artifacts/media-types.js";
31
+ import { uploadArtifact } from "../artifacts/service.js";
32
+ import { DEFAULT_CONTENT_TYPE } from "../blob/index.js";
33
+ import { HttpError } from "./http.js";
34
+ import { toolIds } from "./transcript.js";
35
+ /** The most of one tool result that is recorded inline (Q22): 32 KiB, as JSON. */
36
+ export const TOOL_RESULT_INLINE_BYTES = READ_ARTIFACT_MAX_BYTES;
37
+ /** How much of a stored result's start, and of its end, the preview keeps. */
38
+ export const PREVIEW_HEAD_BYTES = 4 * 1024;
39
+ export const PREVIEW_TAIL_BYTES = 1024;
40
+ /**
41
+ * The most one step's results may hold together: a step's results are one transcript entry, so
42
+ * one event, which must stay well under S2's 1 MiB record.
43
+ */
44
+ export const STEP_RESULTS_BUDGET_BYTES = 256 * 1024;
45
+ /** A result's cap once its step's budget is spent: a stub naming its artifact. */
46
+ export const TOOL_RESULT_STUB_BYTES = 512;
47
+ /** A result this small is never shaped, nor counted against its step's budget. */
48
+ export const TOOL_RESULT_SMALL_BYTES = 1024;
49
+ /** A result's cap when the other results of its step already took `used` bytes. */
50
+ export function resultLimit(used) {
51
+ return Math.max(TOOL_RESULT_STUB_BYTES, Math.min(TOOL_RESULT_INLINE_BYTES, STEP_RESULTS_BUDGET_BYTES - used));
52
+ }
53
+ /** The JSON size of `value` in bytes: what it adds to an event or a prompt. */
54
+ export function jsonBytes(value) {
55
+ return Buffer.byteLength(JSON.stringify(value) ?? "null");
56
+ }
57
+ /**
58
+ * What a tool outcome's result weighs in its step's transcript entry: a completed one's output,
59
+ * a failed one's message; 0 for anything else, such as a model call's outcome.
60
+ */
61
+ export function resultBytes(value) {
62
+ if (!value || typeof value !== "object" || Array.isArray(value))
63
+ return 0;
64
+ const outcome = value;
65
+ if (outcome.kind === "failed")
66
+ return typeof outcome.message === "string" ? Buffer.byteLength(outcome.message) : 0;
67
+ if (outcome.kind !== "completed" || outcome.output === undefined)
68
+ return 0;
69
+ return jsonBytes(outcome.output);
70
+ }
71
+ /**
72
+ * False for an outcome `shapeToolOutcome` returns as it is, whatever made it: a model call's, a
73
+ * small tool result with no parts. Spares a look at the effect for most outcomes.
74
+ */
75
+ export function mayNeedShaping(value) {
76
+ const output = value?.output;
77
+ if (value?.kind === "completed" && Array.isArray(output))
78
+ return true;
79
+ return resultBytes(value) > TOOL_RESULT_SMALL_BYTES;
80
+ }
81
+ /**
82
+ * The outcome `value` of `call`, shaped to fit its cap (see the module comment). An outcome that
83
+ * fits and has no binary part comes back as it is.
84
+ */
85
+ export async function shapeToolOutcome(ctx, call, value, signal) {
86
+ if (!value || typeof value !== "object" || Array.isArray(value))
87
+ return value;
88
+ const outcome = value;
89
+ const limit = call.limit ?? TOOL_RESULT_INLINE_BYTES;
90
+ if (outcome.kind === "failed" && typeof outcome.message === "string")
91
+ return Buffer.byteLength(outcome.message) > limit
92
+ ? { ...outcome, message: cutMessage(outcome.message, limit) }
93
+ : value;
94
+ if (outcome.kind !== "completed" || outcome.output === undefined)
95
+ return value;
96
+ const shaping = { ...call, limit, ctx, signal, files: [] };
97
+ let output = outcome.output;
98
+ if (call.mcp && isContentParts(output))
99
+ output = await shapeParts(shaping, output);
100
+ let truncated = false;
101
+ if (jsonBytes(output) > limit) {
102
+ output = await storedWhole(shaping, output);
103
+ truncated = true;
104
+ }
105
+ if (output === outcome.output)
106
+ return value;
107
+ return {
108
+ ...outcome,
109
+ output,
110
+ ...(shaping.files.length > 0 ? { files: shaping.files } : {}),
111
+ ...(truncated ? { truncated: true } : {}),
112
+ };
113
+ }
114
+ /** True for an MCP `content` array: objects with a `type` each. */
115
+ function isContentParts(value) {
116
+ return (Array.isArray(value) &&
117
+ value.length > 0 &&
118
+ value.every((part) => !!part &&
119
+ typeof part === "object" &&
120
+ !Array.isArray(part) &&
121
+ typeof part.type === "string"));
122
+ }
123
+ /** An MCP result's parts: binary ones stored, then the largest text ones until the whole fits. */
124
+ async function shapeParts(shaping, parts) {
125
+ const shaped = [];
126
+ for (const [index, part] of parts.entries())
127
+ shaped.push(await shapeBinary(shaping, part, index));
128
+ const texts = shaped
129
+ .map((part, index) => ({ index, bytes: textOf(part) === undefined ? 0 : jsonBytes(part) }))
130
+ .filter((item) => item.bytes > 0)
131
+ .sort((a, b) => b.bytes - a.bytes);
132
+ for (const { index } of texts) {
133
+ if (jsonBytes(shaped) <= shaping.limit)
134
+ break;
135
+ const part = shaped[index];
136
+ const text = textOf(part);
137
+ const resource = part.type === "resource" ? part.resource : undefined;
138
+ const contentType = resource ? textType(resource.mimeType) : "text/plain; charset=utf-8";
139
+ const kept = await store(shaping, text, contentType, nameOf(shaping, contentType, index));
140
+ shaped[index] = withPreview({
141
+ type: part.type,
142
+ ...(resource && typeof resource.uri === "string" ? { uri: resource.uri } : {}),
143
+ truncated: true,
144
+ ...kept,
145
+ }, text, shaping.limit);
146
+ }
147
+ return shaped;
148
+ }
149
+ /** An `image`, `audio` or blob `resource` part, stored; any other part as it is. */
150
+ async function shapeBinary(shaping, part, index) {
151
+ const resource = part.type === "resource" && part.resource && typeof part.resource === "object"
152
+ ? part.resource
153
+ : undefined;
154
+ const data = part.type === "image" || part.type === "audio"
155
+ ? part.data
156
+ : resource !== undefined
157
+ ? resource.blob
158
+ : undefined;
159
+ if (typeof data !== "string")
160
+ return part;
161
+ const declared = resource ? resource.mimeType : part.mimeType;
162
+ const contentType = (typeof declared === "string" ? normalizeContentType(declared) : undefined) ??
163
+ DEFAULT_CONTENT_TYPE;
164
+ const name = nameOf(shaping, contentType, index);
165
+ const kept = await store(shaping, Buffer.from(data, "base64"), contentType, name);
166
+ // An image the model reads goes to it as a file, beside the part (Q23).
167
+ if (part.type === "image" &&
168
+ "artifactId" in kept &&
169
+ isModelImage(contentType) &&
170
+ kept.size <= MODEL_FILE_MAX_BYTES)
171
+ shaping.files.push({
172
+ mediaType: contentType,
173
+ reference: { artifactId: kept.artifactId, version: kept.version, name },
174
+ });
175
+ return {
176
+ type: part.type,
177
+ ...(resource && typeof resource.uri === "string" ? { uri: resource.uri } : {}),
178
+ ...("artifactId" in kept ? kept : { contentType, ...kept }),
179
+ };
180
+ }
181
+ /** The text a part carries inline: a text part's, or a text resource's. */
182
+ function textOf(part) {
183
+ if (part.type === "text" && typeof part.text === "string")
184
+ return part.text;
185
+ if (part.type === "resource" && part.resource && typeof part.resource === "object") {
186
+ const text = part.resource.text;
187
+ if (typeof text === "string")
188
+ return text;
189
+ }
190
+ return undefined;
191
+ }
192
+ function textType(declared) {
193
+ const type = typeof declared === "string" ? normalizeContentType(declared) : undefined;
194
+ return type !== undefined && isText(type) ? type : "text/plain; charset=utf-8";
195
+ }
196
+ /** `value` stored whole, as text or JSON, and the preview the model gets. */
197
+ async function storedWhole(shaping, value) {
198
+ const text = typeof value === "string" ? value : JSON.stringify(value);
199
+ const contentType = typeof value === "string" ? "text/plain; charset=utf-8" : "application/json";
200
+ const kept = await store(shaping, text, contentType, nameOf(shaping, contentType));
201
+ return withPreview({ truncated: true, ...kept }, text, shaping.limit);
202
+ }
203
+ /**
204
+ * `stub` with the preview of `text`: the first 4 KiB and the last 1 KiB, or less, so that the
205
+ * whole fits `limit` (a step's budget spent, R2b C11); none when even a little does not.
206
+ */
207
+ function withPreview(stub, text, limit) {
208
+ let budget = Math.min(PREVIEW_HEAD_BYTES + PREVIEW_TAIL_BYTES, limit);
209
+ for (;;) {
210
+ const shaped = { ...stub, preview: previewOf(text, budget) };
211
+ const over = jsonBytes(shaped) - limit;
212
+ if (over <= 0 || budget === 0)
213
+ return shaped;
214
+ budget = budget - over < 64 ? 0 : budget - over;
215
+ }
216
+ }
217
+ /** Stores `body` as an artifact of the call's session, or says why it was not. */
218
+ async function store(shaping, body, contentType, name) {
219
+ const bytes = typeof body === "string" ? Buffer.from(body, "utf8") : body;
220
+ const { ctx, request, session, signal } = shaping;
221
+ const ids = toolIds(request.context);
222
+ try {
223
+ const saved = await uploadArtifact(ctx, {
224
+ name,
225
+ contentType,
226
+ sessionId: session.id,
227
+ source: "engine",
228
+ turnId: request.turnId,
229
+ ...("callId" in ids ? { callId: ids.callId } : {}),
230
+ }, bytes,
231
+ // Into the call's own session only.
232
+ { owner: session.ownerUserId }, signal);
233
+ return {
234
+ artifactId: saved.artifact.artifactId,
235
+ version: saved.version.version,
236
+ contentType: saved.version.contentType,
237
+ size: saved.version.size,
238
+ };
239
+ }
240
+ catch (error) {
241
+ if (signal.aborted)
242
+ throw error;
243
+ // A limit is the Tenant's to raise; any other failure is the store's. Never the bytes.
244
+ ctx.config.logger.warn("tool_result_not_stored", {
245
+ session: session.id,
246
+ effect: request.effectId,
247
+ error: error instanceof Error ? error.message : String(error),
248
+ });
249
+ return {
250
+ dropped: error instanceof HttpError
251
+ ? `The result could not be stored as an artifact: ${error.message}`
252
+ : "The result could not be stored as an artifact",
253
+ size: bytes.byteLength,
254
+ };
255
+ }
256
+ }
257
+ /** A name for a stored result: the tool's, with the part's number and the type's extension. */
258
+ function nameOf(shaping, contentType, index) {
259
+ const tool = (shaping.request.toolName ?? "tool").replace(/[^A-Za-z0-9_.-]/gu, "_");
260
+ const extension = extensionFor(contentType) ?? "bin";
261
+ return `${tool.slice(0, 200)}-result${index === undefined ? "" : `-${index + 1}`}.${extension}`;
262
+ }
263
+ /**
264
+ * The start and end of `text` in whole characters, `budget` bytes split four to one: by default
265
+ * the first `PREVIEW_HEAD_BYTES` and the last `PREVIEW_TAIL_BYTES`.
266
+ */
267
+ export function previewOf(text, budget = PREVIEW_HEAD_BYTES + PREVIEW_TAIL_BYTES) {
268
+ const bytes = Buffer.from(text, "utf8");
269
+ if (bytes.byteLength <= budget)
270
+ return text;
271
+ if (budget <= 0)
272
+ return "";
273
+ const tailBytes = Math.floor(budget / 5);
274
+ const head = utf8Boundary(bytes, budget - tailBytes, "down");
275
+ const tail = utf8Boundary(bytes, bytes.byteLength - tailBytes, "up");
276
+ return `${bytes.subarray(0, head).toString("utf8")}\n…\n${bytes.subarray(tail).toString("utf8")}`;
277
+ }
278
+ /** A message past `limit`: its start and end, and how long it was. */
279
+ function cutMessage(message, limit) {
280
+ const note = `\n(The message was ${Buffer.byteLength(message)} bytes; its middle is not shown.)`;
281
+ const budget = Math.min(PREVIEW_HEAD_BYTES + PREVIEW_TAIL_BYTES, limit - note.length - 16);
282
+ return `${previewOf(message, Math.max(0, budget))}${note}`;
283
+ }
284
+ /**
285
+ * The nearest character boundary of the UTF-8 `bytes` at or before (`down`) or at or after
286
+ * (`up`) byte `at`: never inside a character.
287
+ */
288
+ export function utf8Boundary(bytes, at, direction) {
289
+ let position = Math.max(0, Math.min(at, bytes.byteLength));
290
+ const inside = (index) => index < bytes.byteLength && (bytes[index] & 0xc0) === 0x80;
291
+ if (direction === "down")
292
+ while (position > 0 && inside(position))
293
+ position -= 1;
294
+ else
295
+ while (inside(position))
296
+ position += 1;
297
+ return position;
298
+ }