@nylorun/runtime 0.23.0-beta → 0.25.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 (301) hide show
  1. package/CHANGELOG.md +252 -0
  2. package/README.md +87 -28
  3. package/dist/adapters/execution/restate.js +1 -1
  4. package/dist/adapters/replication/pgoutput.d.ts +2 -0
  5. package/dist/adapters/replication/pgoutput.js +6 -3
  6. package/dist/adapters/sandbox/local.d.ts +9 -1
  7. package/dist/adapters/sandbox/local.js +16 -4
  8. package/dist/adapters/sandbox/virtual.js +4 -1
  9. package/dist/api/a2a/card.d.ts +11 -1
  10. package/dist/api/a2a/card.js +26 -2
  11. package/dist/api/a2a/endpoint.js +9 -3
  12. package/dist/api/a2a/protocol.d.ts +25 -2
  13. package/dist/api/a2a/protocol.js +20 -3
  14. package/dist/api/a2a/routes.d.ts +6 -1
  15. package/dist/api/a2a/routes.js +67 -9
  16. package/dist/api/a2a/tasks.js +2 -1
  17. package/dist/api/ag-ui/endpoint.js +8 -3
  18. package/dist/api/ag-ui/history.d.ts +6 -1
  19. package/dist/api/ag-ui/history.js +39 -3
  20. package/dist/api/ag-ui/routes.d.ts +4 -1
  21. package/dist/api/ag-ui/routes.js +63 -20
  22. package/dist/api/ag-ui/translate.js +3 -2
  23. package/dist/api/components.d.ts +5327 -1091
  24. package/dist/api/components.js +40 -16
  25. package/dist/api/gate/keys.d.ts +2 -1
  26. package/dist/api/gate/keys.js +14 -1
  27. package/dist/api/gate/routes.d.ts +6 -0
  28. package/dist/api/gate/routes.js +52 -3
  29. package/dist/api/http/app.js +17 -6
  30. package/dist/api/http/body.d.ts +3 -3
  31. package/dist/api/http/body.js +7 -7
  32. package/dist/api/http/define.d.ts +6 -0
  33. package/dist/api/http/define.js +13 -1
  34. package/dist/api/http/respond.js +1 -1
  35. package/dist/api/http/routes/artifacts.js +35 -8
  36. package/dist/api/http/routes/coverage.d.ts +9 -0
  37. package/dist/api/http/routes/coverage.js +34 -0
  38. package/dist/api/http/routes/environment-manifests.d.ts +9 -0
  39. package/dist/api/http/routes/environment-manifests.js +118 -0
  40. package/dist/api/http/routes/environments.d.ts +11 -0
  41. package/dist/api/http/routes/environments.js +332 -0
  42. package/dist/api/http/routes/mcp.d.ts +1 -1
  43. package/dist/api/http/routes/mcp.js +2 -2
  44. package/dist/api/http/routes/me.js +1 -1
  45. package/dist/api/http/routes/reads.d.ts +94 -9
  46. package/dist/api/http/routes/reads.js +54 -5
  47. package/dist/api/http/routes/sessions.js +24 -9
  48. package/dist/api/http/routes/tenant.js +65 -15
  49. package/dist/api/inbound-files.d.ts +65 -0
  50. package/dist/api/inbound-files.js +115 -0
  51. package/dist/api/openapi.js +48 -28
  52. package/dist/artifacts/disk.d.ts +59 -0
  53. package/dist/artifacts/disk.js +569 -0
  54. package/dist/artifacts/export.d.ts +1 -1
  55. package/dist/artifacts/export.js +2 -3
  56. package/dist/artifacts/folders.d.ts +6 -3
  57. package/dist/artifacts/folders.js +17 -12
  58. package/dist/artifacts/media-types.d.ts +15 -0
  59. package/dist/artifacts/media-types.js +56 -4
  60. package/dist/artifacts/parts.d.ts +12 -3
  61. package/dist/artifacts/parts.js +15 -4
  62. package/dist/artifacts/service.d.ts +2 -1
  63. package/dist/artifacts/service.js +6 -1
  64. package/dist/artifacts/workspace.d.ts +5 -5
  65. package/dist/artifacts/workspace.js +7 -19
  66. package/dist/core/flow-host.d.ts +3 -4
  67. package/dist/core/flow-host.js +5 -2
  68. package/dist/core/provider.d.ts +3 -2
  69. package/dist/core/provider.js +5 -37
  70. package/dist/core/sandbox-routes.d.ts +2 -21
  71. package/dist/core/sandbox-routes.js +11 -44
  72. package/dist/core/turn-manifest.d.ts +3 -2
  73. package/dist/core/turn-manifest.js +29 -5
  74. package/dist/execution/types.d.ts +1 -1
  75. package/dist/gates/contract.d.ts +3 -24
  76. package/dist/gates/contract.js +1 -36
  77. package/dist/gates/egress-credentials.d.ts +9 -0
  78. package/dist/gates/egress-credentials.js +11 -4
  79. package/dist/gates/egress.d.ts +22 -1
  80. package/dist/gates/egress.js +116 -10
  81. package/dist/gates/fixture-model.d.ts +28 -0
  82. package/dist/gates/fixture-model.js +92 -0
  83. package/dist/gates/handler.js +4 -4
  84. package/dist/gates/http-tool.d.ts +9 -6
  85. package/dist/gates/http-tool.js +11 -17
  86. package/dist/gates/in-process.d.ts +5 -0
  87. package/dist/gates/in-process.js +12 -1
  88. package/dist/gates/journal.d.ts +30 -0
  89. package/dist/gates/journal.js +51 -0
  90. package/dist/gates/mcp-handler.d.ts +4 -0
  91. package/dist/gates/mcp-handler.js +7 -4
  92. package/dist/gates/meter.d.ts +10 -2
  93. package/dist/gates/meter.js +20 -5
  94. package/dist/gates/policy.d.ts +49 -0
  95. package/dist/gates/policy.js +132 -0
  96. package/dist/gates/tenant-vaults.d.ts +7 -7
  97. package/dist/gates/tenant-vaults.js +5 -5
  98. package/dist/gates/tool-calls.d.ts +10 -5
  99. package/dist/gates/tool-calls.js +28 -10
  100. package/dist/gates/tool-client.d.ts +8 -0
  101. package/dist/gates/tool-client.js +27 -11
  102. package/dist/gates/tool-contract.d.ts +11 -69
  103. package/dist/gates/tool-contract.js +1 -85
  104. package/dist/gates/tool-gate.d.ts +1 -1
  105. package/dist/harness/calls.d.ts +14 -7
  106. package/dist/harness/calls.js +30 -24
  107. package/dist/harness/executors.js +12 -9
  108. package/dist/harness/main.js +24 -5
  109. package/dist/harness/pod.d.ts +39 -0
  110. package/dist/harness/pod.js +46 -0
  111. package/dist/harness/service.d.ts +0 -1
  112. package/dist/harness/service.js +17 -9
  113. package/dist/harness/setup.d.ts +29 -0
  114. package/dist/harness/setup.js +174 -0
  115. package/dist/harness/ws-client.js +3 -2
  116. package/dist/harness/ws-port.d.ts +1 -5
  117. package/dist/harness/ws-port.js +1 -5
  118. package/dist/harness-api/in-process.js +3 -3
  119. package/dist/harness-api/record.d.ts +15 -1
  120. package/dist/harness-api/record.js +180 -50
  121. package/dist/harness-api/redact.d.ts +3 -0
  122. package/dist/harness-api/redact.js +45 -0
  123. package/dist/harness-api/server.d.ts +35 -4
  124. package/dist/harness-api/server.js +130 -17
  125. package/dist/harness-api/start.d.ts +10 -3
  126. package/dist/harness-api/start.js +40 -10
  127. package/dist/harness-api/tool-journal.d.ts +105 -0
  128. package/dist/harness-api/tool-journal.js +132 -0
  129. package/dist/harness-api/tool-status.d.ts +13 -0
  130. package/dist/harness-api/tool-status.js +26 -0
  131. package/dist/harness-api/workspace.d.ts +12 -7
  132. package/dist/harness-api/workspace.js +47 -25
  133. package/dist/harness-api/ws-server.d.ts +11 -3
  134. package/dist/harness-api/ws-server.js +94 -15
  135. package/dist/host/gates.d.ts +2 -1
  136. package/dist/host/gates.js +10 -2
  137. package/dist/host/main.js +54 -5
  138. package/dist/host/operate.js +67 -5
  139. package/dist/host/stack-config.d.ts +64 -0
  140. package/dist/host/stack-config.js +62 -2
  141. package/dist/keys/client.js +4 -1
  142. package/dist/keys/keys.d.ts +13 -6
  143. package/dist/keys/keys.js +9 -8
  144. package/dist/management-openapi.json +1264 -2171
  145. package/dist/mcp/connect.d.ts +26 -22
  146. package/dist/mcp/connect.js +30 -34
  147. package/dist/mcp/pool.d.ts +3 -6
  148. package/dist/mcp/pool.js +14 -28
  149. package/dist/mcp/preview.d.ts +3 -3
  150. package/dist/mcp/preview.js +10 -21
  151. package/dist/mcp/snapshot.d.ts +16 -24
  152. package/dist/mcp/snapshot.js +33 -45
  153. package/dist/model/capabilities.d.ts +26 -0
  154. package/dist/model/capabilities.js +44 -0
  155. package/dist/model/catalog.d.ts +34 -3
  156. package/dist/model/catalog.js +38 -2
  157. package/dist/model/models.d.ts +2 -0
  158. package/dist/model/models.js +1 -1
  159. package/dist/model/native-blocks.d.ts +35 -0
  160. package/dist/model/native-blocks.js +170 -0
  161. package/dist/model/pi-model.js +114 -94
  162. package/dist/openapi.json +12822 -3943
  163. package/dist/reads/types.d.ts +16 -3
  164. package/dist/record/append.d.ts +3 -1
  165. package/dist/record/append.js +1 -0
  166. package/dist/record/envelope.d.ts +5 -1
  167. package/dist/record/envelope.js +7 -3
  168. package/dist/record/environment.d.ts +25 -0
  169. package/dist/record/environment.js +44 -0
  170. package/dist/record/index.d.ts +1 -1
  171. package/dist/record/index.js +1 -1
  172. package/dist/sandbox/disk-fs.d.ts +95 -0
  173. package/dist/sandbox/disk-fs.js +206 -0
  174. package/dist/sandbox/join.d.ts +17 -18
  175. package/dist/sandbox/join.js +46 -15
  176. package/dist/sandbox/manager.d.ts +47 -21
  177. package/dist/sandbox/manager.js +162 -107
  178. package/dist/sandbox/placement.d.ts +14 -13
  179. package/dist/sandbox/placement.js +31 -14
  180. package/dist/sandbox/pods/client.d.ts +9 -2
  181. package/dist/sandbox/pods/lifecycle.d.ts +36 -27
  182. package/dist/sandbox/pods/lifecycle.js +29 -26
  183. package/dist/sandbox/pods/reconcile.js +18 -12
  184. package/dist/sandbox/pods/spec.d.ts +15 -13
  185. package/dist/sandbox/pods/spec.js +17 -20
  186. package/dist/sandbox/records.d.ts +2 -2
  187. package/dist/sandbox/records.js +3 -3
  188. package/dist/sandbox/resolve.d.ts +37 -27
  189. package/dist/sandbox/resolve.js +73 -41
  190. package/dist/sandbox/runtime-tools.d.ts +9 -0
  191. package/dist/sandbox/runtime-tools.js +16 -0
  192. package/dist/sandbox/session-sandbox.d.ts +4 -6
  193. package/dist/sandbox/session-sandbox.js +47 -94
  194. package/dist/sandbox/setup.d.ts +64 -0
  195. package/dist/sandbox/setup.js +160 -0
  196. package/dist/sandbox/share.d.ts +21 -26
  197. package/dist/sandbox/share.js +36 -63
  198. package/dist/sandbox/skills.d.ts +8 -6
  199. package/dist/sandbox/skills.js +24 -23
  200. package/dist/sandbox/tenant-config.d.ts +12 -14
  201. package/dist/sandbox/tenant-config.js +12 -17
  202. package/dist/sandbox/types.d.ts +6 -0
  203. package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
  204. package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
  205. package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
  206. package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
  207. package/dist/store/postgres/drizzle/0024_flat_manifest.sql +7 -0
  208. package/dist/store/postgres/drizzle/0025_environment_manifests.sql +14 -0
  209. package/dist/store/postgres/drizzle/0026_environment_forks.sql +1 -0
  210. package/dist/store/postgres/drizzle/0027_environment_image_digest.sql +1 -0
  211. package/dist/store/postgres/drizzle/meta/_journal.json +56 -0
  212. package/dist/store/postgres/reads.js +123 -17
  213. package/dist/store/postgres/record-writer.d.ts +4 -4
  214. package/dist/store/postgres/record-writer.js +9 -9
  215. package/dist/store/postgres/record.d.ts +7 -0
  216. package/dist/store/postgres/record.js +23 -2
  217. package/dist/store/postgres/schema.d.ts +1126 -736
  218. package/dist/store/postgres/schema.js +136 -73
  219. package/dist/store/postgres/store.js +195 -67
  220. package/dist/store/types.d.ts +165 -50
  221. package/dist/tenant/advance.d.ts +7 -6
  222. package/dist/tenant/advance.js +118 -42
  223. package/dist/tenant/artifact-tool.d.ts +1 -1
  224. package/dist/tenant/artifact-tool.js +46 -26
  225. package/dist/tenant/commands.d.ts +15 -2
  226. package/dist/tenant/commands.js +115 -14
  227. package/dist/tenant/context.d.ts +56 -24
  228. package/dist/tenant/context.js +4 -15
  229. package/dist/tenant/effects.d.ts +1 -1
  230. package/dist/tenant/effects.js +44 -29
  231. package/dist/tenant/environment-manifests.d.ts +57 -0
  232. package/dist/tenant/environment-manifests.js +200 -0
  233. package/dist/tenant/environments.d.ts +174 -0
  234. package/dist/tenant/environments.js +1044 -0
  235. package/dist/tenant/harness-tokens.d.ts +21 -0
  236. package/dist/tenant/harness-tokens.js +61 -0
  237. package/dist/tenant/host-token.d.ts +1 -1
  238. package/dist/tenant/host-token.js +2 -4
  239. package/dist/tenant/identity-file.d.ts +4 -4
  240. package/dist/tenant/identity-file.js +17 -8
  241. package/dist/tenant/issuers.js +3 -3
  242. package/dist/tenant/model-setting.d.ts +14 -0
  243. package/dist/tenant/model-setting.js +51 -0
  244. package/dist/tenant/principals.js +1 -1
  245. package/dist/tenant/reset.js +3 -3
  246. package/dist/tenant/runtime.d.ts +6 -1
  247. package/dist/tenant/runtime.js +7 -1
  248. package/dist/tenant/session.d.ts +3 -3
  249. package/dist/tenant/session.js +0 -14
  250. package/dist/tenant/sessions.d.ts +18 -14
  251. package/dist/tenant/sessions.js +99 -143
  252. package/dist/tenant/signing-keys.d.ts +5 -1
  253. package/dist/tenant/skill-tool.d.ts +3 -3
  254. package/dist/tenant/skill-tool.js +6 -11
  255. package/dist/tenant/status.js +3 -3
  256. package/dist/tenant/sweep.js +13 -7
  257. package/dist/tenant/tool-results.js +1 -1
  258. package/dist/tenant/tool-search.d.ts +7 -11
  259. package/dist/tenant/tool-search.js +8 -5
  260. package/dist/tenant/transcript.d.ts +245 -36
  261. package/dist/tenant/transcript.js +64 -12
  262. package/dist/tenant/turn-timing.d.ts +43 -0
  263. package/dist/tenant/turn-timing.js +39 -0
  264. package/dist/tenant/types.d.ts +2 -0
  265. package/dist/traces/ids.d.ts +41 -0
  266. package/dist/traces/ids.js +81 -0
  267. package/dist/traces/key.d.ts +6 -0
  268. package/dist/traces/key.js +37 -0
  269. package/dist/traces/map.d.ts +113 -0
  270. package/dist/traces/map.js +289 -0
  271. package/dist/traces/options.d.ts +7 -0
  272. package/dist/traces/options.js +1 -0
  273. package/dist/traces/otlp.d.ts +42 -0
  274. package/dist/traces/otlp.js +84 -0
  275. package/dist/traces/projector.d.ts +58 -0
  276. package/dist/traces/projector.js +200 -0
  277. package/dist/traces/read.d.ts +23 -0
  278. package/dist/traces/read.js +16 -0
  279. package/dist/vault/coverage.d.ts +26 -0
  280. package/dist/vault/coverage.js +62 -0
  281. package/dist/vault/error.d.ts +11 -1
  282. package/dist/vault/error.js +5 -1
  283. package/dist/vault/host-model.d.ts +4 -1
  284. package/dist/vault/host-model.js +10 -2
  285. package/dist/vault/service.d.ts +92 -48
  286. package/dist/vault/service.js +462 -376
  287. package/dist/vault/sources.d.ts +5 -16
  288. package/dist/vault/sources.js +6 -8
  289. package/dist/version.d.ts +1 -1
  290. package/dist/version.js +2 -2
  291. package/package.json +13 -13
  292. package/dist/api/http/routes/sandboxes.d.ts +0 -9
  293. package/dist/api/http/routes/sandboxes.js +0 -153
  294. package/dist/api/http/routes/vaults.d.ts +0 -10
  295. package/dist/api/http/routes/vaults.js +0 -139
  296. package/dist/record/sandbox.d.ts +0 -25
  297. package/dist/record/sandbox.js +0 -43
  298. package/dist/sandbox/capability.d.ts +0 -3
  299. package/dist/sandbox/capability.js +0 -10
  300. package/dist/tenant/sandboxes.d.ts +0 -123
  301. package/dist/tenant/sandboxes.js +0 -566
