@foldrun/core 0.2.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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// The model loop, extracted to run in two places: the server process (the
|
|
2
|
+
// classic path) and the runner container's driver (the isolated path). One
|
|
3
|
+
// implementation, because the moment these fork, the sandboxed path becomes
|
|
4
|
+
// the less-tested one — backwards from its whole purpose.
|
|
5
|
+
//
|
|
6
|
+
// Everything here takes values, not stores: the caller resolves secrets,
|
|
7
|
+
// assembles the system prompt and builds MCP servers, then hands this the
|
|
8
|
+
// results. That is what lets the same function run somewhere the vault, the
|
|
9
|
+
// library and the account do not exist.
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { checkPaths, checkBash, isFilesystemTool } from "./confine.js";
|
|
15
|
+
/** Conservative per-token rates for a model the catalogue cannot price —
|
|
16
|
+
* Opus-class — so a ceiling still means something on an unknown gateway. */
|
|
17
|
+
export const FALLBACK_PRICE = { input: 15e-6, output: 75e-6 };
|
|
18
|
+
/**
|
|
19
|
+
* What one assistant turn cost. Cache traffic is counted as input at the
|
|
20
|
+
* input rate, which over-approximates on gateways with cheaper cache reads
|
|
21
|
+
* — on the money side, the honest direction to be wrong in. Pure.
|
|
22
|
+
*/
|
|
23
|
+
export function priceTurn(u, price) {
|
|
24
|
+
const inTok = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
|
|
25
|
+
const outTok = u.output_tokens ?? 0;
|
|
26
|
+
const p = price ?? FALLBACK_PRICE;
|
|
27
|
+
return inTok * p.input + outTok * p.output;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Each step launched together gets an equal share of what is left of the
|
|
31
|
+
* run's budget: the shares sum to the remainder, so a group cannot end
|
|
32
|
+
* over the cap however many steps it fans out to. No budget, no ceiling.
|
|
33
|
+
*/
|
|
34
|
+
export function stepCeiling(budgetUsd, spentUsd, launching) {
|
|
35
|
+
if (!budgetUsd || budgetUsd <= 0)
|
|
36
|
+
return null;
|
|
37
|
+
return Math.max(0, budgetUsd - spentUsd) / Math.max(1, launching);
|
|
38
|
+
}
|
|
39
|
+
export async function executeStep(opts) {
|
|
40
|
+
const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
|
|
41
|
+
let status = "running";
|
|
42
|
+
let costUsd = null;
|
|
43
|
+
let usage = null;
|
|
44
|
+
const texts = [];
|
|
45
|
+
fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
|
|
46
|
+
fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
|
|
47
|
+
const q = query({
|
|
48
|
+
prompt: opts.prompt,
|
|
49
|
+
options: {
|
|
50
|
+
cwd: agentDir,
|
|
51
|
+
model: opts.model,
|
|
52
|
+
// Omitted, not passed as undefined-with-a-default: an unset effort
|
|
53
|
+
// should mean "whatever this model does normally", which is not a
|
|
54
|
+
// level we can name — it moves as models ship.
|
|
55
|
+
...(opts.effort ? { effort: opts.effort } : {}),
|
|
56
|
+
systemPrompt: opts.systemPrompt,
|
|
57
|
+
// Restrict the toolset itself, not just approval: an agent that
|
|
58
|
+
// declares no tools gets none, instead of seeing the full Claude
|
|
59
|
+
// Code toolset and burning turns on denied calls.
|
|
60
|
+
tools: opts.allowed,
|
|
61
|
+
// Deliberately NOT allowedTools: listing them there auto-approves and
|
|
62
|
+
// skips canUseTool entirely, which is where confinement happens. The
|
|
63
|
+
// toolset is still restricted by `tools` above.
|
|
64
|
+
settingSources: [],
|
|
65
|
+
// Sandbox the shell — but only when the shell would otherwise run in a
|
|
66
|
+
// process worth protecting. In an isolated run the container IS the
|
|
67
|
+
// boundary (non-root, cap-dropped, host-less, icc-disabled network),
|
|
68
|
+
// so the SDK's own bash sandbox is redundant there and actively harms:
|
|
69
|
+
// it blocks outbound network, which a declared SSH or curl step
|
|
70
|
+
// legitimately needs. So the container relaxes it (opts.sandboxBash =
|
|
71
|
+
// false) and the container's walls do the containing; the in-process
|
|
72
|
+
// path (the CLI on someone's laptop, and the host executor) keeps it,
|
|
73
|
+
// because there a shell escape reaches the real machine.
|
|
74
|
+
sandbox: {
|
|
75
|
+
enabled: opts.sandboxBash !== false,
|
|
76
|
+
// Must stay false. Auto-allowing bash because it's sandboxed skips
|
|
77
|
+
// canUseTool entirely — and the OS sandbox blocks writes outside the
|
|
78
|
+
// tree but still permits reads, so `cat ../../secrets.json` walked
|
|
79
|
+
// straight out. Verified by probe: true leaks, false denies.
|
|
80
|
+
autoAllowBashIfSandboxed: false,
|
|
81
|
+
failIfUnavailable: false,
|
|
82
|
+
},
|
|
83
|
+
env: opts.env,
|
|
84
|
+
mcpServers: opts.mcpServers,
|
|
85
|
+
canUseTool: async (toolName, input) => {
|
|
86
|
+
// The toolset was already narrowed to what the agent declared, so
|
|
87
|
+
// anything outside it is a denial with a reason the model can act on.
|
|
88
|
+
const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
|
|
89
|
+
if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
|
|
90
|
+
return {
|
|
91
|
+
behavior: "deny",
|
|
92
|
+
message: `Tool ${toolName} is not enabled for this agent.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const verdict = toolName === "Bash"
|
|
96
|
+
? checkBash(String(input.command ?? ""))
|
|
97
|
+
: isFilesystemTool(toolName)
|
|
98
|
+
? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
|
|
99
|
+
: { ok: true };
|
|
100
|
+
if (!verdict.ok) {
|
|
101
|
+
emit("error", verdict.reason);
|
|
102
|
+
return { behavior: "deny", message: verdict.reason };
|
|
103
|
+
}
|
|
104
|
+
return { behavior: "allow", updatedInput: verdict.updatedInput ?? input };
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const deadline = opts.timeoutSec ? Date.now() + opts.timeoutSec * 1000 : null;
|
|
109
|
+
// The hard cap. Spend is counted as each assistant turn arrives — its
|
|
110
|
+
// usage is in the message — so the step can stop at the ceiling rather
|
|
111
|
+
// than learn afterwards that it crossed it. Cache traffic is counted as
|
|
112
|
+
// input at the input rate, which over-approximates: on the money side
|
|
113
|
+
// that is the honest direction to be wrong in.
|
|
114
|
+
const ceiling = typeof opts.budgetUsd === "number" && opts.budgetUsd > 0 ? opts.budgetUsd : null;
|
|
115
|
+
let spentUsd = 0;
|
|
116
|
+
// Open tool calls, by the provider's id, so the result can be paired with
|
|
117
|
+
// its call and the trace can say how long each tool ran.
|
|
118
|
+
const openCalls = new Map();
|
|
119
|
+
for await (const message of q) {
|
|
120
|
+
if (deadline && Date.now() > deadline) {
|
|
121
|
+
emit("error", `step exceeded its ${opts.timeoutSec}s timeout`);
|
|
122
|
+
status = "failed";
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
if (message.type === "assistant") {
|
|
126
|
+
const u = message.message.usage;
|
|
127
|
+
if (ceiling && u) {
|
|
128
|
+
spentUsd += priceTurn(u, opts.price ?? null);
|
|
129
|
+
if (spentUsd >= ceiling) {
|
|
130
|
+
emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (budget: in the flow file)`);
|
|
131
|
+
status = "failed";
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const block of message.message.content) {
|
|
136
|
+
if (block.type === "text" && block.text.trim()) {
|
|
137
|
+
texts.push(block.text);
|
|
138
|
+
emit("text", block.text);
|
|
139
|
+
}
|
|
140
|
+
else if (block.type === "tool_use") {
|
|
141
|
+
openCalls.set(block.id, { name: block.name, at: Date.now() });
|
|
142
|
+
emit("tool", block.name, { call: block.id });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else if (message.type === "user") {
|
|
147
|
+
// Tool results ride back as user turns. The completion event closes
|
|
148
|
+
// the span the call opened; the runner's journal keeps both.
|
|
149
|
+
const content = message.message.content;
|
|
150
|
+
if (Array.isArray(content)) {
|
|
151
|
+
for (const block of content) {
|
|
152
|
+
if (block.type !== "tool_result")
|
|
153
|
+
continue;
|
|
154
|
+
const open = openCalls.get(block.tool_use_id);
|
|
155
|
+
if (!open)
|
|
156
|
+
continue;
|
|
157
|
+
openCalls.delete(block.tool_use_id);
|
|
158
|
+
emit("tool", open.name, {
|
|
159
|
+
call: block.tool_use_id,
|
|
160
|
+
ms: Date.now() - open.at,
|
|
161
|
+
...(block.is_error ? { err: true } : {}),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else if (message.type === "result") {
|
|
167
|
+
status = message.subtype === "success" ? "completed" : "failed";
|
|
168
|
+
costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
|
|
169
|
+
if ("usage" in message && message.usage) {
|
|
170
|
+
const u = message.usage;
|
|
171
|
+
usage = {
|
|
172
|
+
// Cache traffic is input the provider still bills (at its own
|
|
173
|
+
// rates); folding it into the input count over-approximates for
|
|
174
|
+
// gateways with cheaper cache reads, which errs on the honest side.
|
|
175
|
+
inputTokens: (u.input_tokens ?? 0) +
|
|
176
|
+
(u.cache_creation_input_tokens ?? 0) +
|
|
177
|
+
(u.cache_read_input_tokens ?? 0),
|
|
178
|
+
outputTokens: u.output_tokens ?? 0,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// The SDK ends a healthy run with a `result` message. A stream that just
|
|
184
|
+
// stops — subprocess OOM-killed, crashed, or torn down — used to fall
|
|
185
|
+
// through as "completed", which reported a run that produced nothing as a
|
|
186
|
+
// success. Silence is not success.
|
|
187
|
+
if (status === "running") {
|
|
188
|
+
status = "failed";
|
|
189
|
+
emit("error", "the model stream ended without a result — the model process likely died (out of memory?)");
|
|
190
|
+
}
|
|
191
|
+
const result = texts.join("\n").trim() || null;
|
|
192
|
+
// A step's reply is not one message: the model narrates ("Now let me read
|
|
193
|
+
// the outputs…"), calls tools, and answers last. Joining those and reading
|
|
194
|
+
// the first line — which is what the run summary did — reports the plan
|
|
195
|
+
// and never the outcome. The final block is the answer; keep it so the
|
|
196
|
+
// summary has something true to read.
|
|
197
|
+
const conclusion = texts.at(-1)?.trim() || null;
|
|
198
|
+
// output: json — the declared shape is a contract, checked here where both
|
|
199
|
+
// executors run. A reply with no JSON in it is not "done with a caveat";
|
|
200
|
+
// it is the failure the next step would otherwise inherit as garbage.
|
|
201
|
+
let data = undefined;
|
|
202
|
+
if (status === "completed" && opts.output === "json") {
|
|
203
|
+
const extracted = extractJson(result);
|
|
204
|
+
if (extracted.ok) {
|
|
205
|
+
data = extracted.value;
|
|
206
|
+
emit("info", `output: json — ${describeJson(data)}`);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
emit("error", `output: json — ${extracted.reason}`);
|
|
210
|
+
status = "failed";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// "Done" should mean a check passed, not that the model stopped talking.
|
|
214
|
+
if (status === "completed" && opts.verify) {
|
|
215
|
+
const verdict = await checkVerify(agentDir, opts.verify, {
|
|
216
|
+
env: opts.verifyEnv ?? {},
|
|
217
|
+
result,
|
|
218
|
+
conclusion,
|
|
219
|
+
data,
|
|
220
|
+
modelEnv: opts.env,
|
|
221
|
+
});
|
|
222
|
+
emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`);
|
|
223
|
+
if (verdict.detail.trim())
|
|
224
|
+
emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000));
|
|
225
|
+
if (!verdict.ok)
|
|
226
|
+
status = "failed";
|
|
227
|
+
}
|
|
228
|
+
return { status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage };
|
|
229
|
+
}
|
|
230
|
+
// ------------------------------------------------------------ output: json
|
|
231
|
+
/**
|
|
232
|
+
* The one JSON value a reply carries. A ```json fence wins — it is what the
|
|
233
|
+
* step was asked to write — and the LAST one at that, because a model that
|
|
234
|
+
* shows its working often quotes an earlier draft first. Without a fence,
|
|
235
|
+
* the reply's trailing `{…}` or `[…]` is tried, so a model that answered
|
|
236
|
+
* with bare JSON is not failed for good behaviour.
|
|
237
|
+
*/
|
|
238
|
+
export function extractJson(result) {
|
|
239
|
+
const text = (result ?? "").trim();
|
|
240
|
+
if (!text)
|
|
241
|
+
return { ok: false, reason: "the reply was empty" };
|
|
242
|
+
const fences = [...text.matchAll(/```(?:json|JSON)?\s*\n([\s\S]*?)\n\s*```/g)].map((m) => m[1].trim());
|
|
243
|
+
const candidates = fences.length ? fences.reverse() : [];
|
|
244
|
+
if (!candidates.length) {
|
|
245
|
+
// The largest trailing bracketed span: walk back from the end to the
|
|
246
|
+
// last closing bracket, then forward to the matching opener.
|
|
247
|
+
const close = Math.max(text.lastIndexOf("}"), text.lastIndexOf("]"));
|
|
248
|
+
if (close !== -1) {
|
|
249
|
+
const opener = text[close] === "}" ? "{" : "[";
|
|
250
|
+
for (let i = text.indexOf(opener); i !== -1 && i < close; i = text.indexOf(opener, i + 1)) {
|
|
251
|
+
candidates.push(text.slice(i, close + 1));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
let lastError = "no JSON value found in the reply";
|
|
256
|
+
for (const c of candidates) {
|
|
257
|
+
try {
|
|
258
|
+
return { ok: true, value: JSON.parse(c) };
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
lastError = `could not parse: ${err instanceof Error ? err.message : String(err)}`;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { ok: false, reason: lastError };
|
|
265
|
+
}
|
|
266
|
+
function describeJson(value) {
|
|
267
|
+
if (Array.isArray(value))
|
|
268
|
+
return `an array of ${value.length}`;
|
|
269
|
+
if (value && typeof value === "object")
|
|
270
|
+
return `an object with keys ${Object.keys(value).slice(0, 8).join(", ")}`;
|
|
271
|
+
return `a ${typeof value}`;
|
|
272
|
+
}
|
|
273
|
+
// ----------------------------------------------------------------- verify:
|
|
274
|
+
/** The eval vocabulary a `verify:` may borrow. Anything else is a shell command. */
|
|
275
|
+
const VERIFY_ASSERTION = /^(contains|not-contains|matches|file|judge):\s*([\s\S]+)$/;
|
|
276
|
+
/**
|
|
277
|
+
* Decide a `verify:`. Two dialects, one key: an eval assertion, when the
|
|
278
|
+
* value starts with one of the eval file's assertion words, or a shell
|
|
279
|
+
* command. The assertion form exists so that a flow and an eval share a
|
|
280
|
+
* vocabulary — "the output must mention the price" is the same sentence in
|
|
281
|
+
* both places — and so that the commonest checks need no shell at all.
|
|
282
|
+
*/
|
|
283
|
+
export async function checkVerify(agentDir, verify, ctx) {
|
|
284
|
+
const m = verify.trim().match(VERIFY_ASSERTION);
|
|
285
|
+
if (!m) {
|
|
286
|
+
const { code, out } = await runVerify(agentDir, verify, ctx.env, ctx.data);
|
|
287
|
+
return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
|
|
288
|
+
}
|
|
289
|
+
const [, kind, rawValue] = m;
|
|
290
|
+
const value = rawValue.trim().replace(/^["']|["']$/g, "");
|
|
291
|
+
const output = (kind === "judge" ? ctx.result : (ctx.conclusion ?? ctx.result)) ?? "";
|
|
292
|
+
const hay = output.toLowerCase();
|
|
293
|
+
switch (kind) {
|
|
294
|
+
case "contains": {
|
|
295
|
+
const ok = hay.includes(value.toLowerCase());
|
|
296
|
+
return { ok, headline: ok ? "found" : `"${value}" not in the reply`, detail: "" };
|
|
297
|
+
}
|
|
298
|
+
case "not-contains": {
|
|
299
|
+
const ok = !hay.includes(value.toLowerCase());
|
|
300
|
+
return { ok, headline: ok ? "absent" : `"${value}" appeared in the reply`, detail: "" };
|
|
301
|
+
}
|
|
302
|
+
case "matches": {
|
|
303
|
+
try {
|
|
304
|
+
const ok = new RegExp(value, "i").test(output);
|
|
305
|
+
return { ok, headline: ok ? "matched" : "no match", detail: "" };
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return { ok: false, headline: "invalid regular expression", detail: "" };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
case "file": {
|
|
312
|
+
const target = path.resolve(agentDir, value);
|
|
313
|
+
if (!target.startsWith(path.resolve(agentDir) + path.sep)) {
|
|
314
|
+
return { ok: false, headline: "path escapes the agent directory", detail: "" };
|
|
315
|
+
}
|
|
316
|
+
const ok = fs.existsSync(target) && fs.statSync(target).size > 0;
|
|
317
|
+
return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
|
|
318
|
+
}
|
|
319
|
+
case "judge": {
|
|
320
|
+
const verdict = await judgeReply(value, output, ctx.modelEnv ?? {});
|
|
321
|
+
const ok = /^\s*PASS\b/i.test(verdict);
|
|
322
|
+
return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return { ok: false, headline: `unknown check "${kind}"`, detail: "" };
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* A toolless, fast-tier grading call: does the reply satisfy the sentence?
|
|
329
|
+
* Same shape as the eval judge, and answered with one word first so the
|
|
330
|
+
* verdict is a prefix test rather than a reading.
|
|
331
|
+
*/
|
|
332
|
+
async function judgeReply(rubric, output, env) {
|
|
333
|
+
const texts = [];
|
|
334
|
+
try {
|
|
335
|
+
const q = query({
|
|
336
|
+
prompt: `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
337
|
+
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
338
|
+
`<reply>\n${output.slice(0, 40_000)}\n</reply>`,
|
|
339
|
+
options: {
|
|
340
|
+
model: "haiku",
|
|
341
|
+
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
342
|
+
tools: [],
|
|
343
|
+
settingSources: [],
|
|
344
|
+
env,
|
|
345
|
+
maxTurns: 1,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
for await (const message of q) {
|
|
349
|
+
if (message.type === "assistant") {
|
|
350
|
+
for (const block of message.message.content) {
|
|
351
|
+
if (block.type === "text")
|
|
352
|
+
texts.push(block.text);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
return `FAIL — the judge could not run: ${err instanceof Error ? err.message : String(err)}`;
|
|
359
|
+
}
|
|
360
|
+
return texts.join("\n").trim();
|
|
361
|
+
}
|
|
362
|
+
// Verification runs in the agent's directory with its secrets available, so
|
|
363
|
+
// a check can be as simple as `npm run build` or `test -s outputs/report.md`.
|
|
364
|
+
function runVerify(agentDir, command, env, data) {
|
|
365
|
+
return new Promise((resolve) => {
|
|
366
|
+
// No clock of the platform's: a verify like `npm run build` takes what
|
|
367
|
+
// it takes, and the step's own `timeout:` is the bound if the flow set one.
|
|
368
|
+
const child = spawn("bash", ["-lc", command], {
|
|
369
|
+
cwd: agentDir,
|
|
370
|
+
env: { ...process.env, ...env },
|
|
371
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
372
|
+
});
|
|
373
|
+
// An `output: json` step's data arrives on stdin, so a check can be
|
|
374
|
+
// `jq -e '.total > 0'` — arithmetic in a real tool, reading the value
|
|
375
|
+
// the step actually returned rather than re-parsing its prose.
|
|
376
|
+
child.stdin.on("error", () => { });
|
|
377
|
+
if (data !== undefined)
|
|
378
|
+
child.stdin.end(JSON.stringify(data));
|
|
379
|
+
else
|
|
380
|
+
child.stdin.end();
|
|
381
|
+
let out = "";
|
|
382
|
+
const append = (c) => {
|
|
383
|
+
if (out.length < 4000)
|
|
384
|
+
out += c.toString();
|
|
385
|
+
};
|
|
386
|
+
child.stdout.on("data", append);
|
|
387
|
+
child.stderr.on("data", append);
|
|
388
|
+
child.on("error", (e) => resolve({ code: null, out: e.message }));
|
|
389
|
+
child.on("close", (code) => resolve({ code, out }));
|
|
390
|
+
});
|
|
391
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { STORAGE_DIR } from "./store.ts";
|
|
2
|
+
/** The directory a run sees, and the one this module mirrors into it.
|
|
3
|
+
* Declared in store.ts, where saveWorkspace also needs it; re-exported so
|
|
4
|
+
* callers of this module do not need to know that. */
|
|
5
|
+
export { STORAGE_DIR };
|
|
6
|
+
export interface FileRecord {
|
|
7
|
+
/** Path under storage/, forward-slashed. The primary key: it is also exactly
|
|
8
|
+
* what the agent typed, which is why the record is keyed by it and not by
|
|
9
|
+
* a generated id nobody could reach from inside a run. */
|
|
10
|
+
path: string;
|
|
11
|
+
/** Content hash. Blobs are stored under it, so re-uploading the same bytes
|
|
12
|
+
* under a second name costs nothing and a rename moves no data. */
|
|
13
|
+
sha: string;
|
|
14
|
+
size: number;
|
|
15
|
+
mime: string;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
/** Provenance: `user:<email>` or `run:<runId>:<agent>`. The reason the
|
|
18
|
+
* dashboard can answer "which run produced this?" without a join table. */
|
|
19
|
+
by: string;
|
|
20
|
+
}
|
|
21
|
+
interface Index {
|
|
22
|
+
version: 1;
|
|
23
|
+
files: FileRecord[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Hosted only. On a laptop `storage/` is just a folder in the user's own
|
|
27
|
+
* project — they can see it, open it, and put a PDF in it with Finder — and
|
|
28
|
+
* an index plus a second hardlinked copy under data/ would be bookkeeping
|
|
29
|
+
* for a problem nobody has. The store exists to give many tenants a shared
|
|
30
|
+
* bucket and a provenance trail; one person with one directory has neither
|
|
31
|
+
* question to answer.
|
|
32
|
+
*
|
|
33
|
+
* Everything public here checks this first and does nothing, so the CLI runs
|
|
34
|
+
* the same driveRun the platform does and simply skips the mirror.
|
|
35
|
+
*/
|
|
36
|
+
export declare function fileStoreEnabled(): boolean;
|
|
37
|
+
/** The object key. Tenant-first so a bucket policy can be written per tenant,
|
|
38
|
+
* and content-addressed so objects are immutable — no cache invalidation,
|
|
39
|
+
* no partially-overwritten object, and a repeated upload is a no-op. */
|
|
40
|
+
export declare function blobKey(tenant: string, workspace: string, sha: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Reject anything that isn't a plain relative path under storage/.
|
|
43
|
+
*
|
|
44
|
+
* This runs on names that came from an upload form *and* on paths harvested
|
|
45
|
+
* out of a container, so it is the boundary for both directions. `..` is the
|
|
46
|
+
* obvious one; a leading slash and a Windows drive letter are the two that
|
|
47
|
+
* path.normalize alone will happily keep.
|
|
48
|
+
*/
|
|
49
|
+
export declare function assertFilePath(rel: string): string;
|
|
50
|
+
export declare function mimeFor(rel: string): string;
|
|
51
|
+
export declare function readIndex(tenant: string, workspace: string): Index;
|
|
52
|
+
export declare function listStorage(tenant: string, workspace: string): FileRecord[];
|
|
53
|
+
export declare function getFile(tenant: string, workspace: string, rel: string): FileRecord | null;
|
|
54
|
+
export declare function usedBytes(tenant: string, workspace: string): number;
|
|
55
|
+
export interface Driver {
|
|
56
|
+
kind: "fs" | "s3";
|
|
57
|
+
put(key: string, body: Buffer, mime: string): Promise<void>;
|
|
58
|
+
get(key: string): Promise<Buffer>;
|
|
59
|
+
has(key: string): Promise<boolean>;
|
|
60
|
+
remove(key: string): Promise<void>;
|
|
61
|
+
/** A URL a browser can GET directly, or null when the driver has no such
|
|
62
|
+
* thing (fs) and the route must stream the bytes itself. */
|
|
63
|
+
presignGet(key: string, filename: string, mime: string, ttlSec?: number): Promise<string | null>;
|
|
64
|
+
/** A URL a browser can PUT directly to, so upload bytes never transit the
|
|
65
|
+
* dashboard process. Null on fs, where the route takes the body. */
|
|
66
|
+
presignPut(key: string, mime: string, ttlSec?: number): Promise<string | null>;
|
|
67
|
+
}
|
|
68
|
+
/** Where the fs driver keeps a blob. Exported because the run mirror
|
|
69
|
+
* hardlinks straight to it rather than reading and rewriting the bytes. */
|
|
70
|
+
export declare function blobPath(tenant: string, workspace: string, sha: string): string;
|
|
71
|
+
interface S3Config {
|
|
72
|
+
endpoint: string;
|
|
73
|
+
bucket: string;
|
|
74
|
+
region: string;
|
|
75
|
+
accessKeyId: string;
|
|
76
|
+
secretAccessKey: string;
|
|
77
|
+
pathStyle: boolean;
|
|
78
|
+
}
|
|
79
|
+
export declare function s3Config(): S3Config | null;
|
|
80
|
+
/**
|
|
81
|
+
* The driver in force. `fs` unless S3 is both selected and configured —
|
|
82
|
+
* a half-set environment falls back rather than failing at the first upload,
|
|
83
|
+
* because a dashboard that boots and stores locally beats one that 500s.
|
|
84
|
+
*/
|
|
85
|
+
export declare function driverFor(tenant: string, workspace: string): Driver;
|
|
86
|
+
export declare function putFile(tenant: string, workspace: string, rel: string, body: Buffer, by: string): Promise<FileRecord>;
|
|
87
|
+
/** Register bytes a browser PUT straight to the bucket. Same bookkeeping as
|
|
88
|
+
* putFile, minus the bytes — the platform never saw them. */
|
|
89
|
+
export declare function registerUpload(tenant: string, workspace: string, rel: string, sha: string, size: number, by: string): Promise<FileRecord>;
|
|
90
|
+
export declare function readFileBytes(tenant: string, workspace: string, rel: string): Promise<Buffer | null>;
|
|
91
|
+
export declare function deleteFile(tenant: string, workspace: string, rel: string): Promise<boolean>;
|
|
92
|
+
/** A link the browser can follow, or null when the route must stream it. */
|
|
93
|
+
export declare function downloadUrl(tenant: string, workspace: string, record: FileRecord): Promise<string | null>;
|
|
94
|
+
/** A link the browser can PUT to, plus the key it will land at. Null on fs. */
|
|
95
|
+
export declare function uploadUrl(tenant: string, workspace: string, rel: string, sha: string): Promise<string | null>;
|
|
96
|
+
/**
|
|
97
|
+
* Put the workspace's files on disk under `<workspace>/storage/`, where a run
|
|
98
|
+
* copies them into its container like any other directory.
|
|
99
|
+
*
|
|
100
|
+
* On the fs driver this is a hardlink: the blob and the mirror are the same
|
|
101
|
+
* inode, so a 2GB corpus costs 2GB once, not twice. On s3 it is a download,
|
|
102
|
+
* and the mirror is a cache — a second run over the same files fetches
|
|
103
|
+
* nothing.
|
|
104
|
+
*
|
|
105
|
+
* Not pruned. A workspace that churns hundreds of large files will hold a
|
|
106
|
+
* copy of each on the PVC until someone deletes the record, and the quota is
|
|
107
|
+
* what stops that becoming a full disk. Pruning wants an LRU that knows no
|
|
108
|
+
* run is currently reading the file it is about to remove, and that bookkeeping
|
|
109
|
+
* is worth writing when a workspace actually outgrows its quota, not before.
|
|
110
|
+
*/
|
|
111
|
+
export declare function materializeFiles(tenant: string, workspace: string): Promise<string[]>;
|
|
112
|
+
/**
|
|
113
|
+
* Take what the run left in `storage/` back into the store, stamped with the
|
|
114
|
+
* run that wrote it.
|
|
115
|
+
*
|
|
116
|
+
* Compares against the index rather than a pre-run snapshot: the mirror is
|
|
117
|
+
* derived from the index, so anything on disk that the index does not already
|
|
118
|
+
* describe with the same size and hash is, by construction, new work.
|
|
119
|
+
*/
|
|
120
|
+
export declare function harvestFiles(tenant: string, workspace: string, by: string): Promise<{
|
|
121
|
+
saved: string[];
|
|
122
|
+
errors: string[];
|
|
123
|
+
}>;
|