@nylorun/runtime 0.22.0-beta → 0.24.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 (273) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +95 -30
  3. package/dist/adapters/execution/restate.d.ts +15 -3
  4. package/dist/adapters/execution/restate.js +78 -84
  5. package/dist/adapters/replication/pgoutput.d.ts +2 -0
  6. package/dist/adapters/replication/pgoutput.js +6 -3
  7. package/dist/adapters/sandbox/local.d.ts +9 -1
  8. package/dist/adapters/sandbox/local.js +16 -4
  9. package/dist/adapters/sandbox/virtual.js +4 -1
  10. package/dist/adapters/streams/s2.js +1 -13
  11. package/dist/api/a2a/endpoint.js +5 -1
  12. package/dist/api/a2a/routes.d.ts +2 -0
  13. package/dist/api/a2a/routes.js +2 -2
  14. package/dist/api/a2a/tasks.js +2 -1
  15. package/dist/api/ag-ui/endpoint.js +4 -2
  16. package/dist/api/ag-ui/history.js +4 -1
  17. package/dist/api/ag-ui/routes.d.ts +4 -1
  18. package/dist/api/ag-ui/routes.js +11 -10
  19. package/dist/api/ag-ui/translate.js +3 -2
  20. package/dist/api/components.d.ts +4953 -1204
  21. package/dist/api/components.js +25 -16
  22. package/dist/api/gate/keys.js +7 -1
  23. package/dist/api/gate/routes.d.ts +6 -0
  24. package/dist/api/gate/routes.js +39 -3
  25. package/dist/api/http/app.js +4 -4
  26. package/dist/api/http/define.d.ts +6 -0
  27. package/dist/api/http/define.js +13 -1
  28. package/dist/api/http/respond.js +1 -1
  29. package/dist/api/http/routes/artifacts.js +35 -8
  30. package/dist/api/http/routes/coverage.d.ts +9 -0
  31. package/dist/api/http/routes/coverage.js +34 -0
  32. package/dist/api/http/routes/environments.d.ts +11 -0
  33. package/dist/api/http/routes/environments.js +283 -0
  34. package/dist/api/http/routes/mcp.d.ts +1 -1
  35. package/dist/api/http/routes/mcp.js +2 -2
  36. package/dist/api/http/routes/me.js +1 -1
  37. package/dist/api/http/routes/reads.d.ts +45 -9
  38. package/dist/api/http/routes/reads.js +54 -5
  39. package/dist/api/http/routes/sessions.js +11 -6
  40. package/dist/api/http/routes/tenant.js +32 -10
  41. package/dist/api/openapi.js +31 -26
  42. package/dist/artifacts/disk.d.ts +59 -0
  43. package/dist/artifacts/disk.js +563 -0
  44. package/dist/artifacts/export.d.ts +1 -1
  45. package/dist/artifacts/export.js +2 -3
  46. package/dist/artifacts/folders.d.ts +6 -3
  47. package/dist/artifacts/folders.js +17 -12
  48. package/dist/artifacts/service.d.ts +2 -1
  49. package/dist/artifacts/service.js +6 -1
  50. package/dist/artifacts/workspace.d.ts +1 -1
  51. package/dist/artifacts/workspace.js +1 -1
  52. package/dist/core/flow-host.d.ts +3 -22
  53. package/dist/core/flow-host.js +11 -37
  54. package/dist/core/provider.d.ts +3 -2
  55. package/dist/core/provider.js +5 -37
  56. package/dist/core/sandbox-routes.d.ts +2 -21
  57. package/dist/core/sandbox-routes.js +10 -43
  58. package/dist/execution/types.d.ts +11 -9
  59. package/dist/execution/types.js +5 -7
  60. package/dist/gates/contract.d.ts +3 -24
  61. package/dist/gates/contract.js +1 -36
  62. package/dist/gates/egress-credentials.d.ts +9 -0
  63. package/dist/gates/egress-credentials.js +11 -4
  64. package/dist/gates/egress.d.ts +28 -1
  65. package/dist/gates/egress.js +115 -10
  66. package/dist/gates/fixture-model.d.ts +28 -0
  67. package/dist/gates/fixture-model.js +92 -0
  68. package/dist/gates/handler.js +4 -4
  69. package/dist/gates/http-tool.d.ts +6 -2
  70. package/dist/gates/http-tool.js +3 -5
  71. package/dist/gates/in-process.d.ts +5 -0
  72. package/dist/gates/in-process.js +12 -1
  73. package/dist/gates/journal.d.ts +30 -0
  74. package/dist/gates/journal.js +52 -0
  75. package/dist/gates/mcp-handler.d.ts +4 -0
  76. package/dist/gates/mcp-handler.js +5 -1
  77. package/dist/gates/meter.d.ts +10 -2
  78. package/dist/gates/meter.js +20 -5
  79. package/dist/gates/policy.d.ts +49 -0
  80. package/dist/gates/policy.js +134 -0
  81. package/dist/gates/tenant-vaults.d.ts +7 -7
  82. package/dist/gates/tenant-vaults.js +5 -5
  83. package/dist/gates/tool-calls.d.ts +10 -5
  84. package/dist/gates/tool-calls.js +28 -10
  85. package/dist/gates/tool-client.d.ts +8 -0
  86. package/dist/gates/tool-client.js +27 -11
  87. package/dist/gates/tool-contract.d.ts +10 -68
  88. package/dist/gates/tool-contract.js +1 -85
  89. package/dist/gates/tool-gate.d.ts +1 -1
  90. package/dist/harness/calls.d.ts +12 -5
  91. package/dist/harness/calls.js +21 -12
  92. package/dist/harness/executors.js +12 -9
  93. package/dist/harness/main.js +18 -5
  94. package/dist/harness/pod.d.ts +34 -0
  95. package/dist/harness/pod.js +41 -0
  96. package/dist/harness/service.d.ts +0 -1
  97. package/dist/harness/service.js +13 -5
  98. package/dist/harness/setup.d.ts +27 -0
  99. package/dist/harness/setup.js +150 -0
  100. package/dist/harness/ws-client.js +3 -2
  101. package/dist/harness/ws-port.d.ts +1 -5
  102. package/dist/harness/ws-port.js +1 -5
  103. package/dist/harness-api/in-process.js +3 -3
  104. package/dist/harness-api/record.d.ts +15 -1
  105. package/dist/harness-api/record.js +174 -44
  106. package/dist/harness-api/redact.d.ts +3 -0
  107. package/dist/harness-api/redact.js +45 -0
  108. package/dist/harness-api/server.d.ts +8 -0
  109. package/dist/harness-api/server.js +93 -13
  110. package/dist/harness-api/start.d.ts +10 -3
  111. package/dist/harness-api/start.js +40 -10
  112. package/dist/harness-api/tool-journal.d.ts +96 -0
  113. package/dist/harness-api/tool-journal.js +102 -0
  114. package/dist/harness-api/tool-status.d.ts +13 -0
  115. package/dist/harness-api/tool-status.js +26 -0
  116. package/dist/harness-api/workspace.d.ts +6 -3
  117. package/dist/harness-api/workspace.js +27 -11
  118. package/dist/harness-api/ws-server.d.ts +11 -3
  119. package/dist/harness-api/ws-server.js +94 -15
  120. package/dist/host/execution.d.ts +8 -6
  121. package/dist/host/gates.d.ts +2 -1
  122. package/dist/host/gates.js +10 -2
  123. package/dist/host/main.js +64 -21
  124. package/dist/host/operate.js +67 -5
  125. package/dist/host/stack-config.d.ts +70 -0
  126. package/dist/host/stack-config.js +67 -1
  127. package/dist/infra/execution.d.ts +18 -1
  128. package/dist/infra/execution.js +28 -4
  129. package/dist/infra/index.d.ts +1 -1
  130. package/dist/keys/client.js +4 -1
  131. package/dist/keys/keys.d.ts +5 -5
  132. package/dist/keys/keys.js +5 -7
  133. package/dist/management-openapi.json +999 -2211
  134. package/dist/mcp/connect.d.ts +25 -20
  135. package/dist/mcp/connect.js +29 -30
  136. package/dist/mcp/pool.d.ts +2 -2
  137. package/dist/mcp/pool.js +1 -1
  138. package/dist/mcp/preview.d.ts +3 -3
  139. package/dist/mcp/preview.js +10 -20
  140. package/dist/mcp/snapshot.d.ts +5 -11
  141. package/dist/mcp/snapshot.js +6 -7
  142. package/dist/model/pi-model.js +8 -0
  143. package/dist/openapi.json +6955 -2526
  144. package/dist/reads/types.d.ts +16 -3
  145. package/dist/record/append.d.ts +3 -1
  146. package/dist/record/append.js +1 -0
  147. package/dist/record/envelope.d.ts +5 -1
  148. package/dist/record/envelope.js +7 -3
  149. package/dist/record/environment.d.ts +25 -0
  150. package/dist/record/environment.js +44 -0
  151. package/dist/record/index.d.ts +1 -1
  152. package/dist/record/index.js +1 -1
  153. package/dist/retry.d.ts +21 -0
  154. package/dist/retry.js +34 -0
  155. package/dist/sandbox/disk-fs.d.ts +95 -0
  156. package/dist/sandbox/disk-fs.js +206 -0
  157. package/dist/sandbox/join.d.ts +17 -18
  158. package/dist/sandbox/join.js +46 -20
  159. package/dist/sandbox/manager.d.ts +16 -8
  160. package/dist/sandbox/manager.js +33 -17
  161. package/dist/sandbox/placement.d.ts +13 -12
  162. package/dist/sandbox/placement.js +29 -12
  163. package/dist/sandbox/pods/client.d.ts +6 -1
  164. package/dist/sandbox/pods/lifecycle.d.ts +28 -27
  165. package/dist/sandbox/pods/lifecycle.js +14 -26
  166. package/dist/sandbox/pods/reconcile.js +21 -15
  167. package/dist/sandbox/pods/spec.d.ts +15 -13
  168. package/dist/sandbox/pods/spec.js +16 -20
  169. package/dist/sandbox/records.d.ts +2 -2
  170. package/dist/sandbox/records.js +3 -3
  171. package/dist/sandbox/resolve.d.ts +35 -27
  172. package/dist/sandbox/resolve.js +66 -41
  173. package/dist/sandbox/session-sandbox.js +1 -1
  174. package/dist/sandbox/setup.d.ts +61 -0
  175. package/dist/sandbox/setup.js +123 -0
  176. package/dist/sandbox/share.d.ts +11 -21
  177. package/dist/sandbox/share.js +12 -41
  178. package/dist/sandbox/tenant-config.d.ts +12 -14
  179. package/dist/sandbox/tenant-config.js +12 -17
  180. package/dist/sandbox/types.d.ts +6 -0
  181. package/dist/store/postgres/control.js +8 -1
  182. package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
  183. package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
  184. package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
  185. package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
  186. package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
  187. package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
  188. package/dist/store/postgres/drizzle/meta/_journal.json +42 -0
  189. package/dist/store/postgres/reads.js +117 -17
  190. package/dist/store/postgres/record-writer.d.ts +4 -4
  191. package/dist/store/postgres/record-writer.js +9 -9
  192. package/dist/store/postgres/record.d.ts +7 -0
  193. package/dist/store/postgres/record.js +23 -2
  194. package/dist/store/postgres/schema.d.ts +1213 -779
  195. package/dist/store/postgres/schema.js +136 -76
  196. package/dist/store/postgres/store.js +283 -117
  197. package/dist/store/types.d.ts +206 -88
  198. package/dist/streams/relay/core.js +1 -13
  199. package/dist/tenant/advance.d.ts +7 -6
  200. package/dist/tenant/advance.js +121 -49
  201. package/dist/tenant/artifact-tool.js +3 -4
  202. package/dist/tenant/commands.d.ts +15 -2
  203. package/dist/tenant/commands.js +94 -14
  204. package/dist/tenant/context.d.ts +67 -33
  205. package/dist/tenant/context.js +4 -15
  206. package/dist/tenant/control.d.ts +8 -1
  207. package/dist/tenant/control.js +2 -0
  208. package/dist/tenant/effects.d.ts +1 -1
  209. package/dist/tenant/effects.js +37 -20
  210. package/dist/tenant/environments.d.ts +167 -0
  211. package/dist/tenant/environments.js +823 -0
  212. package/dist/tenant/harness-tokens.d.ts +21 -0
  213. package/dist/tenant/harness-tokens.js +61 -0
  214. package/dist/tenant/host-token.d.ts +1 -1
  215. package/dist/tenant/host-token.js +2 -4
  216. package/dist/tenant/identity-file.d.ts +4 -4
  217. package/dist/tenant/identity-file.js +17 -8
  218. package/dist/tenant/issuers.js +3 -3
  219. package/dist/tenant/principals.js +1 -1
  220. package/dist/tenant/reset.js +3 -3
  221. package/dist/tenant/runtime.d.ts +6 -1
  222. package/dist/tenant/runtime.js +30 -4
  223. package/dist/tenant/scheduler.d.ts +7 -0
  224. package/dist/tenant/scheduler.js +38 -7
  225. package/dist/tenant/session-streams.d.ts +0 -1
  226. package/dist/tenant/session-streams.js +1 -13
  227. package/dist/tenant/session.d.ts +3 -3
  228. package/dist/tenant/session.js +0 -14
  229. package/dist/tenant/sessions.d.ts +13 -13
  230. package/dist/tenant/sessions.js +61 -138
  231. package/dist/tenant/signing-keys.d.ts +5 -1
  232. package/dist/tenant/status.js +3 -3
  233. package/dist/tenant/streams.js +14 -29
  234. package/dist/tenant/sweep.d.ts +39 -12
  235. package/dist/tenant/sweep.js +168 -63
  236. package/dist/tenant/transcript.d.ts +210 -20
  237. package/dist/tenant/transcript.js +62 -11
  238. package/dist/tenant/turn-timing.d.ts +43 -0
  239. package/dist/tenant/turn-timing.js +39 -0
  240. package/dist/tenant/types.d.ts +2 -0
  241. package/dist/traces/ids.d.ts +42 -0
  242. package/dist/traces/ids.js +85 -0
  243. package/dist/traces/key.d.ts +6 -0
  244. package/dist/traces/key.js +37 -0
  245. package/dist/traces/map.d.ts +113 -0
  246. package/dist/traces/map.js +317 -0
  247. package/dist/traces/options.d.ts +7 -0
  248. package/dist/traces/options.js +1 -0
  249. package/dist/traces/otlp.d.ts +42 -0
  250. package/dist/traces/otlp.js +84 -0
  251. package/dist/traces/projector.d.ts +58 -0
  252. package/dist/traces/projector.js +200 -0
  253. package/dist/traces/read.d.ts +23 -0
  254. package/dist/traces/read.js +16 -0
  255. package/dist/vault/coverage.d.ts +26 -0
  256. package/dist/vault/coverage.js +64 -0
  257. package/dist/vault/error.d.ts +11 -1
  258. package/dist/vault/error.js +5 -1
  259. package/dist/vault/service.d.ts +47 -48
  260. package/dist/vault/service.js +321 -382
  261. package/dist/vault/sources.d.ts +5 -16
  262. package/dist/vault/sources.js +6 -8
  263. package/dist/version.d.ts +1 -1
  264. package/dist/version.js +2 -2
  265. package/package.json +14 -13
  266. package/dist/api/http/routes/sandboxes.d.ts +0 -9
  267. package/dist/api/http/routes/sandboxes.js +0 -153
  268. package/dist/api/http/routes/vaults.d.ts +0 -10
  269. package/dist/api/http/routes/vaults.js +0 -139
  270. package/dist/record/sandbox.d.ts +0 -25
  271. package/dist/record/sandbox.js +0 -43
  272. package/dist/tenant/sandboxes.d.ts +0 -122
  273. package/dist/tenant/sandboxes.js +0 -562
