@foldrun/core 0.7.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/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -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/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +5 -0
- package/dist/src/run-container.js +6 -1
- package/dist/src/runner.d.ts +1 -0
- package/dist/src/runner.js +16 -0
- package/dist/src/starter.js +2 -0
- package/dist/src/step-exec.d.ts +10 -0
- package/dist/src/step-exec.js +113 -2
- package/dist/src/store.d.ts +11 -1
- package/dist/src/store.js +1 -0
- package/package.json +18 -1
- package/src/hooks.ts +406 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/notify.ts +11 -1
- package/src/providers.ts +27 -1
- package/src/run-container.ts +10 -2
- package/src/runner.ts +15 -0
- package/src/starter.ts +2 -0
- package/src/step-exec.ts +115 -3
- package/src/store.ts +4 -0
package/src/runner.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// fail without failing the flow. A direct agent run is a one-step ad-hoc
|
|
4
4
|
// flow, so there is a single execution path.
|
|
5
5
|
|
|
6
|
+
import { hookNames } from "./hooks.ts";
|
|
6
7
|
import fs from "node:fs";
|
|
7
8
|
import os from "node:os";
|
|
8
9
|
import path from "node:path";
|
|
@@ -1341,6 +1342,7 @@ export function agentContext(
|
|
|
1341
1342
|
...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
|
|
1342
1343
|
...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
|
|
1343
1344
|
...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
|
|
1345
|
+
...(browseSettings.state ? { FOLDRUN_BROWSER_STATE: browseSettings.state } : {}),
|
|
1344
1346
|
...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
|
|
1345
1347
|
...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
|
|
1346
1348
|
};
|
|
@@ -2355,6 +2357,17 @@ async function runStep(
|
|
|
2355
2357
|
const limitOwners = toolOwners(apiTools.owners, scriptSpecs);
|
|
2356
2358
|
const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
|
|
2357
2359
|
if (limitOpts.limits) push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
|
|
2360
|
+
// `hooks:` — the account's, the workspace's, then the agent's own, each
|
|
2361
|
+
// once; resolved and run inside the step (hooks.ts, step-exec.ts).
|
|
2362
|
+
const stepHookNames = [...new Set([
|
|
2363
|
+
...hookNames(readAgentsMd(accountDir(tenant))?.data?.hooks),
|
|
2364
|
+
...hookNames(readAgentsMd(workspaceRootOf(agentDir))?.data?.hooks),
|
|
2365
|
+
...hookNames(front.hooks),
|
|
2366
|
+
])];
|
|
2367
|
+
const hookOpts = stepHookNames.length
|
|
2368
|
+
? { hookNames: stepHookNames, hookBase: { run_id: runId ?? null, step: stepIndex ?? null, agent: step.agent, workspace: path.basename(workspaceRootOf(agentDir)) } }
|
|
2369
|
+
: {};
|
|
2370
|
+
if (stepHookNames.length) push("info", `hooks: ${stepHookNames.join(", ")}`);
|
|
2358
2371
|
|
|
2359
2372
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
2360
2373
|
if (isolatedRun()) {
|
|
@@ -2521,6 +2534,7 @@ async function runStep(
|
|
|
2521
2534
|
schema: resolvedSchema ?? undefined,
|
|
2522
2535
|
maxTurns: step.maxTurns,
|
|
2523
2536
|
...limitOpts,
|
|
2537
|
+
...hookOpts,
|
|
2524
2538
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
2525
2539
|
// Outward steps keep a browser of their own: an executor that
|
|
2526
2540
|
// could browse from the slim image through the account's pod
|
|
@@ -2885,6 +2899,7 @@ async function runStep(
|
|
|
2885
2899
|
schema: resolvedSchema ?? undefined,
|
|
2886
2900
|
maxTurns: step.maxTurns,
|
|
2887
2901
|
...limitOpts,
|
|
2902
|
+
...hookOpts,
|
|
2888
2903
|
stopRequested,
|
|
2889
2904
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
2890
2905
|
// the proof a step left names THIS run, not one that came before —
|
package/src/starter.ts
CHANGED
|
@@ -279,6 +279,7 @@ only when they asked for exactly that deletion.
|
|
|
279
279
|
| \`memory/\` | what past runs learned — agents write here themselves |
|
|
280
280
|
| \`evals/\` | tests for agents, same idea as unit tests |
|
|
281
281
|
| \`skills/\` | a procedure an agent can follow, named in \`skills:\` |
|
|
282
|
+
| \`hooks/\` | your code at every tool call, step start or step end (\`hook.md\` + \`run.mjs\`), turned on with \`hooks:\` — refuse, rewrite, add context; same on every model |
|
|
282
283
|
| \`tools/\` | a folder tool: its definition and its code together |
|
|
283
284
|
| \`scripts/\` | code the agents call, referenced by \`scripts:\` |
|
|
284
285
|
| \`state/\` | what runs accumulate. Yours to read, never deployed over |
|
|
@@ -455,6 +456,7 @@ size: large # small | large | heavy — the sandbox it rents
|
|
|
455
456
|
tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
|
|
456
457
|
disallowedTools: [Bash] # subtract from what it would otherwise have
|
|
457
458
|
skills: [house-style] # from skills/
|
|
459
|
+
hooks: [no-force-push] # from hooks/ or the library: code at every tool call
|
|
458
460
|
scripts: [summarise.py] # from scripts/, each becomes a callable tool
|
|
459
461
|
apis: [{ name: crm }] # an HTTP API, as a tool
|
|
460
462
|
mcpServers: {} # an MCP server, as a tool
|
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,
|
|
@@ -411,6 +463,44 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
411
463
|
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: refusal.message } };
|
|
412
464
|
}
|
|
413
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
|
+
}
|
|
414
504
|
if (!verdict?.updatedInput) return {};
|
|
415
505
|
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: verdict.updatedInput } };
|
|
416
506
|
}],
|
|
@@ -419,11 +509,21 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
419
509
|
// after its next tool call, as context — never as a change to what it
|
|
420
510
|
// may do. A model that is only writing, calling no tools, hears it at
|
|
421
511
|
// the next call it makes.
|
|
422
|
-
...(opts.inbox
|
|
512
|
+
...(opts.inbox || resultHooks.length
|
|
423
513
|
? {
|
|
424
514
|
PostToolUse: [{
|
|
425
515
|
hooks: [async (hookInput: HookInput) => {
|
|
426
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 {};
|
|
427
527
|
// A sub-agent's call: the message is for the step's own
|
|
428
528
|
// model, and draining it here would hand it to a context
|
|
429
529
|
// that ends with the delegation — and lose it.
|
|
@@ -730,6 +830,18 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
730
830
|
"the model stream ended without a result — the model process likely died (out of memory?)",
|
|
731
831
|
);
|
|
732
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
|
+
}
|
|
733
845
|
const result = texts.join("\n").trim() || null;
|
|
734
846
|
// A step's reply is not one message: the model narrates ("Now let me read
|
|
735
847
|
// the outputs…"), calls tools, and answers last. Joining those and reading
|
package/src/store.ts
CHANGED
|
@@ -84,6 +84,7 @@ export const WORKSPACE_DIRS = [
|
|
|
84
84
|
"knowledge",
|
|
85
85
|
"tools",
|
|
86
86
|
"scripts",
|
|
87
|
+
"hooks",
|
|
87
88
|
"state",
|
|
88
89
|
] as const;
|
|
89
90
|
|
|
@@ -3024,6 +3025,9 @@ export interface RunEvent {
|
|
|
3024
3025
|
* detail), `output: json`, a schema. What the runner reads to decline a
|
|
3025
3026
|
* retry of an outward step whose tools already ran. */
|
|
3026
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 };
|
|
3027
3031
|
}
|
|
3028
3032
|
|
|
3029
3033
|
/** A question an agent asked a person while its step ran (tools: [ask]). */
|