@foldrun/core 0.6.0 → 1.0.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/README.md +14 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +22 -5
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +26 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/model-credential.d.ts +16 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +10 -0
- package/dist/src/run-container.js +72 -14
- package/dist/src/runner.d.ts +205 -0
- package/dist/src/runner.js +273 -36
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +2 -0
- package/dist/src/script-tools.js +3 -1
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +38 -4
- package/dist/src/step-exec.d.ts +21 -2
- package/dist/src/step-exec.js +165 -7
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +31 -1
- package/dist/src/store.js +90 -8
- package/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +3 -0
- package/package.json +18 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/deploy.ts +28 -3
- package/src/evals.ts +21 -3
- package/src/hooks.ts +406 -0
- package/src/host-env.ts +27 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/model-credential.ts +41 -0
- package/src/model-env.ts +66 -0
- package/src/notify.ts +11 -1
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/providers.ts +27 -1
- package/src/run-container.ts +75 -14
- package/src/runner.ts +279 -36
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +5 -1
- package/src/secrets.ts +34 -6
- package/src/starter.ts +44 -4
- package/src/step-exec.ts +171 -11
- package/src/storage.ts +13 -0
- package/src/store.ts +89 -7
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
package/src/step-exec.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// results. That is what lets the same function run somewhere the vault, the
|
|
9
9
|
// library and the account do not exist.
|
|
10
10
|
|
|
11
|
+
import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks, type HookDef, type HookRecord } from "./hooks.ts";
|
|
11
12
|
import fs from "node:fs";
|
|
12
13
|
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
@@ -57,6 +58,14 @@ export interface ExecOutcome {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export interface ExecOptions {
|
|
61
|
+
/** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
|
|
62
|
+
* inside its sandbox. `hookBase` is what every hook event carries. */
|
|
63
|
+
hooks?: HookDef[];
|
|
64
|
+
/** Or the names (`hooks:` from the account, workspace and agent), resolved
|
|
65
|
+
* here against this step's own roots — in a sandbox those are not the
|
|
66
|
+
* host's paths. A name that resolves to nothing refuses the step. */
|
|
67
|
+
hookNames?: string[];
|
|
68
|
+
hookBase?: Record<string, unknown>;
|
|
60
69
|
agentDir: string;
|
|
61
70
|
workspaceRoot: string;
|
|
62
71
|
libraryRoot: string;
|
|
@@ -158,7 +167,7 @@ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown>
|
|
|
158
167
|
export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
|
|
159
168
|
|
|
160
169
|
/** The pairing fields on a tool event — see RunEvent in store.ts. */
|
|
161
|
-
export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean };
|
|
170
|
+
export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord };
|
|
162
171
|
|
|
163
172
|
/** The tag on every event a failed check writes — `verify:`, `output: json`,
|
|
164
173
|
* a schema — headline and detail alike. The runner's no-retry rule for
|
|
@@ -310,8 +319,51 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
310
319
|
// fan-out instance each start from zero.
|
|
311
320
|
const counter = new CallCounter(opts.limits);
|
|
312
321
|
|
|
322
|
+
// Hooks (hooks.ts): the person's own code at this dispatch, same on every
|
|
323
|
+
// model. Each runs in the agent's directory with a bare environment.
|
|
324
|
+
let hooks = opts.hooks ?? [];
|
|
325
|
+
if (opts.hookNames?.length) {
|
|
326
|
+
const r = resolveHooks({ workspaceRoot, libraryRoot, names: opts.hookNames });
|
|
327
|
+
const bad = [...r.missing.map((n) => `no hook named ${n} in hooks/ or the library`), ...r.errors];
|
|
328
|
+
if (bad.length) {
|
|
329
|
+
emit("error", `hooks: ${bad.join("; ")} — the step is refused rather than run without its hooks`);
|
|
330
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
331
|
+
}
|
|
332
|
+
hooks = [...hooks, ...r.hooks];
|
|
333
|
+
}
|
|
334
|
+
const hookBase = { ...(opts.hookBase ?? {}) };
|
|
335
|
+
const callHook = (hook: HookDef, event: Record<string, unknown>) => runHook(hook, event, { cwd: agentDir, env: opts.env });
|
|
336
|
+
const record = (r: HookRecord, via?: string) =>
|
|
337
|
+
emit(r.did === "error" || r.did === "deny" ? "error" : "info", hookLine(r), { hook: r, ...(via ? { subagent: via } : {}) });
|
|
338
|
+
|
|
339
|
+
// step.start — a gate: context it returns goes in front of the task; a
|
|
340
|
+
// hook that fails refuses the step rather than run it unguarded.
|
|
341
|
+
let prompt = opts.prompt;
|
|
342
|
+
for (const hook of hooks.filter((h) => h.on === "step.start")) {
|
|
343
|
+
const a = await callHook(hook, { ...hookBase, event: "step.start", hook: hook.name, prompt });
|
|
344
|
+
if (!a.ok) {
|
|
345
|
+
record({ hook: hook.name, event: "step.start", did: "error", reason: `${a.failure}, so the step is refused`, ms: a.ms });
|
|
346
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
347
|
+
}
|
|
348
|
+
// Exit 2 is a deny on every event (runHook). On a gate that means the
|
|
349
|
+
// step does not run — it used to come back ok with no context, and the
|
|
350
|
+
// step went ahead as if the hook had agreed.
|
|
351
|
+
if (a.decision === "deny") {
|
|
352
|
+
record({ hook: hook.name, event: "step.start", did: "deny", reason: `${a.reason}, so the step is refused`, ms: a.ms });
|
|
353
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
354
|
+
}
|
|
355
|
+
if (a.context) {
|
|
356
|
+
prompt = `${prompt}\n\n${a.context}`;
|
|
357
|
+
record({ hook: hook.name, event: "step.start", did: "context", ms: a.ms });
|
|
358
|
+
} else if (a.note) {
|
|
359
|
+
record({ hook: hook.name, event: "step.start", did: "note", reason: a.note, ms: a.ms });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const toolHooks = hooks.filter((h) => h.on === "tool.call");
|
|
363
|
+
const resultHooks = hooks.filter((h) => h.on === "tool.result");
|
|
364
|
+
|
|
313
365
|
const q = runQuery({
|
|
314
|
-
prompt
|
|
366
|
+
prompt,
|
|
315
367
|
options: {
|
|
316
368
|
cwd: agentDir,
|
|
317
369
|
abortController: abort,
|
|
@@ -329,6 +381,13 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
329
381
|
// declares no tools gets none, instead of seeing the full Claude
|
|
330
382
|
// Code toolset and burning turns on denied calls.
|
|
331
383
|
tools: opts.allowed,
|
|
384
|
+
// Stated, never left to the SDK. Unset, it chose its own mode for the
|
|
385
|
+
// login it ran under, and in auto mode its classifier refused a granted
|
|
386
|
+
// script before canUseTool saw it: "denied by the Claude Code auto mode
|
|
387
|
+
// classifier. Reason: [Irreversible Local Destruction]" for backlink-
|
|
388
|
+
// desk's outreach_reset, which only marks old bodies STALE (2026-10-04
|
|
389
|
+
// and 05). The decision is ours: canUseTool and the PreToolUse hook.
|
|
390
|
+
permissionMode: "default",
|
|
332
391
|
// Deliberately NOT allowedTools: listing them there auto-approves and
|
|
333
392
|
// skips canUseTool entirely, which is where confinement happens. The
|
|
334
393
|
// toolset is still restricted by `tools` above.
|
|
@@ -404,6 +463,44 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
404
463
|
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: refusal.message } };
|
|
405
464
|
}
|
|
406
465
|
}
|
|
466
|
+
// tool.call hooks — after foldrun's own checks, so a hook never
|
|
467
|
+
// sees a call that was going to be refused anyway, and handed the
|
|
468
|
+
// input as the tool would get it (the workspace/ prefix expanded).
|
|
469
|
+
if (toolHooks.length) {
|
|
470
|
+
const input = (verdict?.updatedInput ?? hookInput.tool_input ?? {}) as Record<string, unknown>;
|
|
471
|
+
// A sub-agent's call says so: a hook that guards the step's own
|
|
472
|
+
// model differently from its delegates can tell them apart.
|
|
473
|
+
const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
|
|
474
|
+
for (const r of v.records) record(r, via);
|
|
475
|
+
const refundCall = () => { if (counter.active) counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners)); };
|
|
476
|
+
if (v.decision === "deny") {
|
|
477
|
+
refundCall();
|
|
478
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: v.reason! } };
|
|
479
|
+
}
|
|
480
|
+
if (v.input) {
|
|
481
|
+
// A rewritten call is answered "allow", and an allow skips
|
|
482
|
+
// canUseTool — where the shell and path confinement live. So
|
|
483
|
+
// the rewrite goes through the same checks here: a hook can
|
|
484
|
+
// narrow what the agent does, never widen it.
|
|
485
|
+
const again = hookInput.tool_name === "Bash"
|
|
486
|
+
? checkBash(String(v.input.command ?? ""))
|
|
487
|
+
: isFilesystemTool(hookInput.tool_name)
|
|
488
|
+
? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
|
|
489
|
+
: { ok: true as const };
|
|
490
|
+
const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
|
|
491
|
+
// The sub-agent guard read the ORIGINAL input: a rewrite of a
|
|
492
|
+
// delegation could otherwise swap subagent_type for one this
|
|
493
|
+
// step never declared (or the SDK's general-purpose).
|
|
494
|
+
const delegation = guardSubagent ? guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: v.input }) : null;
|
|
495
|
+
if (!again.ok || !granted || delegation) {
|
|
496
|
+
refundCall();
|
|
497
|
+
const why = !granted ? `Tool ${hookInput.tool_name} is not enabled for this agent.` : delegation ? `the rewritten call: ${delegation}` : `the rewritten call: ${again.reason}`;
|
|
498
|
+
emit("error", why, via ? { subagent: via } : undefined);
|
|
499
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: why } };
|
|
500
|
+
}
|
|
501
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: again.updatedInput ?? v.input } };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
407
504
|
if (!verdict?.updatedInput) return {};
|
|
408
505
|
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: verdict.updatedInput } };
|
|
409
506
|
}],
|
|
@@ -412,11 +509,21 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
412
509
|
// after its next tool call, as context — never as a change to what it
|
|
413
510
|
// may do. A model that is only writing, calling no tools, hears it at
|
|
414
511
|
// the next call it makes.
|
|
415
|
-
...(opts.inbox
|
|
512
|
+
...(opts.inbox || resultHooks.length
|
|
416
513
|
? {
|
|
417
514
|
PostToolUse: [{
|
|
418
515
|
hooks: [async (hookInput: HookInput) => {
|
|
419
516
|
if (hookInput.hook_event_name !== "PostToolUse") return {};
|
|
517
|
+
// tool.result hooks watch: the call has happened, so one
|
|
518
|
+
// that fails is recorded and the step goes on.
|
|
519
|
+
for (const hook of resultHooks) {
|
|
520
|
+
if (!matchesTool(hook.tool, hookInput.tool_name)) continue;
|
|
521
|
+
const response = (hookInput as { tool_response?: unknown }).tool_response;
|
|
522
|
+
const a = await callHook(hook, { ...hookBase, event: "tool.result", hook: hook.name, tool: { name: hookInput.tool_name, input: hookInput.tool_input ?? {} }, result: response ?? null });
|
|
523
|
+
if (!a.ok) record({ hook: hook.name, event: "tool.result", did: "error", tool: hookInput.tool_name, reason: a.failure, ms: a.ms });
|
|
524
|
+
else if (a.note) record({ hook: hook.name, event: "tool.result", did: "note", tool: hookInput.tool_name, reason: a.note, ms: a.ms });
|
|
525
|
+
}
|
|
526
|
+
if (!opts.inbox) return {};
|
|
420
527
|
// A sub-agent's call: the message is for the step's own
|
|
421
528
|
// model, and draining it here would hand it to a context
|
|
422
529
|
// that ends with the delegation — and lose it.
|
|
@@ -632,11 +739,23 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
632
739
|
...(block.is_error ? { err: true } : {}),
|
|
633
740
|
...(open.subagent ? { subagent: open.subagent } : {}),
|
|
634
741
|
});
|
|
742
|
+
// Why it failed, on the trail. The span above carried only `err`,
|
|
743
|
+
// so a refused call left the agent's own guess as the only account
|
|
744
|
+
// of it: backlink-desk's outreach_reset "denied by the permission
|
|
745
|
+
// classifier" (run-muufboma-2t6l, 2026-10-05) could not be checked.
|
|
746
|
+
if (block.is_error) {
|
|
747
|
+
const why = toolResultText(block.content).replace(/\s+/g, " ").trim().slice(0, 300);
|
|
748
|
+
if (why) emit("error", `${open.name}: ${why}`, { call: block.tool_use_id, ...(open.subagent ? { subagent: open.subagent } : {}) });
|
|
749
|
+
}
|
|
635
750
|
readPod(block.tool_use_id);
|
|
636
751
|
}
|
|
637
752
|
}
|
|
638
753
|
} else if (message.type === "result") {
|
|
639
754
|
status = message.subtype === "success" ? "completed" : "failed";
|
|
755
|
+
// Calls the SDK itself refused, which canUseTool never saw.
|
|
756
|
+
for (const d of ("permission_denials" in message && Array.isArray(message.permission_denials) ? message.permission_denials : [])) {
|
|
757
|
+
emit("error", `refused before it ran: ${String((d as { tool_name?: unknown }).tool_name ?? "a tool")}`);
|
|
758
|
+
}
|
|
640
759
|
if (message.subtype === "error_max_turns") {
|
|
641
760
|
emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
|
|
642
761
|
}
|
|
@@ -711,6 +830,18 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
711
830
|
"the model stream ended without a result — the model process likely died (out of memory?)",
|
|
712
831
|
);
|
|
713
832
|
}
|
|
833
|
+
// step.end hooks watch the reply, and may rewrite it. One that fails is
|
|
834
|
+
// recorded and the reply stands: the work it describes is done.
|
|
835
|
+
if (status === "completed") {
|
|
836
|
+
for (const hook of hooks.filter((h) => h.on === "step.end")) {
|
|
837
|
+
const a = await callHook(hook, { ...hookBase, event: "step.end", hook: hook.name, reply: texts.join("\n").trim() });
|
|
838
|
+
if (!a.ok) record({ hook: hook.name, event: "step.end", did: "error", reason: a.failure, ms: a.ms });
|
|
839
|
+
else if (a.reply !== undefined) {
|
|
840
|
+
texts.splice(0, texts.length, a.reply);
|
|
841
|
+
record({ hook: hook.name, event: "step.end", did: "reply", ms: a.ms });
|
|
842
|
+
} else if (a.note) record({ hook: hook.name, event: "step.end", did: "note", reason: a.note, ms: a.ms });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
714
845
|
const result = texts.join("\n").trim() || null;
|
|
715
846
|
// A step's reply is not one message: the model narrates ("Now let me read
|
|
716
847
|
// the outputs…"), calls tools, and answers last. Joining those and reading
|
|
@@ -877,8 +1008,8 @@ export async function checkVerify(
|
|
|
877
1008
|
* and `matches:` test this, because it is also what the run's headline
|
|
878
1009
|
* is read from: a reporter that narrated between tool calls ("Now let
|
|
879
1010
|
* me write the report…") failed `matches: ^BAD\b` with a correct
|
|
880
|
-
* headline while the check read every turn joined. `judge:`
|
|
881
|
-
* the whole result. */
|
|
1011
|
+
* headline while the check read every turn joined. `judge:` grades this
|
|
1012
|
+
* too, as "the reply", with the whole result beside it as the work. */
|
|
882
1013
|
conclusion?: string | null;
|
|
883
1014
|
data?: unknown;
|
|
884
1015
|
/** The step's own model environment, for `judge:` — it grades on the
|
|
@@ -941,7 +1072,13 @@ export async function checkVerify(
|
|
|
941
1072
|
return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
|
|
942
1073
|
}
|
|
943
1074
|
case "judge": {
|
|
944
|
-
|
|
1075
|
+
// The reply is the final turn, as everywhere else; every turn goes
|
|
1076
|
+
// beside it as the work, so a claim about what the step did can still
|
|
1077
|
+
// be checked. Both as one <reply> failed `judge: the reply opens with
|
|
1078
|
+
// GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
|
|
1079
|
+
// text opened with the narration before it.
|
|
1080
|
+
const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
|
|
1081
|
+
const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
|
|
945
1082
|
const ok = /^\s*PASS\b/i.test(verdict);
|
|
946
1083
|
return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
|
|
947
1084
|
}
|
|
@@ -954,9 +1091,24 @@ export async function checkVerify(
|
|
|
954
1091
|
* Same shape as the eval judge, and answered with one word first so the
|
|
955
1092
|
* verdict is a prefix test rather than a reading.
|
|
956
1093
|
*/
|
|
1094
|
+
export function judgePrompt(rubric: string, reply: string, work: string): string {
|
|
1095
|
+
const shown = reply.slice(0, 20_000);
|
|
1096
|
+
const context = work.trim() && work.trim() !== reply.trim()
|
|
1097
|
+
? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
|
|
1098
|
+
`never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
|
|
1099
|
+
: "";
|
|
1100
|
+
return (
|
|
1101
|
+
`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
1102
|
+
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
1103
|
+
context +
|
|
1104
|
+
`<reply>\n${shown}\n</reply>`
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
957
1108
|
async function judgeReply(
|
|
958
1109
|
rubric: string,
|
|
959
|
-
|
|
1110
|
+
reply: string,
|
|
1111
|
+
work: string,
|
|
960
1112
|
env: Record<string, string | undefined>,
|
|
961
1113
|
signal?: AbortSignal,
|
|
962
1114
|
): Promise<string> {
|
|
@@ -966,10 +1118,7 @@ async function judgeReply(
|
|
|
966
1118
|
signal?.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
967
1119
|
try {
|
|
968
1120
|
const q = query({
|
|
969
|
-
prompt:
|
|
970
|
-
`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
971
|
-
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
972
|
-
`<reply>\n${output.slice(0, 40_000)}\n</reply>`,
|
|
1121
|
+
prompt: judgePrompt(rubric, reply, work),
|
|
973
1122
|
options: {
|
|
974
1123
|
model: "haiku",
|
|
975
1124
|
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
@@ -1060,3 +1209,14 @@ function runVerify(
|
|
|
1060
1209
|
export function sdkEnv(base: Record<string, string | undefined> | undefined, extra: Record<string, string> = {}): Record<string, string | undefined> {
|
|
1061
1210
|
return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
|
|
1062
1211
|
}
|
|
1212
|
+
|
|
1213
|
+
/** The text of a tool result, whichever shape it came in: a string, or a
|
|
1214
|
+
* list of content blocks of which only the text ones say anything. */
|
|
1215
|
+
export function toolResultText(content: unknown): string {
|
|
1216
|
+
if (typeof content === "string") return content;
|
|
1217
|
+
if (!Array.isArray(content)) return "";
|
|
1218
|
+
return content
|
|
1219
|
+
.filter((b): b is { type: "text"; text: string } => !!b && (b as { type?: unknown }).type === "text" && typeof (b as { text?: unknown }).text === "string")
|
|
1220
|
+
.map((b) => b.text)
|
|
1221
|
+
.join(" ");
|
|
1222
|
+
}
|
package/src/storage.ts
CHANGED
|
@@ -839,6 +839,14 @@ export async function materializeFiles(tenant: string, workspace: string): Promi
|
|
|
839
839
|
return brought;
|
|
840
840
|
}
|
|
841
841
|
|
|
842
|
+
/** path → sha of every stored file: what `storage/` holds once
|
|
843
|
+
* materializeFiles has run. A run keeps it to tell its own writes from the
|
|
844
|
+
* files it was handed. */
|
|
845
|
+
export function storageBaseline(tenant: string, workspace: string): Record<string, string> {
|
|
846
|
+
if (!fileStoreEnabled()) return {};
|
|
847
|
+
return Object.fromEntries(readIndex(tenant, workspace).files.map((f) => [f.path, f.sha]));
|
|
848
|
+
}
|
|
849
|
+
|
|
842
850
|
/**
|
|
843
851
|
* Take what the run left in `storage/` back into the store, stamped with the
|
|
844
852
|
* run that wrote it.
|
|
@@ -851,6 +859,7 @@ export async function harvestFiles(
|
|
|
851
859
|
tenant: string,
|
|
852
860
|
workspace: string,
|
|
853
861
|
by: string,
|
|
862
|
+
opts: { changedFrom?: Record<string, string> } = {},
|
|
854
863
|
): Promise<{ saved: string[]; errors: string[] }> {
|
|
855
864
|
if (!fileStoreEnabled()) return { saved: [], errors: [] };
|
|
856
865
|
adoptLegacyFilesDir(workspaceDir(tenant, workspace));
|
|
@@ -885,6 +894,10 @@ export async function harvestFiles(
|
|
|
885
894
|
// Same size is not proof, so confirm by hash before skipping a write.
|
|
886
895
|
if (sha256(fs.readFileSync(abs)) === previous.sha) continue;
|
|
887
896
|
}
|
|
897
|
+
// Only what the run itself changed: a file still holding the bytes it
|
|
898
|
+
// was handed is not this run's work, and the index may have moved on
|
|
899
|
+
// since (an upload while the run was going) — that must win.
|
|
900
|
+
if (opts.changedFrom && opts.changedFrom[norm] === sha256(fs.readFileSync(abs))) continue;
|
|
888
901
|
await putFileQuietly(tenant, workspace, norm, fs.readFileSync(abs), by);
|
|
889
902
|
saved.push(norm);
|
|
890
903
|
} catch (err) {
|
package/src/store.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
//
|
|
21
21
|
// An account is a named directory (per OKF: tenancy is a hosting concern).
|
|
22
22
|
|
|
23
|
+
import { expandPlatformVars } from "./host-env.ts";
|
|
23
24
|
import type { BrowserPodOutcome } from "./browser-pod.ts";
|
|
24
25
|
import fs from "node:fs";
|
|
25
26
|
import path from "node:path";
|
|
@@ -57,7 +58,7 @@ import {
|
|
|
57
58
|
} from "./okf.ts";
|
|
58
59
|
import { readTransport, KINDS } from "./kinds.ts";
|
|
59
60
|
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
|
|
60
|
-
import { starterFiles, accountFiles } from "./starter.ts";
|
|
61
|
+
import { starterFiles, accountFiles, blankWorkspaceFiles } from "./starter.ts";
|
|
61
62
|
import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.ts";
|
|
62
63
|
|
|
63
64
|
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
@@ -83,6 +84,7 @@ export const WORKSPACE_DIRS = [
|
|
|
83
84
|
"knowledge",
|
|
84
85
|
"tools",
|
|
85
86
|
"scripts",
|
|
87
|
+
"hooks",
|
|
86
88
|
"state",
|
|
87
89
|
] as const;
|
|
88
90
|
|
|
@@ -317,6 +319,19 @@ export function assertCanonicalCase(rel: string) {
|
|
|
317
319
|
// fire (trigger-log.ts). Before it was listed here every deploy erased it.
|
|
318
320
|
export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
|
|
319
321
|
|
|
322
|
+
// The agent engine's own home files. Each step's sandbox runs the Claude
|
|
323
|
+
// Agent SDK — the engine, whatever model answers — with its home in the
|
|
324
|
+
// agent's folder, and the engine writes its settings (.claude.json, its
|
|
325
|
+
// backups), plugin caches and the full transcript of every session
|
|
326
|
+
// (.claude/projects/*.jsonl) there. Nothing in foldrun reads them. Copied
|
|
327
|
+
// back after every step, they had piled up to 5,274 files and 19 MB in one
|
|
328
|
+
// agent's folder on the box by 6 Oct 2026, carried every past conversation
|
|
329
|
+
// into every sandbox, and their clashing rewrites were the "write-back
|
|
330
|
+
// conflict on .claude.json" lines (and the big merges) of that day. A
|
|
331
|
+
// workspace's own .claude/agents/*.md — Claude Code subagents an author
|
|
332
|
+
// writes, imported as agents — sits at the workspace root and is untouched.
|
|
333
|
+
const ENGINE_HOME_RE = /^agents\/[^/]+\/\.claude([./]|$)/;
|
|
334
|
+
|
|
320
335
|
/** True for anything the platform, not the author or the agent, writes. */
|
|
321
336
|
export function isPlatformPath(rel: string): boolean {
|
|
322
337
|
const norm = rel.replaceAll("\\", "/");
|
|
@@ -325,6 +340,7 @@ export function isPlatformPath(rel: string): boolean {
|
|
|
325
340
|
// are the vault, as far as anything outside the platform is concerned.
|
|
326
341
|
if (/(^|\/)secrets\.json\./.test(norm)) return true;
|
|
327
342
|
if (norm === "trigger-log.jsonl.tmp") return true;
|
|
343
|
+
if (ENGINE_HOME_RE.test(norm)) return true;
|
|
328
344
|
return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
|
|
329
345
|
}
|
|
330
346
|
|
|
@@ -625,6 +641,27 @@ export function toolIsOutward(data: Record<string, unknown>): boolean {
|
|
|
625
641
|
return data.outward === true;
|
|
626
642
|
}
|
|
627
643
|
|
|
644
|
+
/** How much of a tool.md body rides to the model with the tool. */
|
|
645
|
+
export const TOOL_GUIDE_MAX = 6000;
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The prose under a tool.md's frontmatter: how to call it, what it refuses,
|
|
649
|
+
* what its output means. Only `description:` used to reach the model, so
|
|
650
|
+
* agents invented what the body documented (5 Oct 2026). The fenced program
|
|
651
|
+
* of a single-file tool is code, not guidance, and stays out.
|
|
652
|
+
*/
|
|
653
|
+
export function toolGuide(body: string | undefined, inlineProgram = false): string | undefined {
|
|
654
|
+
if (!body) return undefined;
|
|
655
|
+
let text = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
|
656
|
+
// Only a single-file tool's program is cut; in a `run:` tool every fenced
|
|
657
|
+
// block is an example of a call, which is exactly what the model needs.
|
|
658
|
+
const inline = inlineProgram ? fencedCodeBlock(text) : null;
|
|
659
|
+
if (inline) text = text.slice(0, inline.start) + text.slice(inline.end);
|
|
660
|
+
text = text.trim();
|
|
661
|
+
if (!text) return undefined;
|
|
662
|
+
return text.length > TOOL_GUIDE_MAX ? `${text.slice(0, TOOL_GUIDE_MAX)}\n\n[the rest of tool.md is cut here]` : text;
|
|
663
|
+
}
|
|
664
|
+
|
|
628
665
|
export function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null {
|
|
629
666
|
const name = typeof data.name === "string" ? data.name : fallbackName;
|
|
630
667
|
// `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
|
|
@@ -659,16 +696,24 @@ export function parseToolDef(data: Record<string, unknown>, fallbackName: string
|
|
|
659
696
|
};
|
|
660
697
|
}
|
|
661
698
|
if (kind === "script") {
|
|
699
|
+
// `guide` is the body's, never the frontmatter's: an author's `guide:`
|
|
700
|
+
// would otherwise ride to the model in place of the manual.
|
|
701
|
+
if ("guide" in data) { data = { ...data }; delete data.guide; }
|
|
662
702
|
// The program is a run: path, or the file's own fenced code block — the
|
|
663
703
|
// single-file form, which is what lets a script tool read and edit like
|
|
664
704
|
// every other markdown document.
|
|
665
|
-
if (typeof data.run === "string")
|
|
705
|
+
if (typeof data.run === "string") {
|
|
706
|
+
const guide = toolGuide(body);
|
|
707
|
+
return { kind: "script", name, spec: { ...data, name, ...(guide ? { guide } : {}) } };
|
|
708
|
+
}
|
|
666
709
|
const inline = body ? fencedCode(body) : null;
|
|
667
710
|
if (!inline) return null;
|
|
668
|
-
|
|
711
|
+
const guide = toolGuide(body, true);
|
|
712
|
+
return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext, ...(guide ? { guide } : {}) } };
|
|
669
713
|
}
|
|
670
714
|
const [spec] = parseApis([{ ...data, name }]);
|
|
671
|
-
|
|
715
|
+
const guide = toolGuide(body);
|
|
716
|
+
return spec ? { kind: "http", name, spec: guide ? { ...spec, guide } : spec } : null;
|
|
672
717
|
}
|
|
673
718
|
|
|
674
719
|
/**
|
|
@@ -864,6 +909,8 @@ export interface ApiSpec {
|
|
|
864
909
|
name: string;
|
|
865
910
|
base: string;
|
|
866
911
|
description: string;
|
|
912
|
+
/** The tool.md body, for the model (see toolGuide). */
|
|
913
|
+
guide?: string;
|
|
867
914
|
headers: Record<string, string>; // values may contain ${SECRET_NAME}
|
|
868
915
|
query: Record<string, string>; // always-appended query params (e.g. api keys)
|
|
869
916
|
methods: string[]; // allowed HTTP methods; default GET only
|
|
@@ -1136,7 +1183,9 @@ export function parseApis(raw: unknown): ApiSpec[] {
|
|
|
1136
1183
|
out.push({
|
|
1137
1184
|
name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
1138
1185
|
...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
|
|
1139
|
-
|
|
1186
|
+
// `${FOLDRUN_API_URL}` and `${FOLDRUN_PUBLIC_URL}` are the install's own
|
|
1187
|
+
// addresses, filled here on the host so a desk never names one.
|
|
1188
|
+
base: trimSlashes(expandPlatformVars(base)),
|
|
1140
1189
|
description: typeof e.description === "string" ? e.description : "",
|
|
1141
1190
|
headers: asRecordLocal(e.headers),
|
|
1142
1191
|
query: asRecordLocal(e.query),
|
|
@@ -2875,6 +2924,12 @@ export function setWorkspaceDescription(tenant: string, workspace: string, descr
|
|
|
2875
2924
|
* this copy still said `type: Agent` after the CLI's had moved to `kind:`,
|
|
2876
2925
|
* so every workspace made from the dashboard was born in the old format.
|
|
2877
2926
|
*/
|
|
2927
|
+
/** What the dashboard's "Create workspace" makes: blank (blankWorkspaceFiles),
|
|
2928
|
+
* minus the laptop-only files, for the same reason as templateFiles. */
|
|
2929
|
+
export function blankTemplateFiles(workspace: string): DeployFile[] {
|
|
2930
|
+
return blankWorkspaceFiles(workspace).filter((f) => f.path === "AGENTS.md" || IN_WORKSPACE_DIR.test(f.path));
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2878
2933
|
export function templateFiles(workspace: string): DeployFile[] {
|
|
2879
2934
|
// The starter minus what only matters on a laptop's disk: .gitignore
|
|
2880
2935
|
// guards a local clone's secrets, but the hosted store never keeps
|
|
@@ -2970,6 +3025,9 @@ export interface RunEvent {
|
|
|
2970
3025
|
* detail), `output: json`, a schema. What the runner reads to decline a
|
|
2971
3026
|
* retry of an outward step whose tools already ran. */
|
|
2972
3027
|
check?: boolean;
|
|
3028
|
+
/** Set on the line a hook wrote — a deny, a rewrite, context, a failure
|
|
3029
|
+
* (hooks.ts). The run page and the Hooks page read these. */
|
|
3030
|
+
hook?: { hook: string; event: string; did: string; tool?: string; reason?: string; ms: number };
|
|
2973
3031
|
}
|
|
2974
3032
|
|
|
2975
3033
|
/** A question an agent asked a person while its step ran (tools: [ask]). */
|
|
@@ -3388,7 +3446,23 @@ export function runOutputPath(
|
|
|
3388
3446
|
|
|
3389
3447
|
export function readRun(tenant: string, workspace: string, runId: string): RunRecord | null {
|
|
3390
3448
|
const p = runFilePath(tenant, workspace, runId);
|
|
3391
|
-
|
|
3449
|
+
// EFS/NFS: writeRun() replaces this file by rename, so an open()+read()
|
|
3450
|
+
// here can land on the just-unlinked inode and come back ESTALE (errno
|
|
3451
|
+
// -116, surfaced by Node as "Unknown system error -116", syscall read).
|
|
3452
|
+
// That isn't corruption — the next open() by path sees the committed new
|
|
3453
|
+
// inode — so re-read a few times on ESTALE and rethrow anything else.
|
|
3454
|
+
// (On dev-aws this surfaced as a CLI `--wait` call returning HTTP 500
|
|
3455
|
+
// from waitForRun's poll while the run itself completed fine.)
|
|
3456
|
+
const attempts = 5;
|
|
3457
|
+
for (let i = 1; ; i++) {
|
|
3458
|
+
try {
|
|
3459
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
|
|
3460
|
+
} catch (e) {
|
|
3461
|
+
const err = e as NodeJS.ErrnoException;
|
|
3462
|
+
if ((err.code === "ESTALE" || err.errno === -116) && i < attempts) continue;
|
|
3463
|
+
throw e;
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3392
3466
|
}
|
|
3393
3467
|
|
|
3394
3468
|
export function writeRun(tenant: string, workspace: string, run: RunRecord) {
|
|
@@ -3706,6 +3780,12 @@ export function runCost(run: RunRecord): number {
|
|
|
3706
3780
|
* fan-out template is a row in the trace rather than work — billing any of
|
|
3707
3781
|
* them would charge for steps the customer can see never executed, which is
|
|
3708
3782
|
* the fastest way to lose an argument about an invoice.
|
|
3783
|
+
*
|
|
3784
|
+
* Tokens count only for steps that ran on the platform's own model
|
|
3785
|
+
* credential. A step on the account's own key (`credential` "provider: …" —
|
|
3786
|
+
* a provider: block, or Settings → Model) was paid for at the provider, and
|
|
3787
|
+
* billing it again here charged a BYOK customer twice. And a carried step's
|
|
3788
|
+
* tokens were billed where it ran, like its compute.
|
|
3709
3789
|
*/
|
|
3710
3790
|
export function runMeter(run: RunRecord): {
|
|
3711
3791
|
tokenCostUsd: number;
|
|
@@ -3720,17 +3800,19 @@ export function runMeter(run: RunRecord): {
|
|
|
3720
3800
|
let computeSecs = 0;
|
|
3721
3801
|
let smallSecs = 0;
|
|
3722
3802
|
let netBytes = 0;
|
|
3803
|
+
let tokenCostUsd = 0;
|
|
3723
3804
|
for (const s of run.steps) {
|
|
3724
3805
|
if (s.status !== "completed" && s.status !== "failed") continue;
|
|
3725
3806
|
// A carried step ran — and was billed — in the run it was carried from.
|
|
3726
3807
|
if (s.carriedFrom) continue;
|
|
3727
3808
|
steps += 1;
|
|
3809
|
+
if (!(s.credential ?? "").startsWith("provider:")) tokenCostUsd += s.costUsd ?? 0;
|
|
3728
3810
|
const secs = s.computeSecs ?? 0;
|
|
3729
3811
|
computeSecs += secs;
|
|
3730
3812
|
if (s.size === "small") smallSecs += secs;
|
|
3731
3813
|
netBytes += (s.actual?.rxBytes ?? 0) + (s.actual?.txBytes ?? 0);
|
|
3732
3814
|
}
|
|
3733
|
-
return { tokenCostUsd
|
|
3815
|
+
return { tokenCostUsd, steps, computeSecs, smallSecs, netBytes };
|
|
3734
3816
|
}
|
|
3735
3817
|
|
|
3736
3818
|
/**
|
package/src/tool-names.ts
CHANGED
|
@@ -49,8 +49,10 @@ export const BUILTIN_TOOLS = new Set([
|
|
|
49
49
|
"NotebookEdit", "TodoWrite",
|
|
50
50
|
]);
|
|
51
51
|
|
|
52
|
-
/** The platform's own groups, served in-process rather than by the SDK.
|
|
53
|
-
|
|
52
|
+
/** The platform's own groups, served in-process rather than by the SDK.
|
|
53
|
+
* `install` is granted to every agent anyway (install-tool.ts); naming it is
|
|
54
|
+
* accepted so a file that says so is not flagged. */
|
|
55
|
+
export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "install"]);
|
|
54
56
|
|
|
55
57
|
/** Would this name resolve to something the runtime provides? Built-ins win a
|
|
56
58
|
* clash, so a tool file of the same name is shadowed rather than granted. */
|
package/src/tool-test.ts
CHANGED
|
@@ -277,7 +277,14 @@ export async function testTool(
|
|
|
277
277
|
});
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
// The tool's own secrets, and the calling agent's `secrets:` — a run
|
|
281
|
+
// hands a script both. Testing gbp_read (whose agent, not the tool,
|
|
282
|
+
// declares GBP_OAUTH) said "GBP_OAUTH is not set" while every run of the
|
|
283
|
+
// same call worked (9 Oct 2026). Only an agent that grants the tool: the
|
|
284
|
+
// agents[0] stand-in above is there for its depth, not its keys.
|
|
285
|
+
const own = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
|
|
286
|
+
const granting = caller && caller.ownTools.includes(def.name) ? caller.secrets : [];
|
|
287
|
+
const names = [...new Set([...own, ...granting])];
|
|
281
288
|
const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
|
|
282
289
|
const live = await liveSecrets(stored, { tenant, workspace });
|
|
283
290
|
if ("error" in live) {
|