@@ -0,0 +1,52 @@
1
+ import { mcpToolOf } from "../mcp/snapshot.js";
2
+ import { canonical } from "../store/canonical.js";
3
+ import { httpToolRefOf } from "./http-tool.js";
4
+ export function journalCheck(vaults) {
5
+ return {
6
+ async check(tenantId, claims, key, call) {
7
+ const store = (await vaults.open(tenantId)).store;
8
+ const refusal = await store.tx(async (t) => {
9
+ const row = await t.get("effects", key);
10
+ if (!row)
11
+ return `No journaled call has the effect id '${key}'`;
12
+ const request = row.request;
13
+ if (request.kind !== "tool" ||
14
+ request.sessionId !== claims.sessionId ||
15
+ request.turnId !== claims.turnId)
16
+ return `Effect '${key}' is not a tool call of this run's turn`;
17
+ if (row.status !== "invoking")
18
+ return `Effect '${key}' is not running: it is ${row.status}`;
19
+ if (call.kind === "http") {
20
+ const { sessionId: _journaled, ...expected } = httpToolRefOf(request);
21
+ const { sessionId: _named, ...named } = call.tool;
22
+ if (canonical(expected) !== canonical(named))
23
+ return `Effect '${key}' calls another tool`;
24
+ if (canonical(request.input ?? null) !== canonical(call.input ?? null))
25
+ return `Effect '${key}' was journaled with other input`;
26
+ return held(row);
27
+ }
28
+ const session = await t.get("sessions", request.sessionId);
29
+ const tool = mcpToolOf(session?.mcpSnapshot, request);
30
+ if (!tool ||
31
+ tool.capabilityId !== call.server.capabilityId ||
32
+ tool.serverName !== call.server.serverName ||
33
+ (tool.agentId ?? null) !== (call.server.agentId ?? null) ||
34
+ tool.serverToolName !== call.name)
35
+ return `Effect '${key}' calls another tool`;
36
+ if (canonical(argumentsOf(request.input)) !== canonical(call.arguments))
37
+ return `Effect '${key}' was journaled with other input`;
38
+ return held(row);
39
+ });
40
+ return typeof refusal === "string" ? { refused: refusal } : refusal;
41
+ },
42
+ };
43
+ }
44
+ /** A journaled call: its span context, when core recorded one (R7.4). */
45
+ function held(row) {
46
+ const traceparent = row.traceparent;
47
+ return typeof traceparent === "string" ? { traceparent } : {};
48
+ }
49
+ /** A tool call's input as `tools/call` arguments (`callMcpTool`): an object, else none. */
50
+ function argumentsOf(input) {
51
+ return input && typeof input === "object" && !Array.isArray(input) ? input : {};
52
+ }
@@ -29,6 +29,10 @@ export interface McpHandler {
29
29
  effectId: string;
30
30
  name: string;
31
31
  arguments: Record<string, unknown>;
32
+ /** The call's `Idempotency-Key`, sent to the server in `_meta` (R5.5). */
33
+ key?: string;
34
+ /** The call's span context from its journal row (R7.4), sent as `traceparent`. */
35
+ traceparent?: string;
32
36
  }, signal: AbortSignal): Promise<McpAnswer<Record<string, unknown>>>;
