@alisio/core 0.5.2 → 0.7.0
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/dist/analysis/janitor.d.ts +48 -0
- package/dist/analysis/janitor.js +170 -18
- package/dist/analysis/jobs.d.ts +11 -2
- package/dist/analysis/jobs.js +13 -0
- package/dist/analysis/oci.d.ts +11 -2
- package/dist/analysis/oci.js +20 -4
- package/dist/analysis/python/sources.js +2 -1
- package/dist/analysis/rerun.d.ts +1 -1
- package/dist/analysis/status.d.ts +3 -1
- package/dist/analysis/status.js +3 -0
- package/dist/application.d.ts +28 -2
- package/dist/application.js +189 -4
- package/dist/changelog/data.js +42 -0
- package/dist/config.d.ts +110 -2
- package/dist/config.js +188 -6
- package/dist/core/call-internals.d.ts +22 -0
- package/dist/core/call-internals.js +9 -0
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/runner.js +111 -38
- package/dist/eval/canonical.d.ts +14 -0
- package/dist/eval/canonical.js +40 -0
- package/dist/eval/cassette.d.ts +180 -0
- package/dist/eval/cassette.js +424 -0
- package/dist/eval/compare.d.ts +147 -0
- package/dist/eval/compare.js +436 -0
- package/dist/eval/engine.d.ts +144 -0
- package/dist/eval/engine.js +728 -0
- package/dist/eval/export.d.ts +40 -0
- package/dist/eval/export.js +90 -0
- package/dist/eval/fingerprint.d.ts +88 -0
- package/dist/eval/fingerprint.js +186 -0
- package/dist/eval/normalize.d.ts +68 -0
- package/dist/eval/normalize.js +138 -0
- package/dist/eval/paths.d.ts +11 -0
- package/dist/eval/paths.js +50 -0
- package/dist/eval/plan.d.ts +26 -0
- package/dist/eval/plan.js +44 -0
- package/dist/eval/report.d.ts +53 -0
- package/dist/eval/report.js +258 -0
- package/dist/eval/run-suite.d.ts +68 -0
- package/dist/eval/run-suite.js +94 -0
- package/dist/eval/secrets.d.ts +12 -0
- package/dist/eval/secrets.js +45 -0
- package/dist/eval/stats.d.ts +17 -0
- package/dist/eval/stats.js +50 -0
- package/dist/eval/suite.d.ts +20 -0
- package/dist/eval/suite.js +165 -0
- package/dist/eval/variants.d.ts +35 -0
- package/dist/eval/variants.js +137 -0
- package/dist/eval/verifiers.d.ts +49 -0
- package/dist/eval/verifiers.js +264 -0
- package/dist/forge/broker.d.ts +51 -0
- package/dist/forge/broker.js +244 -0
- package/dist/forge/budget.d.ts +27 -0
- package/dist/forge/budget.js +60 -0
- package/dist/forge/catalog-view.d.ts +24 -0
- package/dist/forge/catalog-view.js +43 -0
- package/dist/forge/errors.d.ts +26 -0
- package/dist/forge/errors.js +49 -0
- package/dist/forge/guides/javascript.d.ts +25 -0
- package/dist/forge/guides/javascript.js +90 -0
- package/dist/forge/guides/provider.d.ts +42 -0
- package/dist/forge/guides/provider.js +96 -0
- package/dist/forge/guides/python-environment.d.ts +15 -0
- package/dist/forge/guides/python-environment.js +81 -0
- package/dist/forge/guides/python.d.ts +38 -0
- package/dist/forge/guides/python.js +170 -0
- package/dist/forge/javascript/engine.d.ts +44 -0
- package/dist/forge/javascript/engine.js +229 -0
- package/dist/forge/javascript/hosts/factory.d.ts +25 -0
- package/dist/forge/javascript/hosts/factory.js +21 -0
- package/dist/forge/javascript/hosts/framing.d.ts +17 -0
- package/dist/forge/javascript/hosts/framing.js +49 -0
- package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
- package/dist/forge/javascript/hosts/isolate-main.js +63 -0
- package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/process-entry.js +3 -0
- package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
- package/dist/forge/javascript/hosts/process-host.js +160 -0
- package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/worker-entry.js +3 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +27 -0
- package/dist/forge/javascript/hosts/worker-host.js +116 -0
- package/dist/forge/javascript/prelude.d.ts +2 -0
- package/dist/forge/javascript/prelude.js +333 -0
- package/dist/forge/javascript/probe.d.ts +2 -0
- package/dist/forge/javascript/probe.js +53 -0
- package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
- package/dist/forge/javascript/runtime-manifest.js +14 -0
- package/dist/forge/javascript/vm-runner.d.ts +21 -0
- package/dist/forge/javascript/vm-runner.js +301 -0
- package/dist/forge/javascript/wasm-asset.d.ts +8 -0
- package/dist/forge/javascript/wasm-asset.js +8 -0
- package/dist/forge/javascript/wasm.d.ts +15 -0
- package/dist/forge/javascript/wasm.js +54 -0
- package/dist/forge/journal.d.ts +105 -0
- package/dist/forge/journal.js +174 -0
- package/dist/forge/kernel.d.ts +76 -0
- package/dist/forge/kernel.js +339 -0
- package/dist/forge/limits.d.ts +38 -0
- package/dist/forge/limits.js +73 -0
- package/dist/forge/nested.d.ts +96 -0
- package/dist/forge/nested.js +214 -0
- package/dist/forge/ports.d.ts +120 -0
- package/dist/forge/ports.js +1 -0
- package/dist/forge/progress.d.ts +24 -0
- package/dist/forge/progress.js +76 -0
- package/dist/forge/protocol.d.ts +110 -0
- package/dist/forge/protocol.js +171 -0
- package/dist/forge/python/backend-selector.d.ts +38 -0
- package/dist/forge/python/backend-selector.js +111 -0
- package/dist/forge/python/backend.d.ts +89 -0
- package/dist/forge/python/backend.js +1 -0
- package/dist/forge/python/engine.d.ts +84 -0
- package/dist/forge/python/engine.js +279 -0
- package/dist/forge/python/frame-parser.d.ts +24 -0
- package/dist/forge/python/frame-parser.js +107 -0
- package/dist/forge/python/frame-transport.d.ts +40 -0
- package/dist/forge/python/frame-transport.js +101 -0
- package/dist/forge/python/import-scan.d.ts +22 -0
- package/dist/forge/python/import-scan.js +133 -0
- package/dist/forge/python/managed-backend.d.ts +30 -0
- package/dist/forge/python/managed-backend.js +111 -0
- package/dist/forge/python/oci-backend.d.ts +27 -0
- package/dist/forge/python/oci-backend.js +84 -0
- package/dist/forge/python/pyodide-backend.d.ts +40 -0
- package/dist/forge/python/pyodide-backend.js +289 -0
- package/dist/forge/python/pyodide-install.d.ts +41 -0
- package/dist/forge/python/pyodide-install.js +167 -0
- package/dist/forge/python/pyodide-manifest.d.ts +42 -0
- package/dist/forge/python/pyodide-manifest.js +376 -0
- package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
- package/dist/forge/python/pyodide-runner-source.js +3 -0
- package/dist/forge/python/rss.d.ts +2 -0
- package/dist/forge/python/rss.js +33 -0
- package/dist/forge/python/scratch-copy.d.ts +6 -0
- package/dist/forge/python/scratch-copy.js +61 -0
- package/dist/forge/python/settle.d.ts +6 -0
- package/dist/forge/python/settle.js +21 -0
- package/dist/forge/python/status.d.ts +8 -0
- package/dist/forge/python/status.js +15 -0
- package/dist/forge/python/wasm-node.d.ts +59 -0
- package/dist/forge/python/wasm-node.js +220 -0
- package/dist/forge/python/wiring.d.ts +39 -0
- package/dist/forge/python/wiring.js +47 -0
- package/dist/forge/registry.d.ts +26 -0
- package/dist/forge/registry.js +83 -0
- package/dist/forge/result.d.ts +58 -0
- package/dist/forge/result.js +86 -0
- package/dist/forge/routing-rule.d.ts +19 -0
- package/dist/forge/routing-rule.js +31 -0
- package/dist/forge/runtime.d.ts +64 -0
- package/dist/forge/runtime.js +88 -0
- package/dist/forge/scratch-files.d.ts +36 -0
- package/dist/forge/scratch-files.js +94 -0
- package/dist/forge/scratch-provider.d.ts +41 -0
- package/dist/forge/scratch-provider.js +79 -0
- package/dist/forge/scratch-quota.d.ts +8 -0
- package/dist/forge/scratch-quota.js +25 -0
- package/dist/forge/selector.d.ts +35 -0
- package/dist/forge/selector.js +51 -0
- package/dist/forge/session-dir.d.ts +59 -0
- package/dist/forge/session-dir.js +239 -0
- package/dist/forge/session-path.d.ts +31 -0
- package/dist/forge/session-path.js +61 -0
- package/dist/forge/slots.d.ts +16 -0
- package/dist/forge/slots.js +47 -0
- package/dist/forge/state-delta.d.ts +54 -0
- package/dist/forge/state-delta.js +99 -0
- package/dist/forge/state-machine.d.ts +20 -0
- package/dist/forge/state-machine.js +40 -0
- package/dist/forge/state-store.d.ts +25 -0
- package/dist/forge/state-store.js +50 -0
- package/dist/forge/task-log.d.ts +9 -0
- package/dist/forge/task-log.js +42 -0
- package/dist/forge/verdict.d.ts +20 -0
- package/dist/forge/verdict.js +28 -0
- package/dist/forge-isolate.d.ts +6 -0
- package/dist/forge-isolate.js +6 -0
- package/dist/index.d.ts +23 -1
- package/dist/index.js +23 -1
- package/dist/permissions/decide.d.ts +42 -0
- package/dist/permissions/decide.js +24 -0
- package/dist/plan/exit-plan.js +2 -1
- package/dist/resources/context.d.ts +2 -1
- package/dist/resources/context.js +2 -1
- package/dist/runtime/access.d.ts +34 -0
- package/dist/runtime/access.js +80 -0
- package/dist/runtime/ids.d.ts +21 -0
- package/dist/runtime/ids.js +41 -1
- package/dist/runtime/process.d.ts +2 -1
- package/dist/runtime/process.js +5 -5
- package/dist/runtime/store.d.ts +11 -0
- package/dist/runtime/store.js +106 -2
- package/dist/tools/analysis.d.ts +25 -2
- package/dist/tools/analysis.js +315 -267
- package/dist/tools/execute.js +6 -7
- package/dist/tools/forge-guide.d.ts +6 -0
- package/dist/tools/forge-guide.js +60 -0
- package/dist/tools/js-run.d.ts +19 -0
- package/dist/tools/js-run.js +126 -0
- package/dist/tools/standard.d.ts +7 -1
- package/dist/tools/standard.js +44 -43
- package/package.json +7 -2
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, local verifiers of the reliability lab (specs/010 D4). A verifier answers
|
|
3
|
+
* `pass`, `fail` or `error`; `error` means it could not run (bad path, missing input, command that
|
|
4
|
+
* cannot start) and is never folded into `fail`. Details never echo file contents.
|
|
5
|
+
*
|
|
6
|
+
* `command` runs a subprocess with a clean environment and a timeout. A subprocess is NOT a
|
|
7
|
+
* sandbox: suites are trusted repository code and the command runs with the user's privileges.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { EvalPathError, resolveInside, unsafeRelativeReason } from "./paths.js";
|
|
14
|
+
const IGNORED_DIRS = new Set([".git", "node_modules"]);
|
|
15
|
+
const MAX_COMMAND_OUTPUT = 1_000_000;
|
|
16
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
17
|
+
/**
|
|
18
|
+
* Hashes every file under `dir` (symlinks by their target text, never followed). `.git` and
|
|
19
|
+
* `node_modules` are skipped at any depth: they are tooling state, not task output.
|
|
20
|
+
*/
|
|
21
|
+
export function snapshotTree(dir) {
|
|
22
|
+
const files = {};
|
|
23
|
+
const walk = (current, prefix) => {
|
|
24
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
25
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
26
|
+
const full = join(current, entry.name);
|
|
27
|
+
if (entry.isSymbolicLink())
|
|
28
|
+
files[rel] = sha256(`symlink:${readlinkSync(full)}`);
|
|
29
|
+
else if (entry.isDirectory()) {
|
|
30
|
+
if (!IGNORED_DIRS.has(entry.name))
|
|
31
|
+
walk(full, rel);
|
|
32
|
+
}
|
|
33
|
+
else if (entry.isFile())
|
|
34
|
+
files[rel] = sha256(readFileSync(full));
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
walk(dir, "");
|
|
38
|
+
const hash = sha256(Object.keys(files)
|
|
39
|
+
.sort()
|
|
40
|
+
.map((path) => `${path}\0${files[path]}`)
|
|
41
|
+
.join("\n"));
|
|
42
|
+
return { files, hash };
|
|
43
|
+
}
|
|
44
|
+
const result = (v, status, detail) => ({
|
|
45
|
+
id: v.id,
|
|
46
|
+
status,
|
|
47
|
+
...(detail === undefined ? {} : { detail }),
|
|
48
|
+
});
|
|
49
|
+
function isMissing(error) {
|
|
50
|
+
return error?.code === "ENOENT";
|
|
51
|
+
}
|
|
52
|
+
function runCommand(v, cwd, tmpDir) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const [bin, ...args] = v.command;
|
|
55
|
+
if (bin === undefined)
|
|
56
|
+
return resolve(result(v, "error", "command is empty."));
|
|
57
|
+
if (!existsSync(cwd) || !statSync(cwd).isDirectory())
|
|
58
|
+
return resolve(result(v, "error", "working directory does not exist."));
|
|
59
|
+
const env = { PATH: process.env.PATH ?? "", NODE_NO_WARNINGS: "1" };
|
|
60
|
+
if (process.env.SystemRoot)
|
|
61
|
+
env.SystemRoot = process.env.SystemRoot;
|
|
62
|
+
if (tmpDir) {
|
|
63
|
+
env.HOME = tmpDir;
|
|
64
|
+
env.TMPDIR = tmpDir;
|
|
65
|
+
}
|
|
66
|
+
let stdout = "";
|
|
67
|
+
let timedOut = false;
|
|
68
|
+
let settled = false;
|
|
69
|
+
const done = (value) => {
|
|
70
|
+
if (settled)
|
|
71
|
+
return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
resolve(value);
|
|
75
|
+
};
|
|
76
|
+
// The child leads its own process group (POSIX) so a timeout can stop grandchildren too.
|
|
77
|
+
const detached = process.platform !== "win32";
|
|
78
|
+
const child = spawn(bin, args, {
|
|
79
|
+
cwd,
|
|
80
|
+
env,
|
|
81
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
82
|
+
shell: false,
|
|
83
|
+
detached,
|
|
84
|
+
});
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
timedOut = true;
|
|
87
|
+
try {
|
|
88
|
+
if (detached && child.pid !== undefined)
|
|
89
|
+
process.kill(-child.pid, "SIGKILL");
|
|
90
|
+
else
|
|
91
|
+
child.kill("SIGKILL");
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
child.kill("SIGKILL");
|
|
95
|
+
}
|
|
96
|
+
}, v.timeoutMs);
|
|
97
|
+
child.stdout.on("data", (chunk) => {
|
|
98
|
+
if (stdout.length < MAX_COMMAND_OUTPUT)
|
|
99
|
+
stdout += chunk.toString("utf8");
|
|
100
|
+
});
|
|
101
|
+
child.on("error", (error) => done(result(v, "error", `command could not start: ${error.message}`)));
|
|
102
|
+
child.on("close", (code, signal) => {
|
|
103
|
+
// The command is the task's own check running agent-written code: a hang is the agent's
|
|
104
|
+
// failure, not an infrastructure error (a command that cannot start stays `error`).
|
|
105
|
+
if (timedOut)
|
|
106
|
+
return done(result(v, "fail", `command timed out after ${v.timeoutMs} ms.`));
|
|
107
|
+
const expected = v.expectExit ?? 0;
|
|
108
|
+
if (code !== expected)
|
|
109
|
+
return done(result(v, "fail", signal ? `terminated by ${signal}.` : `exit ${code}, expected exit ${expected}.`));
|
|
110
|
+
if (v.expectStdoutIncludes !== undefined && !stdout.includes(v.expectStdoutIncludes))
|
|
111
|
+
return done(result(v, "fail", "stdout does not include the expected text."));
|
|
112
|
+
done(result(v, "pass"));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function readTarget(path) {
|
|
117
|
+
try {
|
|
118
|
+
return { data: readFileSync(path) };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (isMissing(error))
|
|
122
|
+
return { missing: true };
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function diffTree(baseline, current) {
|
|
127
|
+
const added = [];
|
|
128
|
+
const deleted = [];
|
|
129
|
+
const modified = [];
|
|
130
|
+
for (const [path, hash] of Object.entries(current.files)) {
|
|
131
|
+
const before = baseline.files[path];
|
|
132
|
+
if (before === undefined)
|
|
133
|
+
added.push(path);
|
|
134
|
+
else if (before !== hash)
|
|
135
|
+
modified.push(path);
|
|
136
|
+
}
|
|
137
|
+
for (const path of Object.keys(baseline.files))
|
|
138
|
+
if (!(path in current.files))
|
|
139
|
+
deleted.push(path);
|
|
140
|
+
return { added, deleted, modified };
|
|
141
|
+
}
|
|
142
|
+
/** `a/b.ts` allows exactly that file; `a/` allows everything below the directory `a`. */
|
|
143
|
+
const isAllowed = (path, allowed) => allowed.some((entry) => (entry.endsWith("/") ? path.startsWith(entry) : path === entry));
|
|
144
|
+
function checkSequence(rule, events) {
|
|
145
|
+
switch (rule.type) {
|
|
146
|
+
case "before": {
|
|
147
|
+
const firstAt = events.findIndex((e) => e.tool === rule.first);
|
|
148
|
+
const thenAt = events.findIndex((e) => e.tool === rule.then);
|
|
149
|
+
if (thenAt === -1)
|
|
150
|
+
return { ok: true };
|
|
151
|
+
return firstAt !== -1 && firstAt < thenAt
|
|
152
|
+
? { ok: true }
|
|
153
|
+
: { ok: false, detail: `"${rule.then}" was called without a prior "${rule.first}".` };
|
|
154
|
+
}
|
|
155
|
+
case "never":
|
|
156
|
+
return events.some((e) => e.tool === rule.tool)
|
|
157
|
+
? { ok: false, detail: `"${rule.tool}" must never be called.` }
|
|
158
|
+
: { ok: true };
|
|
159
|
+
case "max-calls": {
|
|
160
|
+
const calls = events.filter((e) => e.tool === rule.tool).length;
|
|
161
|
+
return calls > rule.max
|
|
162
|
+
? { ok: false, detail: `"${rule.tool}" was called ${calls} times, maximum ${rule.max}.` }
|
|
163
|
+
: { ok: true };
|
|
164
|
+
}
|
|
165
|
+
case "no-repeat-failure": {
|
|
166
|
+
const failed = new Set();
|
|
167
|
+
for (const e of events) {
|
|
168
|
+
if (e.tool !== rule.tool)
|
|
169
|
+
continue;
|
|
170
|
+
const key = e.argsKey ?? "";
|
|
171
|
+
if (failed.has(key))
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
detail: `"${rule.tool}" repeated an identical call that had failed.`,
|
|
175
|
+
};
|
|
176
|
+
if (!e.ok)
|
|
177
|
+
failed.add(key);
|
|
178
|
+
}
|
|
179
|
+
return { ok: true };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function evaluate(v, ctx) {
|
|
184
|
+
switch (v.kind) {
|
|
185
|
+
case "command":
|
|
186
|
+
return runCommand(v, ctx.cwd, ctx.tmpDir);
|
|
187
|
+
case "file-equals": {
|
|
188
|
+
const target = readTarget(resolveInside(ctx.cwd, v.path, "path"));
|
|
189
|
+
if ("missing" in target)
|
|
190
|
+
return result(v, "fail", `${v.path} does not exist.`);
|
|
191
|
+
return sha256(target.data) === v.sha256.toLowerCase()
|
|
192
|
+
? result(v, "pass")
|
|
193
|
+
: result(v, "fail", `${v.path} sha256 differs from the expected value.`);
|
|
194
|
+
}
|
|
195
|
+
case "file-contains": {
|
|
196
|
+
const target = readTarget(resolveInside(ctx.cwd, v.path, "path"));
|
|
197
|
+
if ("missing" in target)
|
|
198
|
+
return result(v, "fail", `${v.path} does not exist.`);
|
|
199
|
+
return target.data.toString("utf8").includes(v.text)
|
|
200
|
+
? result(v, "pass")
|
|
201
|
+
: result(v, "fail", `${v.path} does not contain the expected text.`);
|
|
202
|
+
}
|
|
203
|
+
case "file-absent": {
|
|
204
|
+
const absolute = resolveInside(ctx.cwd, v.path, "path");
|
|
205
|
+
try {
|
|
206
|
+
lstatSync(absolute);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (isMissing(error))
|
|
210
|
+
return result(v, "pass");
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
return result(v, "fail", `${v.path} exists.`);
|
|
214
|
+
}
|
|
215
|
+
case "tree-diff-within": {
|
|
216
|
+
if (!ctx.baseline)
|
|
217
|
+
return result(v, "error", "no fixture snapshot was provided to compare against.");
|
|
218
|
+
for (const entry of v.allowed) {
|
|
219
|
+
const reason = unsafeRelativeReason(entry);
|
|
220
|
+
if (reason)
|
|
221
|
+
return result(v, "error", `allowed entry "${entry}" ${reason}.`);
|
|
222
|
+
}
|
|
223
|
+
const { added, deleted, modified } = diffTree(ctx.baseline, snapshotTree(ctx.cwd));
|
|
224
|
+
const outside = (paths) => paths.filter((p) => !isAllowed(p, v.allowed)).sort();
|
|
225
|
+
const parts = [
|
|
226
|
+
["modified", outside(modified)],
|
|
227
|
+
["added", outside(added)],
|
|
228
|
+
["deleted", outside(deleted)],
|
|
229
|
+
]
|
|
230
|
+
.filter(([, paths]) => paths.length > 0)
|
|
231
|
+
.map(([label, paths]) => `${label}: ${paths.join(", ")}`);
|
|
232
|
+
return parts.length === 0
|
|
233
|
+
? result(v, "pass")
|
|
234
|
+
: result(v, "fail", `changes outside the allowed list (${parts.join("; ")}).`);
|
|
235
|
+
}
|
|
236
|
+
case "tool-sequence": {
|
|
237
|
+
if (!ctx.toolEvents)
|
|
238
|
+
return result(v, "error", "no tool events were recorded for this run.");
|
|
239
|
+
const checked = checkSequence(v.rule, ctx.toolEvents);
|
|
240
|
+
return checked.ok ? result(v, "pass") : result(v, "fail", checked.detail);
|
|
241
|
+
}
|
|
242
|
+
default:
|
|
243
|
+
return result(v, "error", `unknown verifier kind "${v.kind}".`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Runs one verifier; anything that prevents a verdict (including exceptions) is `error`. */
|
|
247
|
+
export async function runVerifier(v, ctx) {
|
|
248
|
+
try {
|
|
249
|
+
return await evaluate(v, ctx);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
const detail = error instanceof EvalPathError
|
|
253
|
+
? error.message
|
|
254
|
+
: `verifier could not run: ${error.code ?? error.message}`;
|
|
255
|
+
return result(v, "error", detail);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Runs the verifiers in order (sequentially: `command` verifiers may share the working directory). */
|
|
259
|
+
export async function runVerifiers(verifiers, ctx) {
|
|
260
|
+
const results = [];
|
|
261
|
+
for (const v of verifiers)
|
|
262
|
+
results.push(await runVerifier(v, ctx));
|
|
263
|
+
return results;
|
|
264
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ForgeProgress } from "@alisio/sdk";
|
|
2
|
+
import type { BudgetLedger } from "./budget.ts";
|
|
3
|
+
import type { SqliteExecutionJournal } from "./journal.ts";
|
|
4
|
+
import type { ForgeLimits } from "./limits.ts";
|
|
5
|
+
import type { NestedCallDispatcher } from "./nested.ts";
|
|
6
|
+
import type { HostReply } from "./ports.ts";
|
|
7
|
+
import type { ForgeOutputItem, GuestMessage } from "./protocol.ts";
|
|
8
|
+
import type { StateDelta } from "./state-delta.ts";
|
|
9
|
+
/** Maximum catalog lookups per execution; they are cheap but not free. */
|
|
10
|
+
export declare const MAX_CATALOG_REQUESTS = 200;
|
|
11
|
+
/** Progress messages one execution may report; the rest is dropped (counted, not stored). */
|
|
12
|
+
export declare const MAX_PROGRESS_MESSAGES = 10000;
|
|
13
|
+
/** Characters of one progress message the host keeps. */
|
|
14
|
+
export declare const MAX_PROGRESS_CHARS = 500;
|
|
15
|
+
/** Bytes of state operations one execution may queue (the commit enforces the stored total). */
|
|
16
|
+
export declare const MAX_STATE_DELTA_BYTES: number;
|
|
17
|
+
export interface BrokerDeps {
|
|
18
|
+
dispatcher: NestedCallDispatcher;
|
|
19
|
+
ledger: BudgetLedger;
|
|
20
|
+
limits: ForgeLimits;
|
|
21
|
+
journal: Pick<SqliteExecutionJournal, "startCall" | "endCall">;
|
|
22
|
+
executionId: string;
|
|
23
|
+
/** Aborts every nested call when the execution is stopped. */
|
|
24
|
+
signal: AbortSignal;
|
|
25
|
+
/** `undefined` when the stored state is switched off. */
|
|
26
|
+
state: StateDelta | undefined;
|
|
27
|
+
/** Coalesced, droppable progress (script progress, a call starting). */
|
|
28
|
+
update(progress: ForgeProgress): void;
|
|
29
|
+
/** Never dropped (a call ended). */
|
|
30
|
+
terminal(progress: ForgeProgress): void;
|
|
31
|
+
engine: ForgeProgress["engine"];
|
|
32
|
+
log(line: string): void;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
export interface CallStats {
|
|
36
|
+
total: number;
|
|
37
|
+
failed: number;
|
|
38
|
+
denied: number;
|
|
39
|
+
}
|
|
40
|
+
export declare class CapabilityBroker {
|
|
41
|
+
#private;
|
|
42
|
+
readonly output: ForgeOutputItem[];
|
|
43
|
+
readonly stats: CallStats;
|
|
44
|
+
/** Progress messages dropped past `MAX_PROGRESS_MESSAGES` (reported in the journal metrics). */
|
|
45
|
+
progressDropped: number;
|
|
46
|
+
constructor(deps: BrokerDeps);
|
|
47
|
+
bind(send: (reply: HostReply) => void): void;
|
|
48
|
+
/** Resolves when every call the guest started has finished (or the timeout passed). */
|
|
49
|
+
drain(timeoutMs: number): Promise<void>;
|
|
50
|
+
receive(message: GuestMessage): Promise<HostReply | undefined>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability broker (spec §4.2, §6.4): the kernel's side of the guest's messages. A script sees
|
|
3
|
+
* proxy functions; every real decision happens here, with the numbers the HOST keeps:
|
|
4
|
+
*
|
|
5
|
+
* - nested calls go through the run's nested-call dispatcher (the same gate as a direct call, no
|
|
6
|
+
* new approvals, D7/D-24), paced by the `BudgetLedger` (total, concurrent, credits);
|
|
7
|
+
* - sizes, output and state are bounded again no matter what the prelude checked;
|
|
8
|
+
* - every nested call is journaled and reported as `ForgeProgress`.
|
|
9
|
+
*
|
|
10
|
+
* Errors that mean "the guest broke a limit" are thrown as `ForgeError`: the engine ends the
|
|
11
|
+
* execution with that code. Errors of one tool call are answered to the guest as `ok: false`.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { describeTool, searchCatalog } from "./catalog-view.js";
|
|
15
|
+
import { ForgeError } from "./errors.js";
|
|
16
|
+
import { clipText } from "./result.js";
|
|
17
|
+
/** Maximum catalog lookups per execution; they are cheap but not free. */
|
|
18
|
+
export const MAX_CATALOG_REQUESTS = 200;
|
|
19
|
+
/** Progress messages one execution may report; the rest is dropped (counted, not stored). */
|
|
20
|
+
export const MAX_PROGRESS_MESSAGES = 10_000;
|
|
21
|
+
/** Characters of one progress message the host keeps. */
|
|
22
|
+
export const MAX_PROGRESS_CHARS = 500;
|
|
23
|
+
/** Bytes of state operations one execution may queue (the commit enforces the stored total). */
|
|
24
|
+
export const MAX_STATE_DELTA_BYTES = 1024 * 1024;
|
|
25
|
+
const DENIED = new Set(["PERMISSION_DENIED", "APPROVAL_REQUIRED"]);
|
|
26
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
27
|
+
const positionOf = (nestedCallId) => Number(nestedCallId.slice(nestedCallId.lastIndexOf(":") + 1));
|
|
28
|
+
export class CapabilityBroker {
|
|
29
|
+
#deps;
|
|
30
|
+
#now;
|
|
31
|
+
output = [];
|
|
32
|
+
stats = { total: 0, failed: 0, denied: 0 };
|
|
33
|
+
#inflight = new Set();
|
|
34
|
+
#send = () => { };
|
|
35
|
+
/** Progress messages dropped past `MAX_PROGRESS_MESSAGES` (reported in the journal metrics). */
|
|
36
|
+
progressDropped = 0;
|
|
37
|
+
#progressSeen = 0;
|
|
38
|
+
#catalogRequests = 0;
|
|
39
|
+
#stateBytes = 0;
|
|
40
|
+
constructor(deps) {
|
|
41
|
+
this.#deps = deps;
|
|
42
|
+
this.#now = deps.now ?? Date.now;
|
|
43
|
+
}
|
|
44
|
+
bind(send) {
|
|
45
|
+
this.#send = send;
|
|
46
|
+
}
|
|
47
|
+
/** Resolves when every call the guest started has finished (or the timeout passed). */
|
|
48
|
+
async drain(timeoutMs) {
|
|
49
|
+
if (this.#inflight.size === 0)
|
|
50
|
+
return;
|
|
51
|
+
await Promise.race([
|
|
52
|
+
Promise.allSettled([...this.#inflight]),
|
|
53
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs).unref?.()),
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
async receive(message) {
|
|
57
|
+
switch (message.type) {
|
|
58
|
+
case "ready":
|
|
59
|
+
return { type: "grant", payload: this.#deps.ledger.grant() };
|
|
60
|
+
case "progress": {
|
|
61
|
+
if (++this.#progressSeen > MAX_PROGRESS_MESSAGES) {
|
|
62
|
+
if (this.progressDropped++ === 0)
|
|
63
|
+
this.#deps.log(`progress: more than ${MAX_PROGRESS_MESSAGES} messages; the rest is dropped`);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const text = clipText(message.payload.message, MAX_PROGRESS_CHARS).text;
|
|
67
|
+
this.#deps.log(`progress: ${text}`);
|
|
68
|
+
this.#deps.update({
|
|
69
|
+
kind: "forge",
|
|
70
|
+
executionId: this.#deps.executionId,
|
|
71
|
+
engine: this.#deps.engine,
|
|
72
|
+
phase: "running",
|
|
73
|
+
message: text,
|
|
74
|
+
...(message.payload.completed !== undefined
|
|
75
|
+
? { completed: message.payload.completed }
|
|
76
|
+
: {}),
|
|
77
|
+
...(message.payload.total !== undefined ? { total: message.payload.total } : {}),
|
|
78
|
+
});
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
case "output":
|
|
82
|
+
this.#recordOutput(message.payload);
|
|
83
|
+
return undefined;
|
|
84
|
+
case "state.set":
|
|
85
|
+
this.#state().set(message.payload.key, message.payload.value);
|
|
86
|
+
this.#countState(message.payload.key, message.payload.value);
|
|
87
|
+
return undefined;
|
|
88
|
+
case "state.delete":
|
|
89
|
+
this.#state().delete(message.payload.key);
|
|
90
|
+
this.#countState(message.payload.key, null);
|
|
91
|
+
return undefined;
|
|
92
|
+
case "catalog":
|
|
93
|
+
return this.#catalog(message.payload);
|
|
94
|
+
case "call": {
|
|
95
|
+
// Not awaited by the engine's dispatch: calls of one script run concurrently.
|
|
96
|
+
const job = this.#call(message.payload);
|
|
97
|
+
this.#inflight.add(job);
|
|
98
|
+
const settled = () => this.#inflight.delete(job);
|
|
99
|
+
job.then(settled, settled);
|
|
100
|
+
await job;
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
#state() {
|
|
108
|
+
const state = this.#deps.state;
|
|
109
|
+
if (!state)
|
|
110
|
+
throw new ForgeError("INVALID_INPUT", "stored state is switched off (forge.state.enabled)");
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
#countState(key, value) {
|
|
114
|
+
this.#stateBytes += key.length + JSON.stringify(value).length;
|
|
115
|
+
if (this.#stateBytes > MAX_STATE_DELTA_BYTES)
|
|
116
|
+
throw new ForgeError("BUDGET_EXCEEDED", "this execution changed too much stored state");
|
|
117
|
+
}
|
|
118
|
+
#recordOutput(item) {
|
|
119
|
+
const bytes = Buffer.byteLength(JSON.stringify(item), "utf8");
|
|
120
|
+
this.#deps.ledger.recordOutput(bytes);
|
|
121
|
+
this.output.push(item);
|
|
122
|
+
if (item.type === "text")
|
|
123
|
+
this.#deps.log(item.text.replace(/\n$/, ""));
|
|
124
|
+
}
|
|
125
|
+
#catalog(payload) {
|
|
126
|
+
if (++this.#catalogRequests > MAX_CATALOG_REQUESTS)
|
|
127
|
+
throw new ForgeError("BUDGET_EXCEEDED", `catalog lookups are limited to ${MAX_CATALOG_REQUESTS} per execution`);
|
|
128
|
+
const tools = this.#deps.dispatcher.catalog();
|
|
129
|
+
const entries = payload.op === "search"
|
|
130
|
+
? searchCatalog(tools, payload.query, payload.limit)
|
|
131
|
+
: [describeTool(tools, payload.query)].filter((entry) => entry !== undefined);
|
|
132
|
+
return { type: "catalog.result", payload: { requestId: payload.requestId, entries } };
|
|
133
|
+
}
|
|
134
|
+
async #call(payload) {
|
|
135
|
+
const { ledger, journal, executionId, limits } = this.#deps;
|
|
136
|
+
// Only names the registry has are ever recorded or broadcast; anything else is a placeholder.
|
|
137
|
+
const tool = this.#deps.dispatcher.knows(payload.tool)
|
|
138
|
+
? payload.tool
|
|
139
|
+
: `unknown:${sha256(payload.tool).slice(0, 12)}`;
|
|
140
|
+
// The host's own count: a call without a credit, or past the total, ends the execution.
|
|
141
|
+
ledger.startCall();
|
|
142
|
+
this.stats.total++;
|
|
143
|
+
const startedAt = this.#now();
|
|
144
|
+
const progress = (status, extra = {}) => ({
|
|
145
|
+
kind: "forge",
|
|
146
|
+
executionId,
|
|
147
|
+
engine: this.#deps.engine,
|
|
148
|
+
phase: "call",
|
|
149
|
+
call: { callId: payload.callId, tool, status, ...extra },
|
|
150
|
+
});
|
|
151
|
+
let journalId;
|
|
152
|
+
let ended = false;
|
|
153
|
+
const end = (status, errorCode) => {
|
|
154
|
+
if (journalId && !ended) {
|
|
155
|
+
ended = true;
|
|
156
|
+
journal.endCall(executionId, journalId, {
|
|
157
|
+
status,
|
|
158
|
+
durationMs: this.#now() - startedAt,
|
|
159
|
+
...(errorCode ? { errorCode } : {}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
let reply;
|
|
164
|
+
try {
|
|
165
|
+
const inputJson = JSON.stringify(payload.input ?? {});
|
|
166
|
+
if (Buffer.byteLength(inputJson, "utf8") > limits.maxCallInputBytes) {
|
|
167
|
+
reply = this.#error(payload.callId, "TOOL_INPUT_INVALID", `tool input is larger than ${limits.maxCallInputBytes} bytes`);
|
|
168
|
+
this.stats.failed++;
|
|
169
|
+
this.#deps.terminal(progress("failed"));
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
this.#deps.log(`call ${tool}`);
|
|
173
|
+
this.#deps.update(progress("started"));
|
|
174
|
+
const outcome = await this.#deps.dispatcher.call({
|
|
175
|
+
name: payload.tool,
|
|
176
|
+
input: payload.input,
|
|
177
|
+
signal: this.#deps.signal,
|
|
178
|
+
onStart: (start) => {
|
|
179
|
+
journalId = journal.startCall({
|
|
180
|
+
executionId,
|
|
181
|
+
n: positionOf(start.nestedCallId),
|
|
182
|
+
toolName: start.name,
|
|
183
|
+
effect: start.effect,
|
|
184
|
+
inputSha256: sha256(inputJson),
|
|
185
|
+
paths: start.paths,
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
if (outcome.ok) {
|
|
190
|
+
const valueJson = JSON.stringify(outcome.value) ?? "null";
|
|
191
|
+
if (Buffer.byteLength(valueJson, "utf8") > limits.maxCallResponseBytes) {
|
|
192
|
+
reply = this.#error(payload.callId, "OUTPUT_LIMIT", `${payload.tool} returned ${Buffer.byteLength(valueJson, "utf8")} bytes, over the ${limits.maxCallResponseBytes} byte limit; ask for less (a smaller range, a filter, a page)`);
|
|
193
|
+
this.stats.failed++;
|
|
194
|
+
end("failed", "OUTPUT_LIMIT");
|
|
195
|
+
this.#deps.terminal(progress("failed", { durationMs: this.#now() - startedAt }));
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
reply = {
|
|
199
|
+
type: "result",
|
|
200
|
+
payload: { callId: payload.callId, ok: true, value: outcome.value ?? null },
|
|
201
|
+
};
|
|
202
|
+
end("succeeded");
|
|
203
|
+
this.#deps.terminal(progress("succeeded", { durationMs: this.#now() - startedAt }));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
if (!journalId) {
|
|
208
|
+
// Refused before it started (denied, unknown, invalid): still on the record.
|
|
209
|
+
journalId = journal.startCall({
|
|
210
|
+
executionId,
|
|
211
|
+
n: positionOf(outcome.nestedCallId),
|
|
212
|
+
toolName: tool,
|
|
213
|
+
effect: (outcome.effect ?? "external"),
|
|
214
|
+
inputSha256: sha256(inputJson),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const denied = DENIED.has(outcome.code);
|
|
218
|
+
if (denied)
|
|
219
|
+
this.stats.denied++;
|
|
220
|
+
else
|
|
221
|
+
this.stats.failed++;
|
|
222
|
+
end("failed", outcome.code);
|
|
223
|
+
this.#deps.log(`call ${tool} failed: ${outcome.code}`);
|
|
224
|
+
this.#deps.terminal(progress(denied ? "denied" : "failed", { durationMs: this.#now() - startedAt }));
|
|
225
|
+
reply = this.#error(payload.callId, outcome.code, outcome.message);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
end("failed", "TOOL_FAILED");
|
|
231
|
+
this.stats.failed++;
|
|
232
|
+
this.#deps.terminal(progress("failed", { durationMs: this.#now() - startedAt }));
|
|
233
|
+
reply = this.#error(payload.callId, "TOOL_FAILED", error instanceof Error ? error.message : String(error));
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
ledger.finishCall();
|
|
237
|
+
}
|
|
238
|
+
this.#send(reply);
|
|
239
|
+
this.#send({ type: "grant", payload: ledger.grant() });
|
|
240
|
+
}
|
|
241
|
+
#error(callId, code, message) {
|
|
242
|
+
return { type: "result", payload: { callId, ok: false, error: { code, message } } };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface BudgetLimits {
|
|
2
|
+
maxToolCalls: number;
|
|
3
|
+
maxConcurrency: number;
|
|
4
|
+
maxRetainedOutputBytes: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class BudgetLedger {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(limits: BudgetLimits);
|
|
9
|
+
get stats(): {
|
|
10
|
+
started: number;
|
|
11
|
+
active: number;
|
|
12
|
+
outputBytes: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Tops the guest's credits up to what it may still use and returns the increment to send in a
|
|
16
|
+
* `grant` message. Call credits never exceed the free concurrency slots or the remaining total.
|
|
17
|
+
*/
|
|
18
|
+
grant(): {
|
|
19
|
+
calls: number;
|
|
20
|
+
bytes: number;
|
|
21
|
+
};
|
|
22
|
+
/** Spends a credit for a new nested call or throws `BUDGET_EXCEEDED` without changing state. */
|
|
23
|
+
startCall(): void;
|
|
24
|
+
finishCall(): void;
|
|
25
|
+
/** Accounts output bytes, or throws `OUTPUT_LIMIT` without partial accounting. */
|
|
26
|
+
recordOutput(bytes: number): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-execution budget: total and concurrent nested calls, the credits granted to the guest
|
|
3
|
+
* (backpressure, spec §5.4) and retained output bytes. The host enforces these numbers even when
|
|
4
|
+
* the guest prelude checks them too. Pure: no I/O.
|
|
5
|
+
*/
|
|
6
|
+
import { ForgeError } from "./errors.js";
|
|
7
|
+
export class BudgetLedger {
|
|
8
|
+
#limits;
|
|
9
|
+
#started = 0;
|
|
10
|
+
#active = 0;
|
|
11
|
+
#outputBytes = 0;
|
|
12
|
+
#callCredits = 0;
|
|
13
|
+
#byteCredits = 0;
|
|
14
|
+
constructor(limits) {
|
|
15
|
+
this.#limits = limits;
|
|
16
|
+
}
|
|
17
|
+
get stats() {
|
|
18
|
+
return { started: this.#started, active: this.#active, outputBytes: this.#outputBytes };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Tops the guest's credits up to what it may still use and returns the increment to send in a
|
|
22
|
+
* `grant` message. Call credits never exceed the free concurrency slots or the remaining total.
|
|
23
|
+
*/
|
|
24
|
+
grant() {
|
|
25
|
+
const callTarget = Math.max(0, Math.min(this.#limits.maxConcurrency - this.#active, this.#limits.maxToolCalls - this.#started));
|
|
26
|
+
const calls = Math.max(0, callTarget - this.#callCredits);
|
|
27
|
+
this.#callCredits += calls;
|
|
28
|
+
const bytes = this.#limits.maxRetainedOutputBytes - this.#outputBytes - this.#byteCredits;
|
|
29
|
+
this.#byteCredits += bytes;
|
|
30
|
+
return { calls, bytes };
|
|
31
|
+
}
|
|
32
|
+
/** Spends a credit for a new nested call or throws `BUDGET_EXCEEDED` without changing state. */
|
|
33
|
+
startCall() {
|
|
34
|
+
if (this.#started >= this.#limits.maxToolCalls) {
|
|
35
|
+
throw new ForgeError("BUDGET_EXCEEDED", `Tool call limit reached (${this.#limits.maxToolCalls})`);
|
|
36
|
+
}
|
|
37
|
+
if (this.#active >= this.#limits.maxConcurrency) {
|
|
38
|
+
throw new ForgeError("BUDGET_EXCEEDED", `Concurrent tool call limit reached (${this.#limits.maxConcurrency})`);
|
|
39
|
+
}
|
|
40
|
+
if (this.#callCredits <= 0) {
|
|
41
|
+
throw new ForgeError("BUDGET_EXCEEDED", "Tool call without a granted credit");
|
|
42
|
+
}
|
|
43
|
+
this.#callCredits--;
|
|
44
|
+
this.#started++;
|
|
45
|
+
this.#active++;
|
|
46
|
+
}
|
|
47
|
+
finishCall() {
|
|
48
|
+
if (this.#active <= 0)
|
|
49
|
+
throw new Error("finishCall without an active call");
|
|
50
|
+
this.#active--;
|
|
51
|
+
}
|
|
52
|
+
/** Accounts output bytes, or throws `OUTPUT_LIMIT` without partial accounting. */
|
|
53
|
+
recordOutput(bytes) {
|
|
54
|
+
if (this.#outputBytes + bytes > this.#limits.maxRetainedOutputBytes) {
|
|
55
|
+
throw new ForgeError("OUTPUT_LIMIT", `Retained output limit reached (${this.#limits.maxRetainedOutputBytes} bytes)`);
|
|
56
|
+
}
|
|
57
|
+
this.#outputBytes += bytes;
|
|
58
|
+
this.#byteCredits = Math.max(0, this.#byteCredits - bytes);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `catalog.search` and `catalog.describe` show a script (spec §6.2, D13): the tools the run
|
|
3
|
+
* lets it call right now, found by name or description, never the whole prompt-sized catalog.
|
|
4
|
+
* Pure over the tool definitions.
|
|
5
|
+
*/
|
|
6
|
+
import type { ToolDefinition } from "@alisio/sdk";
|
|
7
|
+
export declare const CATALOG_DEFAULT_LIMIT = 10;
|
|
8
|
+
export declare const CATALOG_MAX_LIMIT = 25;
|
|
9
|
+
export interface CatalogEntry {
|
|
10
|
+
name: string;
|
|
11
|
+
effect: string;
|
|
12
|
+
summary: string;
|
|
13
|
+
}
|
|
14
|
+
export interface CatalogDetail {
|
|
15
|
+
name: string;
|
|
16
|
+
effect: string;
|
|
17
|
+
description: string;
|
|
18
|
+
inputSchema: unknown;
|
|
19
|
+
}
|
|
20
|
+
/** The first sentence of a description, bounded. */
|
|
21
|
+
export declare function summarize(description: string): string;
|
|
22
|
+
/** Tools whose name or description holds every term of the query (all of them for an empty query). */
|
|
23
|
+
export declare function searchCatalog(tools: readonly ToolDefinition[], query: string, limit?: number): CatalogEntry[];
|
|
24
|
+
export declare function describeTool(tools: readonly ToolDefinition[], name: string): CatalogDetail | undefined;
|