@@ -16,8 +16,9 @@ export type TurnManifestErr = {
16
16
  readonly message: string;
17
17
  };
18
18
  /**
19
- * Parse `message.manifest`, require it to be a variant of the session pin,
20
- * hash it, and store it. Applies to that turn only (caller must not latch).
19
+ * Parse `message.manifest`, require it to be a variant of the session pin, and store the
20
+ * manifest the turn runs with: the variant with the pin's `runtime` block (a variant never sets
21
+ * one), by its hash. Applies to that turn only (caller must not latch).
21
22
  */
22
23
  export declare function pinTurnManifest(args: {
23
24
  readonly candidate: unknown;
@@ -2,8 +2,29 @@ import { AgentManifestSchema, } from "@nylorun/core/contracts";
2
2
  import { hashManifest } from "@nylorun/core/compatibility";
3
3
  import { isVariantOf } from "@nylorun/core/define";
4
4
  /**
5
- * Parse `message.manifest`, require it to be a variant of the session pin,
6
- * hash it, and store it. Applies to that turn only (caller must not latch).
5
+ * The candidate's agents used as tools with the `runtime` block the pin gave each, when the
6
+ * candidate left it out: a turn variant is written from the definition, and only the Runtime
7
+ * writes `runtime`. A candidate that sets one keeps it, and must then match the pin's.
8
+ */
9
+ function withPinnedDelegates(candidate, pinned) {
10
+ if (candidate.tools === undefined)
11
+ return candidate;
12
+ const pinnedAgents = new Map((pinned.tools ?? []).flatMap((tool) => tool.agent !== undefined && !("kind" in tool.agent) ? [[tool.name, tool.agent]] : []));
13
+ let changed = false;
14
+ const tools = candidate.tools.map((tool) => {
15
+ const agent = tool.agent;
16
+ const prior = pinnedAgents.get(tool.name);
17
+ if (agent === undefined || "kind" in agent || agent.runtime !== undefined || prior?.runtime === undefined)
18
+ return tool;
19
+ changed = true;
20
+ return { ...tool, agent: { ...agent, runtime: prior.runtime } };
21
+ });
22
+ return changed ? { ...candidate, tools } : candidate;
23
+ }
24
+ /**
25
+ * Parse `message.manifest`, require it to be a variant of the session pin, and store the
26
+ * manifest the turn runs with: the variant with the pin's `runtime` block (a variant never sets
27
+ * one), by its hash. Applies to that turn only (caller must not latch).
7
28
  */
8
29
  export function pinTurnManifest(args) {
9
30
  const parsed = AgentManifestSchema.safeParse(args.candidate);
@@ -14,14 +35,17 @@ export function pinTurnManifest(args) {
14
35
  message: "Turn manifest failed AgentManifestSchema validation",
15
36
  };
16
37
  }
17
- const manifest = parsed.data;
18
- if (!isVariantOf(manifest, args.pinned)) {
38
+ const candidate = withPinnedDelegates(parsed.data, args.pinned);
39
+ if (!isVariantOf(candidate, args.pinned)) {
19
40
  return {
20
41
  ok: false,
21
42
  code: "loop.invalid-agent",
22
- message: "Turn manifest is not a variant of the session's pinned manifest",
43
+ message: candidate.runtime === undefined
44
+ ? "Turn manifest is not a variant of the session's pinned manifest"
45
+ : "Turn manifest sets runtime, which only the Runtime writes: leave it out, and the turn runs with the session's",
23
46
  };
24
47
  }
48
+ const manifest = args.pinned.runtime === undefined ? candidate : { ...candidate, runtime: args.pinned.runtime };
25
49
  const hash = hashManifest(manifest);
26
50
  args.store.put(hash, manifest);
27
51
  return { ok: true, manifest, hash };
@@ -72,7 +72,7 @@ export type AdvanceResult = {
72
72
  retryAfterMs: number;
73
73
  };
74
74
  /** A pod sandbox's timers. */
75
- export type SandboxTimer = "idle" | "ttl";
75
+ export type SandboxTimer = "idle";
76
76
  /**
77
77
  * What `DurableExecution.sandbox` asks for: a reconcile, or a timer. A reconcile's `key` merges
78
78
  * repeated sends of one request (the outbox's `signal:<row id>`); without one, every send runs.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The gates service's wire format (blueprint §15, P1.1). Internal: only the loop calls it, so
3
- * it is not in the published OpenAPI documents.
2
+ * The gates service's wire format (blueprint §15, P1.1). Not in the published OpenAPI documents:
3
+ * harnesses call it, and its bodies are published in `@nylorun/core/harness-api` (R4.5).
4
4
  *
5
5
  * `POST /nylorun/v1/model-calls`
6
6
  * - Headers: `Authorization: Bearer <run token>` (F5, `tenant/run-token.ts`), `Idempotency-Key:
@@ -22,31 +22,10 @@
22
22
  * whether or not it exists, `403 gate_forbidden` when it is another session's call. The token
23
23
  * need not be live: a user cancel makes it stale, and still stops its call.
24
24
  */
25
- import { z } from "zod";
26
25
  import type { ModelGateOutcome } from "./model-gate.js";
27
- export declare const MODEL_CALLS_PATH = "/nylorun/v1/model-calls";
26
+ export { MAX_MODEL_CALL_BYTES, MODEL_CALLS_PATH, ModelCallBodySchema, type GateErrorBody, type ModelCallBody, } from "@nylorun/core/harness-api";
28
27
  /** Header naming the Tenant a call is for: optional, and checked against the gate's Tenant. */
29
28
  export declare const TENANT_HEADER = "nylorun-tenant";
30
- /** Largest request body the gate reads: one `ModelCall` with its whole projected history. */
31
- export declare const MAX_MODEL_CALL_BYTES: number;
32
- export declare const ModelCallBodySchema: z.ZodObject<{
33
- effectId: z.ZodString;
34
- invocationId: z.ZodString;
35
- call: z.ZodObject<{
36
- prompt: z.ZodArray<z.ZodUnknown>;
37
- tools: z.ZodArray<z.ZodObject<{
38
- name: z.ZodString;
39
- }, z.core.$loose>>;
40
- }, z.core.$loose>;
41
- }, z.core.$strict>;
42
- export type ModelCallBody = z.infer<typeof ModelCallBodySchema>;
43
29
  export interface ModelCallResponse {
44
30
  readonly outcome: ModelGateOutcome;
45
31
  }
46
- /** An error answer: `{error: {code, message}}`. */
47
- export interface GateErrorBody {
48
- readonly error: {
49
- readonly code: "gate_unauthorized" | "gate_forbidden" | "run_stale" | "invalid_request" | "gate_conflict";
50
- readonly message: string;
51
- };
52
- }
@@ -1,38 +1,3 @@
1
- /**
2
- * The gates service's wire format (blueprint §15, P1.1). Internal: only the loop calls it, so
3
- * it is not in the published OpenAPI documents.
4
- *
5
- * `POST /nylorun/v1/model-calls`
6
- * - Headers: `Authorization: Bearer <run token>` (F5, `tenant/run-token.ts`), `Idempotency-Key:
7
- * <effect id>`, and optionally `Nylorun-Tenant: <tenant id>`, which must then be the token's
8
- * Tenant. Core's credential (`NYLORUN_GATES_TOKEN`) is refused: only a run calls the model.
9
- * - Body: `ModelCallBody`, at most `MAX_MODEL_CALL_BYTES`. It names no session, turn or agent:
10
- * the gate takes them from the token, and refuses a body that names them.
11
- * - `200 {outcome}` once the call has finished, whether the outcome is a candidate or a
12
- * failure; nothing is sent while it runs. `401 gate_unauthorized` for a missing, malformed,
13
- * expired or foreign token, `409 run_stale` when the token's lease is no longer the
14
- * session's (a cancel, a new turn, a takeover), `400 invalid_request` for a malformed Tenant
15
- * header or a bad body, `409 gate_conflict` when a different request already runs under the
16
- * same key.
17
- * - With an `Idempotency-Key` the call outlives its client (P1.2): a re-send with the same key
18
- * and body joins it, or gets its outcome for 30 minutes. Without one, a client that goes
19
- * away aborts the call.
20
- *
21
- * `POST /nylorun/v1/model-calls/{key}/cancel` (same headers): aborts the keyed call; `204`
22
- * whether or not it exists, `403 gate_forbidden` when it is another session's call. The token
23
- * need not be live: a user cancel makes it stale, and still stops its call.
24
- */
25
- import { z } from "zod";
26
- export const MODEL_CALLS_PATH = "/nylorun/v1/model-calls";
1
+ export { MAX_MODEL_CALL_BYTES, MODEL_CALLS_PATH, ModelCallBodySchema, } from "@nylorun/core/harness-api";
27
2
  /** Header naming the Tenant a call is for: optional, and checked against the gate's Tenant. */
28
3
  export const TENANT_HEADER = "nylorun-tenant";
29
- /** Largest request body the gate reads: one `ModelCall` with its whole projected history. */
30
- export const MAX_MODEL_CALL_BYTES = 32 * 1024 * 1024;
31
- export const ModelCallBodySchema = z.strictObject({
32
- effectId: z.string().min(1),
33
- invocationId: z.string().min(1),
34
- call: z.looseObject({
35
- prompt: z.array(z.unknown()),
36
- tools: z.array(z.looseObject({ name: z.string() })),
37
- }),
38
- });
@@ -27,6 +27,13 @@ export interface EgressCredentials {
27
27
  /** Tests: the port requests are sent to, instead of 443. */
28
28
  readonly upstreamPort?: number;
29
29
  }
30
+ /** The answer to a CONNECT the gate serves; a tunnel's byte counts leave it out. */
31
+ export declare const CONNECT_ESTABLISHED = "HTTP/1.1 200 Connection Established\r\n\r\n";
32
+ /** What one terminated connection did so far (R7.3): requests read, credentials set (ids). */
33
+ export interface EgressTally {
34
+ requests: number;
35
+ readonly credentialIds: Set<string>;
36
+ }
30
37
  export interface CredentialTerminator {
31
38
  /**
32
39
  * Answers the CONNECT, terminates TLS for `host` and serves its requests, sending each to one
@@ -39,6 +46,8 @@ export interface CredentialTerminator {
39
46
  address: string;
40
47
  family: number;
41
48
  }[];
49
+ /** Counted as requests are served, read when the connection closes. */
50
+ readonly tally: EgressTally;
42
51
  }): Promise<void>;
43
52
  }
44
53
  export declare function credentialTerminator(options: {
@@ -12,10 +12,14 @@
12
12
  * Nothing here knows a tool: the header name and its format are the credential's. A release the
13
13
  * vault refuses (two secrets for one host, an unreadable value) answers 502 and is logged, never
14
14
  * as an event; a value never is. Upgrades (WebSocket) and HTTP/2 are not served on this path.
15
+ * Each connection keeps a tally for its `environment.egress` event (R7.3, D67): how many requests it
16
+ * read and which credentials it set, by id.
15
17
  */
16
18
  import { createServer } from "node:http";
17
19
  import { Agent, request } from "node:https";
18
20
  import { createSecureContext, TLSSocket } from "node:tls";
21
+ /** The answer to a CONNECT the gate serves; a tunnel's byte counts leave it out. */
22
+ export const CONNECT_ESTABLISHED = "HTTP/1.1 200 Connection Established\r\n\r\n";
19
23
  /** Headers that describe one hop and are never forwarded. */
20
24
  const HOP_BY_HOP = new Set([
21
25
  "connection",
@@ -74,6 +78,7 @@ export function credentialTerminator(options) {
74
78
  return;
75
79
  }
76
80
  const { sandboxId, host } = at;
81
+ at.tally.requests += 1;
77
82
  const release = await credentials.release(sandboxId, host);
78
83
  if (release.status === "refused") {
79
84
  logger.warn("egress_credential_refused", { status: 502, reason: release.reason, sandboxId, host });
@@ -82,8 +87,10 @@ export function credentialTerminator(options) {
82
87
  }
83
88
  const header = release.status === "released" ? release.header.toLowerCase() : undefined;
84
89
  const headers = forwardable(req.rawHeaders, header);
85
- if (release.status === "released")
90
+ if (release.status === "released") {
86
91
  headers[header] = [release.value];
92
+ at.tally.credentialIds.add(release.credentialId);
93
+ }
87
94
  const upstream = request({
88
95
  host,
89
96
  port: credentials.upstreamPort ?? 443,
@@ -111,7 +118,7 @@ export function credentialTerminator(options) {
111
118
  }
112
119
  return {
113
120
  async terminate(client, target) {
114
- const { sandboxId, host, addresses } = target;
121
+ const { sandboxId, host, addresses, tally } = target;
115
122
  const leaf = await credentials.leaf(host);
116
123
  if (client.destroyed)
117
124
  return;
@@ -128,13 +135,13 @@ export function credentialTerminator(options) {
128
135
  lookup,
129
136
  ...(credentials.upstreamCa ? { ca: credentials.upstreamCa } : {}),
130
137
  });
131
- client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
138
+ client.write(CONNECT_ESTABLISHED);
132
139
  const tls = new TLSSocket(client, {
133
140
  isServer: true,
134
141
  secureContext: createSecureContext({ key: leaf.key, cert: leaf.cert }),
135
142
  ALPNProtocols: ["http/1.1"],
136
143
  });
137
- terminated.set(tls, { sandboxId, host, agent });
144
+ terminated.set(tls, { sandboxId, host, agent, tally });
138
145
  tls.on("error", () => tls.destroy());
139
146
  tls.on("close", () => {
140
147
  agent.destroy();
@@ -1,5 +1,6 @@
1
1
  import { type Server } from "node:http";
2
2
  import { type Socket } from "node:net";
3
+ import type { EgressRunningTurn, EnvironmentEventPayload } from "@nylorun/core/contracts";
3
4
  import type { EgressTokenVerdict } from "../sandbox/egress-token.js";
4
5
  import type { SessionStore } from "../store/types.js";
5
6
  import type { Logger } from "../tenant/types.js";
@@ -15,6 +16,17 @@ export interface EgressSandboxes {
15
16
  readonly allow: readonly string[];
16
17
  } | undefined>;
17
18
  }
19
+ /** One connection's record (R7.3, D67): `environment.egress`'s payload. */
20
+ export type EgressConnection = EnvironmentEventPayload<"environment.egress">;
21
+ /** How many running turns one connection names, at most. */
22
+ export declare const EGRESS_RUNNING_TURNS_MAX = 16;
23
+ /** Where egress-gate records connections (R7.3, D67). */
24
+ export interface EgressRecord {
25
+ /** The turns active on the Environment now, at most `EGRESS_RUNNING_TURNS_MAX`. */
26
+ runningTurns(environmentId: string): Promise<readonly EgressRunningTurn[]>;
27
+ /** Writes `connection` to its Environment's stream; a deleted Environment gets nothing. */
28
+ write(connection: EgressConnection): Promise<void>;
29
+ }
18
30
  /** Ports a tunnel may open. */
19
31
  export declare const EGRESS_PORTS: readonly number[];
20
32
  /** Tunnels one sandbox may hold open at once. */
@@ -49,6 +61,8 @@ export interface StartEgressGateOptions {
49
61
  readonly dial?: (address: string, port: number) => Socket;
50
62
  /** The vault and keys, for hosts an `environment_secret` is bound to (R2c). None: tunnels only. */
51
63
  readonly credentials?: EgressCredentials;
64
+ /** Where connections are recorded (R7.3). None: they are only logged. */
65
+ readonly record?: EgressRecord;
52
66
  }
53
67
  export interface EgressGate {
54
68
  /** Where the gate listens, e.g. `http://0.0.0.0:4200`. */
@@ -70,6 +84,13 @@ export declare function startEgressGate(options: StartEgressGateOptions): Promis
70
84
  /**
71
85
  * `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
72
86
  * host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
73
- * sandbox, or one deleted, lost or expired, as `staleHost` judges a host token.
87
+ * Environment, or one deleted or lost, as `staleHost` judges a host token.
74
88
  */
75
89
  export declare function storeEgressSandboxes(store: SessionStore): EgressSandboxes;
90
+ /**
91
+ * `EgressRecord` over the Tenant's record (R7.3, D67). The running turns are the sessions on the
92
+ * Environment whose body has an `activeTurnId`, in id order, at most `EGRESS_RUNNING_TURNS_MAX`.
93
+ * The write is `environment.egress` on the Environment's stream, under its row's lock; a deleted
94
+ * Environment gets nothing.
95
+ */
96
+ export declare function storeEgressRecord(store: SessionStore): EgressRecord;
@@ -21,8 +21,18 @@
21
21
  * `environment_secret` of a live session on the sandbox is bound to. There the gate terminates
22
22
  * TLS and sets the credential's header on every request (`egress-credentials.ts`), still sending
23
23
  * only to the checked address. Every other host gets the tunnel. Any other request
24
- * (an absolute-form plain HTTP request included) is answered 405. Refusals are logged, never as
25
- * events; the token never is. Tunnels close after `idleMs` without traffic in either direction.
24
+ * (an absolute-form plain HTTP request included) is answered 405. Refusals are logged; the token
25
+ * never is. Tunnels close after `idleMs` without traffic in either direction.
26
+ *
27
+ * Every connection to a named host on a live token is recorded once (R7.3, D67), when it closes
28
+ * or at once when the gate refuses it, as `environment.egress` on the Environment's own stream:
29
+ * the connection is the Environment's, since its token names the pod and turns run in parallel
30
+ * (D66). `runningTurns` lists the turns active on the Environment as it opened (read as the name
31
+ * resolves), as context and never as an owner. Host, port, outcome, bytes, duration and, for a
32
+ * credential host, the request count and credential ids; never a header value, a path or a body. A refusal
33
+ * before that (no token, a stale one, no `host:port`) stays a log line, and a client that hangs up
34
+ * before the gate answers leaves nothing. A write never holds the tunnel up, and a failed one is
35
+ * logged (`egress_record_failed`).
26
36
  */
27
37
  import { lookup as dnsLookup } from "node:dns/promises";
28
38
  import { createServer } from "node:http";
@@ -30,7 +40,9 @@ import { BlockList, connect, isIP } from "node:net";
30
40
  import { isSandboxHostPattern } from "@nylorun/core/define";
31
41
  import { isPrivateAddress } from "../tenant/outbound.js";
32
42
  import { bindListener } from "../host/http.js";
33
- import { credentialTerminator } from "./egress-credentials.js";
43
+ import { CONNECT_ESTABLISHED, credentialTerminator, } from "./egress-credentials.js";
44
+ /** How many running turns one connection names, at most. */
45
+ export const EGRESS_RUNNING_TURNS_MAX = 16;
34
46
  /** Ports a tunnel may open. */
35
47
  export const EGRESS_PORTS = [443, 80];
36
48
  /** Tunnels one sandbox may hold open at once. */
@@ -134,6 +146,51 @@ function answer(socket, status, message, headers = []) {
134
146
  body,
135
147
  ].join("\r\n"));
136
148
  }
149
+ function connectionRecord(sink, logger, client, at) {
150
+ let running;
151
+ let written = false;
152
+ const runningTurns = (to) => (running ??= to.runningTurns(at.environmentId));
153
+ const write = (fields) => {
154
+ if (written || !sink)
155
+ return;
156
+ written = true;
157
+ const socket = client;
158
+ const allowed = fields.outcome === "allowed";
159
+ const durationMs = Math.max(0, Date.now() - at.startedAt.getTime());
160
+ const connection = (turns) => ({
161
+ environmentId: at.environmentId,
162
+ host: at.host,
163
+ port: at.port,
164
+ ...fields,
165
+ mode: record.credentialBound ? "credential" : "tunnel",
166
+ credentialBound: record.credentialBound,
167
+ // Up is what the sandbox sent after its CONNECT, down what it got after the gate's 200.
168
+ bytesUp: allowed ? Math.max(0, (socket.bytesRead ?? 0) - at.upFrom) : 0,
169
+ bytesDown: allowed ? Math.max(0, (socket.bytesWritten ?? 0) - CONNECT_ESTABLISHED.length) : 0,
170
+ durationMs,
171
+ startedAt: at.startedAt.toISOString(),
172
+ runningTurns: [...turns],
173
+ });
174
+ runningTurns(sink)
175
+ .then((turns) => sink.write(connection(turns)))
176
+ .catch((error) => logger.error("egress_record_failed", {
177
+ sandboxId: at.environmentId,
178
+ host: at.host,
179
+ error: error instanceof Error ? error.message : String(error),
180
+ }));
181
+ };
182
+ const record = {
183
+ credentialBound: false,
184
+ // A failed read is logged when the write needs it.
185
+ opened: () => void (sink && runningTurns(sink).catch(() => undefined)),
186
+ refused: (status, reason) => write({ outcome: "refused", reason, status }),
187
+ closed: (tally) => write({
188
+ outcome: "allowed",
189
+ ...(tally ? { requests: tally.requests, credentialIds: [...tally.credentialIds] } : {}),
190
+ }),
191
+ };
192
+ return record;
193
+ }
137
194
  export async function startEgressGate(options) {
138
195
  const { logger, sandboxes } = options;
139
196
  const idleMs = options.idleMs ?? EGRESS_IDLE_MS;
@@ -160,11 +217,17 @@ export async function startEgressGate(options) {
160
217
  : undefined;
161
218
  const open = new Map();
162
219
  const tunnels = new Set();
220
+ /** Each connection's record, once its Environment and target are known. */
221
+ const records = new WeakMap();
163
222
  async function onConnect(req, client, head) {
223
+ const startedAt = new Date();
224
+ // What the sandbox sent before the tunnel: its CONNECT, less the bytes after it (`head`).
225
+ const upFrom = (client.bytesRead ?? 0) - head.length;
164
226
  const target = parseTarget(req.url);
165
227
  const refuse = (status, reason, fields = {}, message) => {
166
228
  logger.warn("egress_refused", { status, reason, ...(target ? { host: target.host, port: target.port } : {}), ...fields });
167
229
  answer(client, status, message ?? reason, status === 407 ? ['proxy-authenticate: Basic realm="nylorun-egress"'] : []);
230
+ records.get(client)?.refused(status, reason);
168
231
  };
169
232
  const raw = proxyToken(req.headers["proxy-authorization"]);
170
233
  if (!raw)
@@ -183,12 +246,22 @@ export async function startEgressGate(options) {
183
246
  if (!target)
184
247
  return refuse(403, "target_invalid", { sandboxId }, "CONNECT needs host:port");
185
248
  const { host, port } = target;
249
+ const record = connectionRecord(options.record, logger, client, {
250
+ environmentId: sandboxId,
251
+ host,
252
+ port,
253
+ startedAt,
254
+ upFrom,
255
+ });
256
+ records.set(client, record);
186
257
  if (!EGRESS_PORTS.includes(port))
187
258
  return refuse(403, "port_refused", { sandboxId }, `port ${port} is not allowed; tunnels open to 443 or 80 only`);
188
259
  if (isIP(host.replace(/^\[|\]$/g, "")) !== 0 || !isSandboxHostPattern(host) || host.startsWith("*."))
189
260
  return refuse(403, "not_a_host_name", { sandboxId }, `${host} is not a host name; IP literals are refused`);
190
261
  if (!egressAllows(host, sandbox.allow))
191
262
  return refuse(403, "host_not_allowed", { sandboxId }, `${host} is not in this sandbox's network.allow`);
263
+ // The turns running on the Environment as the connection opens, read while the name resolves.
264
+ record.opened();
192
265
  let mine = open.get(sandboxId);
193
266
  if (!mine)
194
267
  open.set(sandboxId, (mine = new Set()));
@@ -217,10 +290,13 @@ export async function startEgressGate(options) {
217
290
  if (client.destroyed)
218
291
  return;
219
292
  if (terminator && port === 443 && (await options.credentials.bound(sandboxId, host))) {
293
+ record.credentialBound = true;
220
294
  // A client that spoke before the 200 is not one this path serves.
221
295
  if (head.length > 0)
222
296
  return refuse(403, "early_data", { sandboxId }, "wait for the CONNECT answer before sending");
223
- return terminator.terminate(client, { sandboxId, host, addresses: allowed });
297
+ const tally = { requests: 0, credentialIds: new Set() };
298
+ client.once("close", () => record.closed(tally));
299
+ return terminator.terminate(client, { sandboxId, host, addresses: allowed, tally });
224
300
  }
225
301
  const upstream = await dialFirst(allowed.map((entry) => entry.address), port);
226
302
  if (!upstream)
@@ -237,8 +313,11 @@ export async function startEgressGate(options) {
237
313
  upstream.on("timeout", close);
238
314
  upstream.on("error", close);
239
315
  upstream.on("close", () => client.destroy());
240
- client.on("close", () => upstream.destroy());
241
- client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
316
+ client.on("close", () => {
317
+ upstream.destroy();
318
+ record.closed();
319
+ });
320
+ client.write(CONNECT_ESTABLISHED);
242
321
  if (head.length > 0)
243
322
  upstream.write(head);
244
323
  upstream.pipe(client);
@@ -284,6 +363,7 @@ export async function startEgressGate(options) {
284
363
  onConnect(req, client, head).catch((error) => {
285
364
  logger.error("egress_failed", { error: error instanceof Error ? error.message : String(error) });
286
365
  answer(client, 502, "the tunnel failed");
366
+ records.get(client)?.refused(502, "egress_failed");
287
367
  });
288
368
  });
289
369
  server.on("clientError", (_error, socket) => {
@@ -311,22 +391,48 @@ export async function startEgressGate(options) {
311
391
  /**
312
392
  * `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
313
393
  * host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
314
- * sandbox, or one deleted, lost or expired, as `staleHost` judges a host token.
394
+ * Environment, or one deleted or lost, as `staleHost` judges a host token.
315
395
  */
316
396
  export function storeEgressSandboxes(store) {
317
397
  return {
318
398
  async live(sandboxId) {
319
- const sandbox = await store.tx((t) => t.sandboxResource(sandboxId));
399
+ const sandbox = await store.tx((t) => t.environment(sandboxId));
320
400
  const pod = sandbox?.pod;
321
401
  if (!sandbox || sandbox.kind !== "pod" || !pod)
322
402
  return undefined;
323
- if (pod.desired === "deleted" || pod.observed === "lost" || pod.observed === "expired")
403
+ if (pod.desired === "deleted" || pod.observed === "lost")
324
404
  return undefined;
325
405
  return {
326
406
  epoch: pod.hostEpoch,
327
407
  ...(pod.podUid ? { podUid: pod.podUid } : {}),
328
- allow: sandbox.spec.network?.allow ?? [],
408
+ // The applied allowlist: a recipe change writes it at once (Q8).
409
+ allow: sandbox.applied.network?.allowedHosts ?? [],
329
410
  };
330
411
  },
331
412
  };
332
413
  }
414
+ /**
415
+ * `EgressRecord` over the Tenant's record (R7.3, D67). The running turns are the sessions on the
416
+ * Environment whose body has an `activeTurnId`, in id order, at most `EGRESS_RUNNING_TURNS_MAX`.
417
+ * The write is `environment.egress` on the Environment's stream, under its row's lock; a deleted
418
+ * Environment gets nothing.
419
+ */
420
+ export function storeEgressRecord(store) {
421
+ return {
422
+ async runningTurns(environmentId) {
423
+ const sessions = await store.tx((t) => t.sessionsOnEnvironment(environmentId));
424
+ return sessions
425
+ .filter((session) => typeof session.activeTurnId === "string")
426
+ .map((session) => ({ sessionId: session.id, turnId: session.activeTurnId }))
427
+ .sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0))
428
+ .slice(0, EGRESS_RUNNING_TURNS_MAX);
429
+ },
430
+ async write(connection) {
431
+ await store.tx(async (t) => {
432
+ if (!(await t.environment(connection.environmentId, { lock: true })))
433
+ return;
434
+ await t.environmentEvent(connection.environmentId, "environment.egress", connection);
435
+ });
436
+ },
437
+ };
438
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The fixture model (R4.3): a deterministic, scripted model the Model Gate answers with when the
3
+ * Tenant's setting `model.fixture` is on (`PUT /v1/tenant/config/seed {fixtureModel: true}`).
4
+ * The gate answers it itself, metered like any model (a `model_usage` row per call, provider
5
+ * `fixture`, tokens counted from the text, cost 0), so every harness that calls the gate is seen
6
+ * the same way: ours, and one written from the published contract.
7
+ *
8
+ * What it answers is read from the call: the last user message and the offered tools.
9
+ * - `fixture:text`: one text answer, `fixture ok`.
10
+ * - `fixture:tool <name> <json>`: one call of the offered tool `<name>` with `<json>` as its
11
+ * arguments; once the tool's result is in the prompt, a text answer that quotes it.
12
+ * - `fixture:unoffered <name> <json>`: the same call of a tool `<name>` the call does *not*
13
+ * offer (a model that names a tool it was never given), for the check that such a call runs
14
+ * nothing; once a result is in the prompt, a text answer that quotes it.
15
+ * - `fixture:budget`: a `budget_exhausted` failure.
16
+ * - `fixture:slow <seconds>`: a text answer after that long (at most 60 s), or nothing once
17
+ * the call is aborted, for cancel checks.
18
+ * - Anything else keeps the release demo: with a `lookup_order` tool it looks up order
19
+ * `demo-123` once, then answers with the result; on a later user message it says what the
20
+ * first one was; otherwise it says hello.
21
+ */
22
+ import type { ModelCandidate, ModelFailureOutcome } from "@nylorun/core/harness-api";
23
+ import type { RuntimeModelCall } from "../contracts.js";
24
+ export declare const FIXTURE_PROVIDER = "fixture";
25
+ type Answer = ModelCandidate | ModelFailureOutcome;
26
+ /** The fixture model's answer to `call`. */
27
+ export declare function fixtureAnswer(call: RuntimeModelCall, signal?: AbortSignal): Promise<Answer>;
28
+ export {};
@@ -0,0 +1,92 @@
1
+ export const FIXTURE_PROVIDER = "fixture";
2
+ const MARKER = /^fixture:(\w+)(?:\s+([\s\S]*))?$/;
3
+ /** The fixture model's answer to `call`. */
4
+ export async function fixtureAnswer(call, signal) {
5
+ const last = call.prompt.at(-1);
6
+ const users = call.prompt.filter((item) => item.kind === "message" && item.role === "user");
7
+ const marker = MARKER.exec(textOf(users.at(-1)?.content ?? []).trim());
8
+ if (marker)
9
+ return scripted(call, marker[1], marker[2]?.trim() ?? "", last, signal);
10
+ if (last?.kind === "tool-result")
11
+ return candidate(call, `Order lookup complete: ${textOf(last.content, true)}`);
12
+ if (users.length > 1)
13
+ return candidate(call, `I remember: ${textOf(users[0].content)}`);
14
+ if (!call.tools.some((tool) => tool.name === "lookup_order"))
15
+ return candidate(call, "Hello from the fixture model.");
16
+ return candidate(call, [{ type: "tool-call", id: "fixture_lookup", name: "lookup_order", args: { orderId: "demo-123" } }]);
17
+ }
18
+ async function scripted(call, kind, rest, last, signal) {
19
+ switch (kind) {
20
+ case "text":
21
+ return candidate(call, "fixture ok");
22
+ case "tool":
23
+ case "unoffered": {
24
+ if (last?.kind === "tool-result")
25
+ return candidate(call, `fixture tool ${last.status}: ${textOf(last.content, true)}`);
26
+ const space = rest.indexOf(" ");
27
+ const name = space < 0 ? rest : rest.slice(0, space);
28
+ const offered = call.tools.some((tool) => tool.name === name);
29
+ if (kind === "tool" && !offered)
30
+ return candidate(call, `fixture: no tool ${name} is offered`);
31
+ if (kind === "unoffered" && offered)
32
+ return candidate(call, `fixture: tool ${name} is offered`);
33
+ let args = {};
34
+ try {
35
+ if (space >= 0)
36
+ args = JSON.parse(rest.slice(space + 1));
37
+ }
38
+ catch {
39
+ return candidate(call, "fixture: the tool arguments are not JSON");
40
+ }
41
+ if (typeof args !== "object" || args === null || Array.isArray(args))
42
+ return candidate(call, "fixture: the tool arguments are not an object");
43
+ return candidate(call, [{ type: "tool-call", id: "fixture_call", name, args: args }]);
44
+ }
45
+ case "budget":
46
+ return {
47
+ kind: "failed",
48
+ code: "budget_exhausted",
49
+ message: "The fixture model's budget is exhausted (fixture:budget)",
50
+ retryable: false,
51
+ };
52
+ case "slow": {
53
+ const seconds = Math.min(Math.max(Number(rest) || 0, 0), 60);
54
+ await sleep(seconds * 1000, signal);
55
+ return candidate(call, `fixture slept ${seconds} s`);
56
+ }
57
+ default:
58
+ return candidate(call, `fixture: unknown marker ${kind}`);
59
+ }
60
+ }
61
+ function candidate(call, output) {
62
+ const blocks = typeof output === "string" ? [{ type: "text", text: output }] : output;
63
+ const inputTokens = tokens(JSON.stringify(call.prompt));
64
+ const outputTokens = tokens(JSON.stringify(blocks));
65
+ return {
66
+ output: blocks,
67
+ finishReason: blocks.some((block) => block.type === "tool-call") ? "tool-calls" : "stop",
68
+ usage: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, costUsd: 0 },
69
+ evidence: {
70
+ resolvedModel: FIXTURE_PROVIDER,
71
+ extras: { producer: { provider: FIXTURE_PROVIDER, model: FIXTURE_PROVIDER }, costKnown: true },
72
+ },
73
+ };
74
+ }
75
+ /** About four characters a token, at least one. */
76
+ const tokens = (text) => Math.max(1, Math.ceil(text.length / 4));
77
+ function textOf(content, json = false) {
78
+ return content
79
+ .map((part) => (part.type === "text" ? (part.text ?? "") : json ? JSON.stringify(part) : ""))
80
+ .join(" ");
81
+ }
82
+ function sleep(ms, signal) {
83
+ return new Promise((resolve, reject) => {
84
+ if (signal?.aborted)
85
+ return reject(signal.reason);
86
+ const timer = setTimeout(resolve, ms);
87
+ signal?.addEventListener("abort", () => {
88
+ clearTimeout(timer);
89
+ reject(signal.reason);
90
+ }, { once: true });
91
+ });
92
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Serves one model call in the gates service: opens the Tenant's vault (the database's one
3
- * Tenant; a call naming another is refused) and calls the model
4
- * through `inProcessModelGate`, the same code the loop ran before the gate, so outcomes,
3
+ * Tenant; a call naming another is refused) and calls the model (the fixture model while the
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
6
  * the Tenant's ledger. Logs one line per call, never the prompt, the output or a credential.
7
7
  */
8
8
  import { classifyThrown } from "../model/classify.js";
9
- import { callFiles, inProcessModelGate } from "./in-process.js";
9
+ import { callFiles, inProcessModelGate, serve } from "./in-process.js";
10
10
  import { createMeter } from "./meter.js";
11
11
  import { GateRefusal } from "./tenant-vaults.js";
12
12
  export function createModelCallHandler(options) {
@@ -28,7 +28,7 @@ export function createModelCallHandler(options) {
28
28
  ...(settings ? { settings } : {}),
29
29
  ...(blobs ? { files: callFiles(vault.store, blobs) } : {}),
30
30
  });
31
- outcome = await meter.call(vault.store, call, () => gate.call(call, signal));
31
+ outcome = await meter.call(vault.store, call, () => serve(vault.store, gate, call, signal));
32
32
  }
33
33
  catch (error) {
34
34
  if (signal.aborted) {