33
37
  close(server: McpServerRef): Promise<void>;
34
38
  /** Closes connections idle past the timeout. */
@@ -114,7 +114,11 @@ export function createMcpHandler(options) {
114
114
  async function callOnce(tenantId, request, signal) {
115
115
  for (let attempt = 0;; attempt += 1) {
116
116
  try {
117
- return await use(tenantId, request.server, (entry) => entry.connection.client.callTool({ name: request.name, arguments: request.arguments }, { signal }), true);
117
+ return await use(tenantId, request.server, (entry) => entry.connection.client.callTool({ name: request.name, arguments: request.arguments }, {
118
+ signal,
119
+ ...(request.key === undefined ? {} : { key: request.key }),
120
+ ...(request.traceparent === undefined ? {} : { traceparent: request.traceparent }),
121
+ }), true);
118
122
  }
119
123
  catch (error) {
120
124
  if (!(error instanceof McpConnectionStale) || attempt > 0)
@@ -13,8 +13,16 @@ export interface MeterOptions {
13
13
  readonly now?: () => Date;
14
14
  }
15
15
  export declare function createMeter(options: MeterOptions): Meter;
16
- /** The ledger row of an outcome that carries usage; undefined for a failure. */
17
- export declare function usageRow(request: ModelGateRequest, outcome: ModelGateOutcome, at: Date): ModelUsageWrite | undefined;
16
+ /** When the gate sent a call and when its answer was complete (R7.1). */
17
+ export interface CallTiming {
18
+ readonly startedAt: Date;
19
+ readonly endedAt: Date;
20
+ }
21
+ /**
22
+ * The ledger row of an outcome that carries usage; undefined for a failure. With `timing`, the
23
+ * row keeps the gate's start and end of the call, and the adapter's `firstTokenMs`.
24
+ */
25
+ export declare function usageRow(request: ModelGateRequest, outcome: ModelGateOutcome, at: Date, timing?: CallTiming): ModelUsageWrite | undefined;
18
26
  /**
19
27
  * Why the budget refuses another call, or undefined. Calls in flight count at the scope's
20
28
  * average cost per recorded call.
@@ -8,6 +8,10 @@
8
8
  * the scope at the scope's average cost per call, reaches its limit. Calls in a turn run one
9
9
  * at a time, so a scope overspends by at most one call per concurrent call. The refusal is a
10
10
  * `budget_exhausted` failure, which the turn does not retry.
11
+ *
12
+ * The row also keeps the gate's own timing of the call (R7.1): `now()` before the call and
13
+ * after it, and the adapter's `firstTokenMs` (`evidence.extras`), so a model call's span is
14
+ * what the gate measured.
11
15
  */
12
16
  import { randomUUID } from "node:crypto";
13
17
  import { failure } from "../model/classify.js";
@@ -60,8 +64,9 @@ export function createMeter(options) {
60
64
  for (const key of keys)
61
65
  inflight.set(key, (inflight.get(key) ?? 0) + 1);
62
66
  try {
67
+ const startedAt = now();
63
68
  const outcome = await call();
64
- await record(store, request, outcome);
69
+ await record(store, request, outcome, { startedAt, endedAt: now() });
65
70
  return outcome;
66
71
  }
67
72
  finally {
@@ -75,8 +80,8 @@ export function createMeter(options) {
75
80
  }
76
81
  },
77
82
  };
78
- async function record(store, request, outcome) {
79
- const row = usageRow(request, outcome, now());
83
+ async function record(store, request, outcome, timing) {
84
+ const row = usageRow(request, outcome, timing.endedAt, timing);
80
85
  if (!row)
81
86
  return;
82
87
  try {
@@ -96,11 +101,15 @@ export function createMeter(options) {
96
101
  }
97
102
  }
98
103
  }
99
- /** The ledger row of an outcome that carries usage; undefined for a failure. */
100
- export function usageRow(request, outcome, at) {
104
+ /**
105
+ * The ledger row of an outcome that carries usage; undefined for a failure. With `timing`, the
106
+ * row keeps the gate's start and end of the call, and the adapter's `firstTokenMs`.
107
+ */
108
+ export function usageRow(request, outcome, at, timing) {
101
109
  if (typeof outcome !== "object" || "kind" in outcome || !outcome.usage)
102
110
  return undefined;
103
111
  const usage = outcome.usage;
112
+ const firstTokenMs = outcome.evidence?.extras?.firstTokenMs;
104
113
  const producer = producerOf(outcome);
105
114
  const inputTokens = count(usage.inputTokens);
106
115
  const outputTokens = count(usage.outputTokens);
@@ -126,6 +135,12 @@ export function usageRow(request, outcome, at) {
126
135
  reasoningTokens: count(usage.reasoningTokens),
127
136
  costUsd: Number.isFinite(usage.costUsd) && usage.costUsd > 0 ? usage.costUsd : 0,
128
137
  createdAt: at.toISOString(),
138
+ ...(timing
139
+ ? { startedAt: timing.startedAt.toISOString(), endedAt: timing.endedAt.toISOString() }
140
+ : {}),
141
+ ...(typeof firstTokenMs === "number" && Number.isFinite(firstTokenMs) && firstTokenMs >= 0
142
+ ? { firstTokenMs: Math.round(firstTokenMs) }
143
+ : {}),
129
144
  };
130
145
  }
131
146
  /** The ledger rows a budget counts for `request`; undefined when it doesn't apply. */
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Tool policy (R5 step 2). Every MCP and HTTP tool call of every harness is decided as core
3
+ * journals it (`harness-api/record.ts`), which each harness must do before it sends a call:
4
+ * `deny` fails it to the model as `tool.denied`, unsent; `ask` holds the row until a person
5
+ * approves it on the session; `allow` runs it. The Tool Gate sends only a call the journal holds
6
+ * running (`journal.ts`), so a held or refused call cannot run, whichever harness asks.
7
+ *
8
+ * The rules are the Tenant's (`PUT /v1/tenant/tool-policy`, the `tool-policy` setting). A rule
9
+ * matches a call when every selector it sets matches; the strictest matching rule decides
10
+ * (`deny`, then `ask`, then `allow`), and a call no rule matches runs. A session pinned to a
11
+ * manifest saved before R5 step 2 keeps its `approval: "always"` as an ask: no session loses
12
+ * the approval it was opened with. A policy that cannot be read refuses every call.
13
+ */
14
+ import { type ToolPolicy, type ToolRule } from "@nylorun/core/contracts";
15
+ import type { AgentManifest } from "@nylorun/core/define";
16
+ import type { HostEffect } from "@nylorun/harness/run";
17
+ import { type McpSnapshot } from "../mcp/snapshot.js";
18
+ import type { Tx } from "../store/types.js";
19
+ import type { Session } from "../tenant/context.js";
20
+ /** The `tenant_settings` key the policy is kept under. */
21
+ export declare const TOOL_POLICY_SETTING = "tool-policy";
22
+ /** A call as the policy reads it. */
23
+ export interface PolicyCall {
24
+ readonly kind: "mcp" | "http";
25
+ /** The name the model calls the tool by; a flow stage's key. */
26
+ readonly tool: string;
27
+ /** The MCP server's name. */
28
+ readonly server?: string;
29
+ /** The agent that calls it. */
30
+ readonly agent: string;
31
+ readonly input: unknown;
32
+ }
33
+ export type PolicyDecision = {
34
+ readonly effect: "allow";
35
+ } | {
36
+ readonly effect: "ask" | "deny";
37
+ readonly reason: string;
38
+ };
39
+ /** The Tenant's policy; an empty one when none was set. Throws when it cannot be read. */
40
+ export declare function readToolPolicy(t: Pick<Tx, "getSetting">): Promise<ToolPolicy>;
41
+ /**
42
+ * The decision for `request`, a session's MCP or HTTP tool call: the Tenant's rules and the
43
+ * pinned manifest's `approval`. A policy that cannot be read refuses the call.
44
+ */
45
+ export declare function toolCallDecision(t: Pick<Tx, "getSetting">, s: Session, request: HostEffect, kind: "mcp" | "http"): Promise<PolicyDecision>;
46
+ /** Decides `call` by `rules`; `pinnedAsk` adds the ask of a manifest saved before R5 step 2. */
47
+ export declare function decide(rules: readonly ToolRule[], call: PolicyCall, pinnedAsk?: boolean): PolicyDecision;
48
+ /** The pinned `approval: "always"` of the call's tool, in a manifest saved before R5 step 2. */
49
+ export declare function pinnedApproval(manifest: AgentManifest, snapshot: McpSnapshot | undefined, request: HostEffect): boolean;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tool policy (R5 step 2). Every MCP and HTTP tool call of every harness is decided as core
3
+ * journals it (`harness-api/record.ts`), which each harness must do before it sends a call:
4
+ * `deny` fails it to the model as `tool.denied`, unsent; `ask` holds the row until a person
5
+ * approves it on the session; `allow` runs it. The Tool Gate sends only a call the journal holds
6
+ * running (`journal.ts`), so a held or refused call cannot run, whichever harness asks.
7
+ *
8
+ * The rules are the Tenant's (`PUT /v1/tenant/tool-policy`, the `tool-policy` setting). A rule
9
+ * matches a call when every selector it sets matches; the strictest matching rule decides
10
+ * (`deny`, then `ask`, then `allow`), and a call no rule matches runs. A session pinned to a
11
+ * manifest saved before R5 step 2 keeps its `approval: "always"` as an ask: no session loses
12
+ * the approval it was opened with. A policy that cannot be read refuses every call.
13
+ */
14
+ import { ToolPolicySchema } from "@nylorun/core/contracts";
15
+ import { mcpToolOf, mcpToolSettingsIn } from "../mcp/snapshot.js";
16
+ import { canonical } from "../store/canonical.js";
17
+ import { turnManifestOf } from "../tenant/session.js";
18
+ import { declaredHttpTool } from "./http-tool.js";
19
+ /** The `tenant_settings` key the policy is kept under. */
20
+ export const TOOL_POLICY_SETTING = "tool-policy";
21
+ const STRICTNESS = { allow: 0, ask: 1, deny: 2 };
22
+ /** The Tenant's policy; an empty one when none was set. Throws when it cannot be read. */
23
+ export async function readToolPolicy(t) {
24
+ const raw = await t.getSetting(TOOL_POLICY_SETTING);
25
+ if (raw === undefined)
26
+ return { rules: [], updatedAt: null };
27
+ return ToolPolicySchema.parse(JSON.parse(raw));
28
+ }
29
+ /**
30
+ * The decision for `request`, a session's MCP or HTTP tool call: the Tenant's rules and the
31
+ * pinned manifest's `approval`. A policy that cannot be read refuses the call.
32
+ */
33
+ export async function toolCallDecision(t, s, request, kind) {
34
+ let rules;
35
+ try {
36
+ rules = (await readToolPolicy(t)).rules;
37
+ }
38
+ catch {
39
+ return { effect: "deny", reason: "The Tenant's tool policy could not be read, so no tool call runs" };
40
+ }
41
+ const mcp = mcpToolOf(s.mcpSnapshot, request);
42
+ return decide(rules, {
43
+ kind,
44
+ tool: request.toolName ?? request.key ?? request.effectId,
45
+ ...(mcp ? { server: mcp.serverName } : {}),
46
+ agent: request.agent?.id ?? s.agentId,
47
+ input: request.input,
48
+ }, pinnedApproval(turnManifestOf(s), s.mcpSnapshot, request));
49
+ }
50
+ /** Decides `call` by `rules`; `pinnedAsk` adds the ask of a manifest saved before R5 step 2. */
51
+ export function decide(rules, call, pinnedAsk = false) {
52
+ let strictest = pinnedAsk
53
+ ? { effect: "ask", reason: `The agent's manifest asks for approval of '${call.tool}'` }
54
+ : undefined;
55
+ for (const rule of rules) {
56
+ if (!matches(rule, call))
57
+ continue;
58
+ if (strictest && STRICTNESS[rule.effect] <= STRICTNESS[strictest.effect])
59
+ continue;
60
+ strictest = { effect: rule.effect, reason: rule.reason ?? describe(rule) };
61
+ }
62
+ if (!strictest || strictest.effect === "allow")
63
+ return { effect: "allow" };
64
+ return { effect: strictest.effect, reason: strictest.reason };
65
+ }
66
+ /** The pinned `approval: "always"` of the call's tool, in a manifest saved before R5 step 2. */
67
+ export function pinnedApproval(manifest, snapshot, request) {
68
+ const mcp = mcpToolOf(snapshot, request);
69
+ if (mcp)
70
+ return mcpToolSettingsIn(manifest, mcp).approval === "always";
71
+ if (request.capabilityId === undefined)
72
+ return false;
73
+ return declaredHttpTool(manifest, request.agent?.id, request.capabilityId, request.toolName)?.approval === "always";
74
+ }
75
+ function matches(rule, call) {
76
+ if (rule.kind !== undefined && rule.kind !== call.kind)
77
+ return false;
78
+ if (rule.tool !== undefined && !glob(rule.tool).test(call.tool))
79
+ return false;
80
+ if (rule.server !== undefined && (call.server === undefined || !glob(rule.server).test(call.server)))
81
+ return false;
82
+ if (rule.agent !== undefined && !glob(rule.agent).test(call.agent))
83
+ return false;
84
+ for (const [path, condition] of Object.entries(rule.when ?? {}))
85
+ if (!holds(condition, fieldOf(call.input, path)))
86
+ return false;
87
+ return true;
88
+ }
89
+ /** A pattern where `*` is any run of characters, matched whole. */
90
+ function glob(pattern) {
91
+ return new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
92
+ }
93
+ /** The value at a dotted `path` of `input`, or undefined. */
94
+ function fieldOf(input, path) {
95
+ let value = input;
96
+ for (const key of path.split(".")) {
97
+ if (!value || typeof value !== "object")
98
+ return undefined;
99
+ value = value[key];
100
+ }
101
+ return value;
102
+ }
103
+ function holds(condition, value) {
104
+ if (value === undefined)
105
+ return false;
106
+ if (condition.equals !== undefined && canonical(value) !== canonical(condition.equals))
107
+ return false;
108
+ if (condition.in !== undefined && !condition.in.some((item) => canonical(item) === canonical(value)))
109
+ return false;
110
+ const number = typeof value === "number" ? value : undefined;
111
+ if (condition.gt !== undefined && !(number !== undefined && number > condition.gt))
112
+ return false;
113
+ if (condition.gte !== undefined && !(number !== undefined && number >= condition.gte))
114
+ return false;
115
+ if (condition.lt !== undefined && !(number !== undefined && number < condition.lt))
116
+ return false;
117
+ if (condition.lte !== undefined && !(number !== undefined && number <= condition.lte))
118
+ return false;
119
+ if (condition.matches !== undefined && !(typeof value === "string" && new RegExp(condition.matches).test(value)))
120
+ return false;
121
+ return true;
122
+ }
123
+ /** What a rule with no `reason` says. */
124
+ function describe(rule) {
125
+ const what = [
126
+ rule.tool === undefined ? undefined : `tool '${rule.tool}'`,
127
+ rule.server === undefined ? undefined : `server '${rule.server}'`,
128
+ rule.kind === undefined ? undefined : `${rule.kind} tools`,
129
+ rule.agent === undefined ? undefined : `agent '${rule.agent}'`,
130
+ rule.when === undefined ? undefined : `input ${Object.keys(rule.when).join(", ")}`,
131
+ ].filter((part) => part !== undefined);
132
+ const verb = rule.effect === "deny" ? "refuses" : "asks a person about";
133
+ return `The Tenant's tool policy ${verb} ${what.length === 0 ? "every tool call" : `calls of ${what.join(", ")}`}`;
134
+ }
@@ -29,15 +29,15 @@ export interface TenantVault {
29
29
  session(sessionId: string): Promise<Session | undefined>;
30
30
  /**
31
31
  * The authorization of one request to a session's remote MCP server or HTTP tool: its
32
- * credential from the session's attached vaults.
32
+ * credential from the vault of the session's Environment.
33
33
  */
34
34
  authorizeMcp(sessionId: string, request: McpCredentialRequest): Promise<AuthorizeResult>;
35
35
  /** Vault writes and token signing with the Tenant's vault key (the keys service, F4.2). */
36
36
  keys(): Keys;
37
- /** Whether a live session on the sandbox has an `environment_secret` bound to `host` (R2c). */
38
- environmentSecretBound(sandboxId: string, host: string): Promise<boolean>;
37
+ /** Whether the Environment's vault has an `environment_secret` bound to `host` (R2c). */
38
+ environmentSecretBound(environmentId: string, host: string): Promise<boolean>;
39
39
  /** The header an `environment_secret` sets on one request to `host` (R2c). */
40
- releaseEnvironmentSecret(sandboxId: string, host: string): ReturnType<VaultService["releaseEnvironmentSecret"]>;
40
+ releaseEnvironmentSecret(environmentId: string, host: string): ReturnType<VaultService["releaseEnvironmentSecret"]>;
41
41
  }
42
42
  export interface TenantVaults {
43
43
  /**
@@ -60,8 +60,8 @@ export interface TenantVaultsOptions {
60
60
  }
61
61
  export declare function createTenantVaults(options: TenantVaultsOptions): TenantVaults;
62
62
  /**
63
- * A request to a session's remote MCP server, authorized from the session's attached vaults:
64
- * what the loop does in its own process (`tenant/effects.ts` `authorize`). The vaults come from
65
- * the session row.
63
+ * A request to a session's remote MCP server, authorized from its Environment's vault: what the
64
+ * loop does in its own process (`tenant/effects.ts` `authorize`). The Environment comes from the
65
+ * session row.
66
66
  */
67
67
  export declare function authorizeSessionMcp(vault: Pick<VaultService, "authorize">, session: (sessionId: string) => Promise<Session | undefined>, sessionId: string, request: McpCredentialRequest): Promise<AuthorizeResult>;
@@ -52,8 +52,8 @@ export function createTenantVaults(options) {
52
52
  session,
53
53
  authorizeMcp: (sessionId, request) => authorizeSessionMcp(credentials, session, sessionId, request),
54
54
  keys: () => keys,
55
- environmentSecretBound: (sandboxId, host) => credentials.environmentSecretBound(sandboxId, host),
56
- releaseEnvironmentSecret: (sandboxId, host) => credentials.releaseEnvironmentSecret({ sandboxId, host }),
55
+ environmentSecretBound: (environmentId, host) => credentials.environmentSecretBound(environmentId, host),
56
+ releaseEnvironmentSecret: (environmentId, host) => credentials.releaseEnvironmentSecret({ environmentId, host }),
57
57
  };
58
58
  }
59
59
  /** The database's Tenant, once its schema is at this build's version. */
@@ -81,9 +81,9 @@ export function createTenantVaults(options) {
81
81
  };
82
82
  }
83
83
  /**
84
- * A request to a session's remote MCP server, authorized from the session's attached vaults:
85
- * what the loop does in its own process (`tenant/effects.ts` `authorize`). The vaults come from
86
- * the session row.
84
+ * A request to a session's remote MCP server, authorized from its Environment's vault: what the
85
+ * loop does in its own process (`tenant/effects.ts` `authorize`). The Environment comes from the
86
+ * session row.
87
87
  */
88
88
  export async function authorizeSessionMcp(vault, session, sessionId, request) {
89
89
  const found = await session(sessionId);
@@ -8,10 +8,14 @@
8
8
  * - The gate also writes a `tool_crossings` row before it calls the server, and the answer
9
9
  * after. A re-send that finds the answer gets it. One that finds a row without an answer,
10
10
  * for a call this gateway is not running, was lost with an earlier gateway (a restart mid
11
- * call): it answers `uncertain`, because the call may have run, and is never run again.
11
+ * call): it is started again under the same key (R5.3), so the tool sees the key twice and
12
+ * a tool that honors it acts once. Core counts the sends and stops at its cap (R5.4).
13
+ * - A re-send that another gateway process started or settled between this one's read and its
14
+ * write answers `{uncertain: true}`: not answered here, so the caller sends it again later.
12
15
  *
13
- * A cancel aborts the call and leaves its row without an answer. Settled rows are deleted a day
14
- * later (`prune`).
16
+ * A cancel aborts the call and settles its row as cancelled: it never runs again under its key.
17
+ * A shutdown leaves the row open, so the next gateway starts it again. Settled rows are deleted
18
+ * a day later (`prune`); a re-send after that runs the call again, which at least once allows.
15
19
  */
16
20
  import type { Logger } from "../tenant/types.js";
17
21
  import { type InflightCallsOptions, type InflightOwner } from "./inflight.js";
@@ -29,8 +33,9 @@ export interface ToolCalls {
29
33
  */
30
34
  run(tenantId: string | undefined, key: string, hash: string, call: (signal: AbortSignal) => Promise<ToolCallAnswer>, owner?: InflightOwner): Promise<ToolCallAnswer>;
31
35
  /**
32
- * Aborts the call under `key`; it is never run again under that key. With `sessionId`, false
33
- * when the running call is another session's (nothing is aborted).
36
+ * Aborts the call under `key` and settles its crossing as cancelled: it is never run again
37
+ * under that key. With `sessionId`, false when the running call is another session's (nothing
38
+ * is aborted).
34
39
  */
35
40
  cancel(key: string, sessionId?: string): boolean;
36
41
  /** Aborts every running call (gateway shutdown). */
@@ -1,11 +1,15 @@
1
1
  import { createInflightCalls, InflightConflict, } from "./inflight.js";
2
2
  /** How long a settled crossing is kept. */
3
3
  export const TOOL_CROSSING_TTL_MS = 24 * 60 * 60_000;
4
- const LOST = {
4
+ const CANCELLED = {
5
+ ok: false,
6
+ error: { message: "The call was cancelled while it ran; it may have reached its server" },
7
+ };
8
+ const ELSEWHERE = {
5
9
  ok: false,
6
10
  error: {
7
11
  uncertain: true,
8
- message: "The gateway stopped while this tool call ran; the call may have reached its server, so it is not run again",
12
+ message: "Another gateway process is running this tool call; send it again",
9
13
  },
10
14
  };
11
15
  export function createToolCalls(options) {
@@ -16,7 +20,7 @@ export function createToolCalls(options) {
16
20
  return {
17
21
  async run(tenantId, key, hash, call, owner) {
18
22
  if (inflight.has(key))
19
- return inflight.run(key, hash, () => Promise.resolve(LOST), owner);
23
+ return inflight.run(key, hash, () => Promise.resolve(ELSEWHERE), owner);
20
24
  const vault = await vaults.open(tenantId);
21
25
  const store = vault.store;
22
26
  const row = await store.tx((t) => t.toolCrossing(key));
@@ -25,16 +29,19 @@ export function createToolCalls(options) {
25
29
  throw new InflightConflict(`A different request already ran under ${key}`);
26
30
  if (row.answer !== null && row.answer !== undefined)
27
31
  return row.answer;
28
- // Started here a moment ago, or lost with an earlier gateway.
32
+ // Started here a moment ago: join it.
29
33
  if (inflight.has(key))
30
- return inflight.run(key, hash, () => Promise.resolve(LOST), owner);
31
- return LOST;
34
+ return inflight.run(key, hash, () => Promise.resolve(ELSEWHERE), owner);
35
+ // Otherwise it was lost with an earlier gateway: it starts again below (R5.3).
32
36
  }
33
37
  return inflight.run(key, hash, async (signal) => {
34
- const started = await store.tx((t) => t.startToolCrossing({ key, hash, startedAt: iso() }));
35
- // Another gateway process wrote it between the read and now: never run it twice.
38
+ const startedAt = iso();
39
+ const started = await store.tx((t) => row
40
+ ? t.restartToolCrossing({ key, hash, startedAt })
41
+ : t.startToolCrossing({ key, hash, startedAt }));
42
+ // Another gateway process started or settled it between the read and now.
36
43
  if (!started)
37
- return LOST;
44
+ return ELSEWHERE;
38
45
  const answer = await call(signal);
39
46
  // A cancel or a shutdown: the call may have reached the server; the row stays open.
40
47
  if (signal.aborted)
@@ -48,7 +55,18 @@ export function createToolCalls(options) {
48
55
  return answer;
49
56
  }, owner);
50
57
  },
51
- cancel: (key, sessionId) => inflight.cancel(key, sessionId),
58
+ cancel(key, sessionId) {
59
+ if (!inflight.cancel(key, sessionId))
60
+ return false;
61
+ void vaults
62
+ .open(undefined)
63
+ .then((vault) => vault.store.tx((t) => t.settleToolCrossing(key, CANCELLED, iso())))
64
+ .catch((error) => logger.warn("tool_crossing_unsettled", {
65
+ effect: key,
66
+ message: error instanceof Error ? error.message : String(error),
67
+ }));
68
+ return true;
69
+ },
52
70
  close: () => inflight.close(),
53
71
  async prune() {
54
72
  try {
@@ -1,5 +1,13 @@
1
1
  import type { ToolGate } from "./tool-gate.js";
2
2
  import type { RunTokens } from "../tenant/run-grants.js";
3
+ /**
4
+ * A keyed call whose answer the hop lost (R5.3): the gate never got it, the connection broke,
5
+ * the gate answered `5xx`, or another gateway process holds the call. Sending it again with the
6
+ * same key is safe: the gate joins the call, answers it from its crossing, or runs it again.
7
+ */
8
+ export declare class GateCallLost extends Error {
9
+ readonly name = "GateCallLost";
10
+ }
3
11
  export interface HttpToolGateOptions {
4
12
  /** The gates service, e.g. `http://gateway:4100` (`NYLORUN_GATES_URL`). */
5
13
  readonly url: string;
@@ -8,10 +8,12 @@
8
8
  * Failures keep the meaning they had in the loop's own process:
9
9
  * - An MCP request that fails throws, so the pool reports a diagnostic. A tool call's coded
10
10
  * failure (R2b C7) throws the `McpCallFailed` the loop's own call would have, which becomes a
11
- * failed outcome the model sees. Only the hop's failures and a call lost with the gateway leave
12
- * it `uncertain`.
13
- * - An HTTP tool call throws only for the hop, or a call lost with the gateway: its own failures
14
- * are outcomes.
11
+ * failed outcome the model sees.
12
+ * - An HTTP tool call throws only for the hop: its own failures are outcomes.
13
+ * - A hop that lost the answer (the gate unreachable, the connection lost, a `5xx`, or a call
14
+ * another gateway process holds) throws `GateCallLost`: sending the call again with the same
15
+ * key joins it, collects its answer, or runs it again (R5.3). A gate's refusal (`4xx`) throws
16
+ * a plain error.
15
17
  *
16
18
  * Credentials (F5): a session's MCP requests and keyed cancels carry its run token, read per
17
19
  * request from the advance's grant, and leave the session out of the body: the gate takes it
@@ -26,6 +28,14 @@ import { McpCallFailed, } from "../mcp/connect.js";
26
28
  import { TENANT_HEADER } from "./contract.js";
27
29
  import { GATE_CLIENT_TIMEOUT_MS } from "./http-client.js";
28
30
  import { HTTP_CALLS_PATH, MCP_CLOSE_PATH, MCP_CONNECT_PATH, MCP_LIST_PATH, TOOL_CALLS_PATH, } from "./tool-contract.js";
31
+ /**
32
+ * A keyed call whose answer the hop lost (R5.3): the gate never got it, the connection broke,
33
+ * the gate answered `5xx`, or another gateway process holds the call. Sending it again with the
34
+ * same key is safe: the gate joins the call, answers it from its crossing, or runs it again.
35
+ */
36
+ export class GateCallLost extends Error {
37
+ name = "GateCallLost";
38
+ }
29
39
  /** How long a close or a cancel may take; neither changes an outcome. */
30
40
  const SHORT_TIMEOUT_MS = 2_000;
31
41
  export function httpToolGate(options) {
@@ -111,8 +121,7 @@ export function httpToolGate(options) {
111
121
  }
112
122
  /**
113
123
  * The answer of an MCP or HTTP tool request, or a throw: a coded MCP failure (`McpCallFailed`),
114
- * or one the pool and `resolveEffect` read as before, the call's fate unknown and the effect
115
- * `uncertain`.
124
+ * a lost hop (`GateCallLost`, sent again), or a gate's refusal.
116
125
  */
117
126
  async function answer(path, scope, request) {
118
127
  const result = await exchange(path, scope.body, {
@@ -123,8 +132,10 @@ export function httpToolGate(options) {
123
132
  if (result.kind === "aborted")
124
133
  throw result.reason ?? new Error("aborted");
125
134
  if (result.kind !== "answer")
126
- throw new Error(result.message);
135
+ throw new GateCallLost(result.message);
127
136
  const parsed = parse(result.text);
137
+ if (result.status >= 500)
138
+ throw new GateCallLost(gateRefusal(result.status, parsed));
128
139
  if (result.status !== 200 || !parsed || !("ok" in parsed))
129
140
  throw new Error(gateRefusal(result.status, parsed));
130
141
  if (parsed.ok)
@@ -176,7 +187,9 @@ export function httpToolGate(options) {
176
187
  async callHttp(call, signal) {
177
188
  const token = options.runTokens?.token(call.tool.sessionId);
178
189
  const { sessionId: _, ...unnamed } = call.tool;
179
- const body = { ...call, tool: token ? unnamed : { ...call.tool } };
190
+ // The gate reads the call's span context from its journal row (R7.4), not from the caller.
191
+ const { traceparent: _traceparent, ...sent } = call;
192
+ const body = { ...sent, tool: token ? unnamed : { ...call.tool } };
180
193
  return answered(HTTP_CALLS_PATH, { body, ...(token ? { bearer: token } : {}) }, { signal, idempotencyKey: call.effectId });
181
194
  },
182
195
  async cancel(request) {
@@ -190,10 +203,13 @@ export function httpToolGate(options) {
190
203
  }
191
204
  /** An MCP failure the gate reported, as the loop's own MCP code would have thrown it. */
192
205
  function mcpError(error) {
193
- // Coded (R2b C7): the model sees it. A call lost with the gateway never is.
194
- if (error.failure && !error.uncertain)
206
+ // Held by another gateway process: send it again (R5.3).
207
+ if (error.uncertain)
208
+ return new GateCallLost(error.message);
209
+ // Coded (R2b C7): the model sees it.
210
+ if (error.failure)
195
211
  return McpCallFailed.from(error.failure);
196
- const thrown = new Error(error.uncertain ? `${error.message} (the call may have run; it is not sent again)` : error.message);
212
+ const thrown = new Error(error.message);
197
213
  if (error.code !== undefined)
198
214
  thrown.code = error.code;
199
215
  if (error.credentialIds !== undefined)