@nylorun/runtime 0.23.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.
- package/CHANGELOG.md +148 -0
- package/README.md +79 -28
- package/dist/adapters/execution/restate.js +1 -1
- package/dist/adapters/replication/pgoutput.d.ts +2 -0
- package/dist/adapters/replication/pgoutput.js +6 -3
- package/dist/adapters/sandbox/local.d.ts +9 -1
- package/dist/adapters/sandbox/local.js +16 -4
- package/dist/adapters/sandbox/virtual.js +4 -1
- package/dist/api/a2a/endpoint.js +5 -1
- package/dist/api/a2a/routes.d.ts +2 -0
- package/dist/api/a2a/routes.js +2 -2
- package/dist/api/a2a/tasks.js +2 -1
- package/dist/api/ag-ui/endpoint.js +4 -2
- package/dist/api/ag-ui/history.js +4 -1
- package/dist/api/ag-ui/routes.d.ts +4 -1
- package/dist/api/ag-ui/routes.js +11 -10
- package/dist/api/ag-ui/translate.js +3 -2
- package/dist/api/components.d.ts +4953 -1204
- package/dist/api/components.js +25 -16
- package/dist/api/gate/keys.js +7 -1
- package/dist/api/gate/routes.d.ts +6 -0
- package/dist/api/gate/routes.js +39 -3
- package/dist/api/http/app.js +4 -4
- package/dist/api/http/define.d.ts +6 -0
- package/dist/api/http/define.js +13 -1
- package/dist/api/http/respond.js +1 -1
- package/dist/api/http/routes/artifacts.js +35 -8
- package/dist/api/http/routes/coverage.d.ts +9 -0
- package/dist/api/http/routes/coverage.js +34 -0
- package/dist/api/http/routes/environments.d.ts +11 -0
- package/dist/api/http/routes/environments.js +283 -0
- package/dist/api/http/routes/mcp.d.ts +1 -1
- package/dist/api/http/routes/mcp.js +2 -2
- package/dist/api/http/routes/me.js +1 -1
- package/dist/api/http/routes/reads.d.ts +45 -9
- package/dist/api/http/routes/reads.js +54 -5
- package/dist/api/http/routes/sessions.js +11 -6
- package/dist/api/http/routes/tenant.js +32 -10
- package/dist/api/openapi.js +31 -26
- package/dist/artifacts/disk.d.ts +59 -0
- package/dist/artifacts/disk.js +563 -0
- package/dist/artifacts/export.d.ts +1 -1
- package/dist/artifacts/export.js +2 -3
- package/dist/artifacts/folders.d.ts +6 -3
- package/dist/artifacts/folders.js +17 -12
- package/dist/artifacts/service.d.ts +2 -1
- package/dist/artifacts/service.js +6 -1
- package/dist/artifacts/workspace.d.ts +1 -1
- package/dist/artifacts/workspace.js +1 -1
- package/dist/core/flow-host.d.ts +2 -3
- package/dist/core/flow-host.js +3 -0
- package/dist/core/provider.d.ts +3 -2
- package/dist/core/provider.js +5 -37
- package/dist/core/sandbox-routes.d.ts +2 -21
- package/dist/core/sandbox-routes.js +10 -43
- package/dist/execution/types.d.ts +1 -1
- package/dist/gates/contract.d.ts +3 -24
- package/dist/gates/contract.js +1 -36
- package/dist/gates/egress-credentials.d.ts +9 -0
- package/dist/gates/egress-credentials.js +11 -4
- package/dist/gates/egress.d.ts +28 -1
- package/dist/gates/egress.js +115 -10
- package/dist/gates/fixture-model.d.ts +28 -0
- package/dist/gates/fixture-model.js +92 -0
- package/dist/gates/handler.js +4 -4
- package/dist/gates/http-tool.d.ts +6 -2
- package/dist/gates/http-tool.js +3 -5
- package/dist/gates/in-process.d.ts +5 -0
- package/dist/gates/in-process.js +12 -1
- package/dist/gates/journal.d.ts +30 -0
- package/dist/gates/journal.js +52 -0
- package/dist/gates/mcp-handler.d.ts +4 -0
- package/dist/gates/mcp-handler.js +5 -1
- package/dist/gates/meter.d.ts +10 -2
- package/dist/gates/meter.js +20 -5
- package/dist/gates/policy.d.ts +49 -0
- package/dist/gates/policy.js +134 -0
- package/dist/gates/tenant-vaults.d.ts +7 -7
- package/dist/gates/tenant-vaults.js +5 -5
- package/dist/gates/tool-calls.d.ts +10 -5
- package/dist/gates/tool-calls.js +28 -10
- package/dist/gates/tool-client.d.ts +8 -0
- package/dist/gates/tool-client.js +27 -11
- package/dist/gates/tool-contract.d.ts +10 -68
- package/dist/gates/tool-contract.js +1 -85
- package/dist/gates/tool-gate.d.ts +1 -1
- package/dist/harness/calls.d.ts +12 -5
- package/dist/harness/calls.js +21 -12
- package/dist/harness/executors.js +12 -9
- package/dist/harness/main.js +18 -5
- package/dist/harness/pod.d.ts +34 -0
- package/dist/harness/pod.js +41 -0
- package/dist/harness/service.d.ts +0 -1
- package/dist/harness/service.js +13 -5
- package/dist/harness/setup.d.ts +27 -0
- package/dist/harness/setup.js +150 -0
- package/dist/harness/ws-client.js +3 -2
- package/dist/harness/ws-port.d.ts +1 -5
- package/dist/harness/ws-port.js +1 -5
- package/dist/harness-api/in-process.js +3 -3
- package/dist/harness-api/record.d.ts +15 -1
- package/dist/harness-api/record.js +174 -44
- package/dist/harness-api/redact.d.ts +3 -0
- package/dist/harness-api/redact.js +45 -0
- package/dist/harness-api/server.d.ts +8 -0
- package/dist/harness-api/server.js +93 -13
- package/dist/harness-api/start.d.ts +10 -3
- package/dist/harness-api/start.js +40 -10
- package/dist/harness-api/tool-journal.d.ts +96 -0
- package/dist/harness-api/tool-journal.js +102 -0
- package/dist/harness-api/tool-status.d.ts +13 -0
- package/dist/harness-api/tool-status.js +26 -0
- package/dist/harness-api/workspace.d.ts +6 -3
- package/dist/harness-api/workspace.js +27 -11
- package/dist/harness-api/ws-server.d.ts +11 -3
- package/dist/harness-api/ws-server.js +94 -15
- package/dist/host/gates.d.ts +2 -1
- package/dist/host/gates.js +10 -2
- package/dist/host/main.js +54 -5
- package/dist/host/operate.js +67 -5
- package/dist/host/stack-config.d.ts +64 -0
- package/dist/host/stack-config.js +61 -1
- package/dist/keys/client.js +4 -1
- package/dist/keys/keys.d.ts +5 -5
- package/dist/keys/keys.js +5 -7
- package/dist/management-openapi.json +999 -2211
- package/dist/mcp/connect.d.ts +25 -20
- package/dist/mcp/connect.js +29 -30
- package/dist/mcp/pool.d.ts +2 -2
- package/dist/mcp/pool.js +1 -1
- package/dist/mcp/preview.d.ts +3 -3
- package/dist/mcp/preview.js +10 -20
- package/dist/mcp/snapshot.d.ts +5 -11
- package/dist/mcp/snapshot.js +6 -7
- package/dist/model/pi-model.js +8 -0
- package/dist/openapi.json +6955 -2526
- package/dist/reads/types.d.ts +16 -3
- package/dist/record/append.d.ts +3 -1
- package/dist/record/append.js +1 -0
- package/dist/record/envelope.d.ts +5 -1
- package/dist/record/envelope.js +7 -3
- package/dist/record/environment.d.ts +25 -0
- package/dist/record/environment.js +44 -0
- package/dist/record/index.d.ts +1 -1
- package/dist/record/index.js +1 -1
- package/dist/sandbox/disk-fs.d.ts +95 -0
- package/dist/sandbox/disk-fs.js +206 -0
- package/dist/sandbox/join.d.ts +17 -18
- package/dist/sandbox/join.js +42 -15
- package/dist/sandbox/manager.d.ts +16 -8
- package/dist/sandbox/manager.js +33 -17
- package/dist/sandbox/placement.d.ts +13 -12
- package/dist/sandbox/placement.js +29 -12
- package/dist/sandbox/pods/client.d.ts +6 -1
- package/dist/sandbox/pods/lifecycle.d.ts +28 -27
- package/dist/sandbox/pods/lifecycle.js +14 -26
- package/dist/sandbox/pods/reconcile.js +18 -12
- package/dist/sandbox/pods/spec.d.ts +15 -13
- package/dist/sandbox/pods/spec.js +16 -20
- package/dist/sandbox/records.d.ts +2 -2
- package/dist/sandbox/records.js +3 -3
- package/dist/sandbox/resolve.d.ts +35 -27
- package/dist/sandbox/resolve.js +66 -41
- package/dist/sandbox/session-sandbox.js +1 -1
- package/dist/sandbox/setup.d.ts +61 -0
- package/dist/sandbox/setup.js +123 -0
- package/dist/sandbox/share.d.ts +11 -21
- package/dist/sandbox/share.js +12 -41
- package/dist/sandbox/tenant-config.d.ts +12 -14
- package/dist/sandbox/tenant-config.js +12 -17
- package/dist/sandbox/types.d.ts +6 -0
- package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
- package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
- package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
- package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
- package/dist/store/postgres/reads.js +117 -17
- package/dist/store/postgres/record-writer.d.ts +4 -4
- package/dist/store/postgres/record-writer.js +9 -9
- package/dist/store/postgres/record.d.ts +7 -0
- package/dist/store/postgres/record.js +23 -2
- package/dist/store/postgres/schema.d.ts +934 -761
- package/dist/store/postgres/schema.js +102 -73
- package/dist/store/postgres/store.js +119 -66
- package/dist/store/types.d.ts +109 -48
- package/dist/tenant/advance.d.ts +7 -6
- package/dist/tenant/advance.js +115 -42
- package/dist/tenant/artifact-tool.js +3 -4
- package/dist/tenant/commands.d.ts +15 -2
- package/dist/tenant/commands.js +93 -12
- package/dist/tenant/context.d.ts +48 -22
- package/dist/tenant/context.js +4 -15
- package/dist/tenant/effects.d.ts +1 -1
- package/dist/tenant/effects.js +37 -20
- package/dist/tenant/environments.d.ts +167 -0
- package/dist/tenant/environments.js +823 -0
- package/dist/tenant/harness-tokens.d.ts +21 -0
- package/dist/tenant/harness-tokens.js +61 -0
- package/dist/tenant/host-token.d.ts +1 -1
- package/dist/tenant/host-token.js +2 -4
- package/dist/tenant/identity-file.d.ts +4 -4
- package/dist/tenant/identity-file.js +17 -8
- package/dist/tenant/issuers.js +3 -3
- package/dist/tenant/principals.js +1 -1
- package/dist/tenant/reset.js +3 -3
- package/dist/tenant/runtime.d.ts +6 -1
- package/dist/tenant/runtime.js +7 -1
- package/dist/tenant/session.d.ts +3 -3
- package/dist/tenant/session.js +0 -14
- package/dist/tenant/sessions.d.ts +13 -13
- package/dist/tenant/sessions.js +61 -138
- package/dist/tenant/signing-keys.d.ts +5 -1
- package/dist/tenant/status.js +3 -3
- package/dist/tenant/sweep.js +12 -6
- package/dist/tenant/transcript.d.ts +210 -20
- package/dist/tenant/transcript.js +62 -11
- package/dist/tenant/turn-timing.d.ts +43 -0
- package/dist/tenant/turn-timing.js +39 -0
- package/dist/tenant/types.d.ts +2 -0
- package/dist/traces/ids.d.ts +42 -0
- package/dist/traces/ids.js +85 -0
- package/dist/traces/key.d.ts +6 -0
- package/dist/traces/key.js +37 -0
- package/dist/traces/map.d.ts +113 -0
- package/dist/traces/map.js +317 -0
- package/dist/traces/options.d.ts +7 -0
- package/dist/traces/options.js +1 -0
- package/dist/traces/otlp.d.ts +42 -0
- package/dist/traces/otlp.js +84 -0
- package/dist/traces/projector.d.ts +58 -0
- package/dist/traces/projector.js +200 -0
- package/dist/traces/read.d.ts +23 -0
- package/dist/traces/read.js +16 -0
- package/dist/vault/coverage.d.ts +26 -0
- package/dist/vault/coverage.js +64 -0
- package/dist/vault/error.d.ts +11 -1
- package/dist/vault/error.js +5 -1
- package/dist/vault/service.d.ts +47 -48
- package/dist/vault/service.js +321 -382
- package/dist/vault/sources.d.ts +5 -16
- package/dist/vault/sources.js +6 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +2 -2
- package/package.json +13 -13
- package/dist/api/http/routes/sandboxes.d.ts +0 -9
- package/dist/api/http/routes/sandboxes.js +0 -153
- package/dist/api/http/routes/vaults.d.ts +0 -10
- package/dist/api/http/routes/vaults.js +0 -139
- package/dist/record/sandbox.d.ts +0 -25
- package/dist/record/sandbox.js +0 -43
- package/dist/tenant/sandboxes.d.ts +0 -123
- package/dist/tenant/sandboxes.js +0 -566
package/dist/gates/egress.js
CHANGED
|
@@ -21,8 +21,17 @@
|
|
|
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
|
|
25
|
-
*
|
|
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, Q6, Q7), when it
|
|
28
|
+
* closes or at once when the gate refuses it: `egress.connection` on the session log of the turn
|
|
29
|
+
* that held the Environment when it opened (read as the name resolves), else `environment.egress`
|
|
30
|
+
* on the Environment's own stream. Host, port, outcome, bytes, duration and, for a credential
|
|
31
|
+
* host, the request count and credential ids; never a header value, a path or a body. A refusal
|
|
32
|
+
* before that (no token, a stale one, no `host:port`) stays a log line, and a client that hangs up
|
|
33
|
+
* before the gate answers leaves nothing. A write never holds the tunnel up, and a failed one is
|
|
34
|
+
* logged (`egress_record_failed`).
|
|
26
35
|
*/
|
|
27
36
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
28
37
|
import { createServer } from "node:http";
|
|
@@ -30,7 +39,9 @@ import { BlockList, connect, isIP } from "node:net";
|
|
|
30
39
|
import { isSandboxHostPattern } from "@nylorun/core/define";
|
|
31
40
|
import { isPrivateAddress } from "../tenant/outbound.js";
|
|
32
41
|
import { bindListener } from "../host/http.js";
|
|
33
|
-
import { credentialTerminator } from "./egress-credentials.js";
|
|
42
|
+
import { CONNECT_ESTABLISHED, credentialTerminator, } from "./egress-credentials.js";
|
|
43
|
+
/** egress-gate as the writer of its events (R4.9): the gate saw the connection itself. */
|
|
44
|
+
export const EGRESS_WRITER = { source: { kind: "egress-gate", id: "egress" }, evidence: "observed" };
|
|
34
45
|
/** Ports a tunnel may open. */
|
|
35
46
|
export const EGRESS_PORTS = [443, 80];
|
|
36
47
|
/** Tunnels one sandbox may hold open at once. */
|
|
@@ -134,6 +145,49 @@ function answer(socket, status, message, headers = []) {
|
|
|
134
145
|
body,
|
|
135
146
|
].join("\r\n"));
|
|
136
147
|
}
|
|
148
|
+
function connectionRecord(sink, logger, client, at) {
|
|
149
|
+
let holding;
|
|
150
|
+
let written = false;
|
|
151
|
+
const holder = (to) => (holding ??= to.holder(at.environmentId));
|
|
152
|
+
const write = (fields) => {
|
|
153
|
+
if (written || !sink)
|
|
154
|
+
return;
|
|
155
|
+
written = true;
|
|
156
|
+
const socket = client;
|
|
157
|
+
const allowed = fields.outcome === "allowed";
|
|
158
|
+
const connection = {
|
|
159
|
+
environmentId: at.environmentId,
|
|
160
|
+
host: at.host,
|
|
161
|
+
port: at.port,
|
|
162
|
+
...fields,
|
|
163
|
+
mode: record.credentialBound ? "credential" : "tunnel",
|
|
164
|
+
credentialBound: record.credentialBound,
|
|
165
|
+
// Up is what the sandbox sent after its CONNECT, down what it got after the gate's 200.
|
|
166
|
+
bytesUp: allowed ? Math.max(0, (socket.bytesRead ?? 0) - at.upFrom) : 0,
|
|
167
|
+
bytesDown: allowed ? Math.max(0, (socket.bytesWritten ?? 0) - CONNECT_ESTABLISHED.length) : 0,
|
|
168
|
+
durationMs: Math.max(0, Date.now() - at.startedAt.getTime()),
|
|
169
|
+
startedAt: at.startedAt.toISOString(),
|
|
170
|
+
};
|
|
171
|
+
holder(sink)
|
|
172
|
+
.then((held) => sink.write(held, connection))
|
|
173
|
+
.catch((error) => logger.error("egress_record_failed", {
|
|
174
|
+
sandboxId: at.environmentId,
|
|
175
|
+
host: at.host,
|
|
176
|
+
error: error instanceof Error ? error.message : String(error),
|
|
177
|
+
}));
|
|
178
|
+
};
|
|
179
|
+
const record = {
|
|
180
|
+
credentialBound: false,
|
|
181
|
+
// A failed read is logged when the write needs it.
|
|
182
|
+
holder: () => void (sink && holder(sink).catch(() => undefined)),
|
|
183
|
+
refused: (status, reason) => write({ outcome: "refused", reason, status }),
|
|
184
|
+
closed: (tally) => write({
|
|
185
|
+
outcome: "allowed",
|
|
186
|
+
...(tally ? { requests: tally.requests, credentialIds: [...tally.credentialIds] } : {}),
|
|
187
|
+
}),
|
|
188
|
+
};
|
|
189
|
+
return record;
|
|
190
|
+
}
|
|
137
191
|
export async function startEgressGate(options) {
|
|
138
192
|
const { logger, sandboxes } = options;
|
|
139
193
|
const idleMs = options.idleMs ?? EGRESS_IDLE_MS;
|
|
@@ -160,11 +214,17 @@ export async function startEgressGate(options) {
|
|
|
160
214
|
: undefined;
|
|
161
215
|
const open = new Map();
|
|
162
216
|
const tunnels = new Set();
|
|
217
|
+
/** Each connection's record, once its Environment and target are known. */
|
|
218
|
+
const records = new WeakMap();
|
|
163
219
|
async function onConnect(req, client, head) {
|
|
220
|
+
const startedAt = new Date();
|
|
221
|
+
// What the sandbox sent before the tunnel: its CONNECT, less the bytes after it (`head`).
|
|
222
|
+
const upFrom = (client.bytesRead ?? 0) - head.length;
|
|
164
223
|
const target = parseTarget(req.url);
|
|
165
224
|
const refuse = (status, reason, fields = {}, message) => {
|
|
166
225
|
logger.warn("egress_refused", { status, reason, ...(target ? { host: target.host, port: target.port } : {}), ...fields });
|
|
167
226
|
answer(client, status, message ?? reason, status === 407 ? ['proxy-authenticate: Basic realm="nylorun-egress"'] : []);
|
|
227
|
+
records.get(client)?.refused(status, reason);
|
|
168
228
|
};
|
|
169
229
|
const raw = proxyToken(req.headers["proxy-authorization"]);
|
|
170
230
|
if (!raw)
|
|
@@ -183,12 +243,22 @@ export async function startEgressGate(options) {
|
|
|
183
243
|
if (!target)
|
|
184
244
|
return refuse(403, "target_invalid", { sandboxId }, "CONNECT needs host:port");
|
|
185
245
|
const { host, port } = target;
|
|
246
|
+
const record = connectionRecord(options.record, logger, client, {
|
|
247
|
+
environmentId: sandboxId,
|
|
248
|
+
host,
|
|
249
|
+
port,
|
|
250
|
+
startedAt,
|
|
251
|
+
upFrom,
|
|
252
|
+
});
|
|
253
|
+
records.set(client, record);
|
|
186
254
|
if (!EGRESS_PORTS.includes(port))
|
|
187
255
|
return refuse(403, "port_refused", { sandboxId }, `port ${port} is not allowed; tunnels open to 443 or 80 only`);
|
|
188
256
|
if (isIP(host.replace(/^\[|\]$/g, "")) !== 0 || !isSandboxHostPattern(host) || host.startsWith("*."))
|
|
189
257
|
return refuse(403, "not_a_host_name", { sandboxId }, `${host} is not a host name; IP literals are refused`);
|
|
190
258
|
if (!egressAllows(host, sandbox.allow))
|
|
191
259
|
return refuse(403, "host_not_allowed", { sandboxId }, `${host} is not in this sandbox's network.allow`);
|
|
260
|
+
// Who holds the Environment as the connection opens, read while the name resolves.
|
|
261
|
+
record.holder();
|
|
192
262
|
let mine = open.get(sandboxId);
|
|
193
263
|
if (!mine)
|
|
194
264
|
open.set(sandboxId, (mine = new Set()));
|
|
@@ -217,10 +287,13 @@ export async function startEgressGate(options) {
|
|
|
217
287
|
if (client.destroyed)
|
|
218
288
|
return;
|
|
219
289
|
if (terminator && port === 443 && (await options.credentials.bound(sandboxId, host))) {
|
|
290
|
+
record.credentialBound = true;
|
|
220
291
|
// A client that spoke before the 200 is not one this path serves.
|
|
221
292
|
if (head.length > 0)
|
|
222
293
|
return refuse(403, "early_data", { sandboxId }, "wait for the CONNECT answer before sending");
|
|
223
|
-
|
|
294
|
+
const tally = { requests: 0, credentialIds: new Set() };
|
|
295
|
+
client.once("close", () => record.closed(tally));
|
|
296
|
+
return terminator.terminate(client, { sandboxId, host, addresses: allowed, tally });
|
|
224
297
|
}
|
|
225
298
|
const upstream = await dialFirst(allowed.map((entry) => entry.address), port);
|
|
226
299
|
if (!upstream)
|
|
@@ -237,8 +310,11 @@ export async function startEgressGate(options) {
|
|
|
237
310
|
upstream.on("timeout", close);
|
|
238
311
|
upstream.on("error", close);
|
|
239
312
|
upstream.on("close", () => client.destroy());
|
|
240
|
-
client.on("close", () =>
|
|
241
|
-
|
|
313
|
+
client.on("close", () => {
|
|
314
|
+
upstream.destroy();
|
|
315
|
+
record.closed();
|
|
316
|
+
});
|
|
317
|
+
client.write(CONNECT_ESTABLISHED);
|
|
242
318
|
if (head.length > 0)
|
|
243
319
|
upstream.write(head);
|
|
244
320
|
upstream.pipe(client);
|
|
@@ -284,6 +360,7 @@ export async function startEgressGate(options) {
|
|
|
284
360
|
onConnect(req, client, head).catch((error) => {
|
|
285
361
|
logger.error("egress_failed", { error: error instanceof Error ? error.message : String(error) });
|
|
286
362
|
answer(client, 502, "the tunnel failed");
|
|
363
|
+
records.get(client)?.refused(502, "egress_failed");
|
|
287
364
|
});
|
|
288
365
|
});
|
|
289
366
|
server.on("clientError", (_error, socket) => {
|
|
@@ -311,22 +388,50 @@ export async function startEgressGate(options) {
|
|
|
311
388
|
/**
|
|
312
389
|
* `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
|
|
313
390
|
* host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
|
|
314
|
-
*
|
|
391
|
+
* Environment, or one deleted or lost, as `staleHost` judges a host token.
|
|
315
392
|
*/
|
|
316
393
|
export function storeEgressSandboxes(store) {
|
|
317
394
|
return {
|
|
318
395
|
async live(sandboxId) {
|
|
319
|
-
const sandbox = await store.tx((t) => t.
|
|
396
|
+
const sandbox = await store.tx((t) => t.environment(sandboxId));
|
|
320
397
|
const pod = sandbox?.pod;
|
|
321
398
|
if (!sandbox || sandbox.kind !== "pod" || !pod)
|
|
322
399
|
return undefined;
|
|
323
|
-
if (pod.desired === "deleted" || pod.observed === "lost"
|
|
400
|
+
if (pod.desired === "deleted" || pod.observed === "lost")
|
|
324
401
|
return undefined;
|
|
325
402
|
return {
|
|
326
403
|
epoch: pod.hostEpoch,
|
|
327
404
|
...(pod.podUid ? { podUid: pod.podUid } : {}),
|
|
328
|
-
|
|
405
|
+
// The applied allowlist: a recipe change writes it at once (Q8).
|
|
406
|
+
allow: sandbox.applied.network?.allowedHosts ?? [],
|
|
329
407
|
};
|
|
330
408
|
},
|
|
331
409
|
};
|
|
332
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* `EgressRecord` over the Tenant's record (R7.3). The holder is the session on the Environment
|
|
413
|
+
* whose body has an `activeTurnId` (turns are serial per Environment). With a holder, the write
|
|
414
|
+
* is `egress.connection` on its session log, at that turn, under the session row's lock; without
|
|
415
|
+
* one, `environment.egress` on the Environment's stream, under its row's lock. A deleted
|
|
416
|
+
* Environment gets nothing.
|
|
417
|
+
*/
|
|
418
|
+
export function storeEgressRecord(store) {
|
|
419
|
+
return {
|
|
420
|
+
async holder(environmentId) {
|
|
421
|
+
const sessions = await store.tx((t) => t.sessionsOnEnvironment(environmentId));
|
|
422
|
+
const holding = sessions.find((session) => typeof session.activeTurnId === "string");
|
|
423
|
+
return holding ? { sessionId: holding.id, turnId: holding.activeTurnId } : undefined;
|
|
424
|
+
},
|
|
425
|
+
async write(holder, connection) {
|
|
426
|
+
await store.tx(async (t) => {
|
|
427
|
+
if (holder) {
|
|
428
|
+
await t.event(holder.sessionId, holder.turnId, "egress.connection", connection, EGRESS_WRITER);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!(await t.environment(connection.environmentId, { lock: true })))
|
|
432
|
+
return;
|
|
433
|
+
await t.environmentEvent(connection.environmentId, "environment.egress", connection);
|
|
434
|
+
});
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/gates/handler.js
CHANGED
|
@@ -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
|
|
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) {
|
|
@@ -17,9 +17,8 @@ export type HttpOutcome = {
|
|
|
17
17
|
readonly kind: "failed";
|
|
18
18
|
readonly code: string;
|
|
19
19
|
readonly message: string;
|
|
20
|
-
/** With `credential_rejected`: the tool's `credential
|
|
20
|
+
/** With `credential_rejected`: the tool's `credential`. */
|
|
21
21
|
readonly server?: string;
|
|
22
|
-
readonly vault?: "installation" | "user";
|
|
23
22
|
/** How many credential values the message had in it, replaced (R2b C8). */
|
|
24
23
|
readonly redacted?: number;
|
|
25
24
|
};
|
|
@@ -45,6 +44,11 @@ export interface HttpToolCall {
|
|
|
45
44
|
readonly effectId: string;
|
|
46
45
|
readonly turnId: string;
|
|
47
46
|
readonly input: unknown;
|
|
47
|
+
/**
|
|
48
|
+
* The call's span context (R7.4), sent as `traceparent`: in the gates service, from the call's
|
|
49
|
+
* journal row (`gates/journal.ts`); in process, from core's `execute` answer. Never a body field.
|
|
50
|
+
*/
|
|
51
|
+
readonly traceparent?: string;
|
|
48
52
|
}
|
|
49
53
|
/** What `runHttpTool` reads of the Tenant: the session's pinned manifest and its credentials. */
|
|
50
54
|
export interface HttpToolTenant {
|
package/dist/gates/http-tool.js
CHANGED
|
@@ -163,7 +163,6 @@ export async function runHttpTool(tenant, call, options) {
|
|
|
163
163
|
let credential = {};
|
|
164
164
|
let identity;
|
|
165
165
|
let target = http;
|
|
166
|
-
let vault;
|
|
167
166
|
if (http.credential !== undefined) {
|
|
168
167
|
const authorized = await tenant.authorize(tool.sessionId, {
|
|
169
168
|
url: http.url,
|
|
@@ -172,10 +171,9 @@ export async function runHttpTool(tenant, call, options) {
|
|
|
172
171
|
if (authorized.status === "refused")
|
|
173
172
|
return failed("http.credential", `The credential '${http.credential}' was refused: ${authorized.reason}`);
|
|
174
173
|
if (authorized.status === "unauthenticated")
|
|
175
|
-
return failed("http.credential", `The session's
|
|
174
|
+
return failed("http.credential", `The session's Environment holds no credential '${http.credential}' for ${authorized.url}`);
|
|
176
175
|
credential = authorized.headers;
|
|
177
176
|
identity = authorized.identity;
|
|
178
|
-
vault = authorized.vault;
|
|
179
177
|
// A credential with `via` sends the call there, a gateway (R2b C2, Q9).
|
|
180
178
|
if (authorized.via !== undefined)
|
|
181
179
|
target = { ...http, url: authorized.via };
|
|
@@ -187,6 +185,7 @@ export async function runHttpTool(tenant, call, options) {
|
|
|
187
185
|
[TURN_ID_HEADER]: call.turnId,
|
|
188
186
|
[AGENT_ID_HEADER]: agentId ?? session.manifest.id,
|
|
189
187
|
"idempotency-key": call.effectId,
|
|
188
|
+
...(call.traceparent ? { traceparent: call.traceparent } : {}),
|
|
190
189
|
},
|
|
191
190
|
policy: options.policy,
|
|
192
191
|
signal: options.signal,
|
|
@@ -199,9 +198,8 @@ export async function runHttpTool(tenant, call, options) {
|
|
|
199
198
|
return outcome;
|
|
200
199
|
return {
|
|
201
200
|
...outcome,
|
|
202
|
-
message: `The service answered HTTP 401: it rejected the credential '${http.credential}' from the
|
|
201
|
+
message: `The service answered HTTP 401: it rejected the credential '${http.credential}' from the session's Environment`,
|
|
203
202
|
server: http.credential,
|
|
204
|
-
...(vault === undefined ? {} : { vault }),
|
|
205
203
|
};
|
|
206
204
|
}
|
|
207
205
|
}
|
|
@@ -41,4 +41,9 @@ export declare function tenantModelGate(options: {
|
|
|
41
41
|
/** The Tenant's Object store, for the files a prompt names. */
|
|
42
42
|
readonly blobs?: BlobStore;
|
|
43
43
|
}): ModelGate;
|
|
44
|
+
/**
|
|
45
|
+
* Serves one call: from the fixture model while the Tenant's `model.fixture` setting is on (R4.3),
|
|
46
|
+
* else from `gate`. Read per call, so a seed applies to the next call.
|
|
47
|
+
*/
|
|
48
|
+
export declare function serve(store: SessionStore, gate: ModelGate, request: ModelGateRequest, signal: AbortSignal): ReturnType<ModelGate["call"]>;
|
|
44
49
|
export declare function inProcessModelGate(options: InProcessModelGateOptions): ModelGate;
|
package/dist/gates/in-process.js
CHANGED
|
@@ -2,6 +2,8 @@ import { piModel } from "../model/pi-model.js";
|
|
|
2
2
|
import { HostModelVault } from "../vault/host-model.js";
|
|
3
3
|
import { createMeter } from "./meter.js";
|
|
4
4
|
import { artifactFiles } from "../artifacts/files.js";
|
|
5
|
+
import { usesFixtureModel } from "../tenant/model-setting.js";
|
|
6
|
+
import { fixtureAnswer } from "./fixture-model.js";
|
|
5
7
|
/**
|
|
6
8
|
* The files a call names, from the Tenant's artifacts, limited to the call's session: in
|
|
7
9
|
* process, the loop's own request; over HTTP, the session of the run token's claims, which the
|
|
@@ -26,9 +28,18 @@ export function tenantModelGate(options) {
|
|
|
26
28
|
});
|
|
27
29
|
const meter = createMeter({ logger: options.logger });
|
|
28
30
|
return {
|
|
29
|
-
call: (request, signal) => meter.call(options.store, request, () => gate
|
|
31
|
+
call: (request, signal) => meter.call(options.store, request, () => serve(options.store, gate, request, signal)),
|
|
30
32
|
};
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Serves one call: from the fixture model while the Tenant's `model.fixture` setting is on (R4.3),
|
|
36
|
+
* else from `gate`. Read per call, so a seed applies to the next call.
|
|
37
|
+
*/
|
|
38
|
+
export async function serve(store, gate, request, signal) {
|
|
39
|
+
if (await store.tx((t) => usesFixtureModel(t)))
|
|
40
|
+
return fixtureAnswer(request.call, signal);
|
|
41
|
+
return gate.call(request, signal);
|
|
42
|
+
}
|
|
32
43
|
export function inProcessModelGate(options) {
|
|
33
44
|
return {
|
|
34
45
|
async call(request, signal) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { McpServerRef } from "../mcp/pool.js";
|
|
2
|
+
import type { RunClaims } from "../tenant/run-token.js";
|
|
3
|
+
import { type HttpToolRef } from "./http-tool.js";
|
|
4
|
+
import type { TenantVaults } from "./tenant-vaults.js";
|
|
5
|
+
/** The call a run asks the gate to send, as the gate's body names it. */
|
|
6
|
+
export type GateToolCall = {
|
|
7
|
+
readonly kind: "mcp";
|
|
8
|
+
readonly server: McpServerRef;
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly arguments: Record<string, unknown>;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: "http";
|
|
13
|
+
readonly tool: HttpToolRef;
|
|
14
|
+
readonly input: unknown;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* What the journal says of a call: why it does not hold it, or that it does, with the call's span
|
|
18
|
+
* context from the row (R7.4), which the gate sends on as `traceparent`.
|
|
19
|
+
*/
|
|
20
|
+
export type JournalAnswer = {
|
|
21
|
+
readonly refused: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly refused?: undefined;
|
|
24
|
+
readonly traceparent?: string;
|
|
25
|
+
};
|
|
26
|
+
export interface JournalCheck {
|
|
27
|
+
/** Whether the journal holds `call` under `key` for the run `claims` names. */
|
|
28
|
+
check(tenantId: string | undefined, claims: RunClaims, key: string, call: GateToolCall): Promise<JournalAnswer>;
|
|
29
|
+
}
|
|
30
|
+
export declare function journalCheck(vaults: TenantVaults): JournalCheck;
|
|
@@ -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 }, {
|
|
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)
|
package/dist/gates/meter.d.ts
CHANGED
|
@@ -13,8 +13,16 @@ export interface MeterOptions {
|
|
|
13
13
|
readonly now?: () => Date;
|
|
14
14
|
}
|
|
15
15
|
export declare function createMeter(options: MeterOptions): Meter;
|
|
16
|
-
/**
|
|
17
|
-
export
|
|
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.
|
package/dist/gates/meter.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
100
|
-
|
|
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. */
|