@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/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: opts.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]). */