@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/dist/src/providers.js
CHANGED
|
@@ -352,7 +352,7 @@ export function browseEngineRefusal(engine, what) {
|
|
|
352
352
|
}
|
|
353
353
|
const BROWSE_SETTING_KEYS = [
|
|
354
354
|
"engine", "user_agent", "device", "locale", "timezone", "cookies", "cookie_domain", "storage", "storage_origin", "identities", "headless", "version", "live",
|
|
355
|
-
"allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key",
|
|
355
|
+
"allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key", "state",
|
|
356
356
|
"video", "live_view", "vendor_session",
|
|
357
357
|
// Deprecated spelling of vendor_session (read, and warned about).
|
|
358
358
|
"session",
|
|
@@ -696,6 +696,12 @@ export function readBrowseSettings(raw) {
|
|
|
696
696
|
settings[k] = list;
|
|
697
697
|
continue;
|
|
698
698
|
}
|
|
699
|
+
if (k === "state") {
|
|
700
|
+
if (typeof v !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(v))
|
|
701
|
+
return { settings, rest: left(rest), error: "web.browse.state is a plain name (letters, digits, dot, dash, underscore), like youtube — the login is kept in workspace/state/browser/<name>.json." };
|
|
702
|
+
settings.state = v;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
699
705
|
if (k === "state_key") {
|
|
700
706
|
if (typeof v !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(v))
|
|
701
707
|
return { settings, rest: left(rest), error: "web.browse.state_key must be the NAME of a vault secret (CAPITALS), never the key — store it with `foldrun secrets set NAME` and declare it under secrets:." };
|
|
@@ -762,6 +768,20 @@ export function readBrowseSettings(raw) {
|
|
|
762
768
|
`would be sent to whatever site the call opens. Write \`cookie_domain: .example.com\`.`,
|
|
763
769
|
};
|
|
764
770
|
}
|
|
771
|
+
// A secret's cookies seeding a saved login: the login then sits in state/,
|
|
772
|
+
// a workspace file, so it must be encrypted with a key from the vault.
|
|
773
|
+
if (settings.state && settings.cookies && !settings.state_key) {
|
|
774
|
+
return {
|
|
775
|
+
settings,
|
|
776
|
+
rest: left(rest),
|
|
777
|
+
error: `web.browse.state beside cookies needs web.browse.state_key — the secret's session would be saved ` +
|
|
778
|
+
`to state/ where any step can read it. Store a key (\`foldrun secrets set BROWSER_STATE_KEY\`), ` +
|
|
779
|
+
`write \`state_key: BROWSER_STATE_KEY\` and declare it under secrets:.`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
if (settings.state && settings.storage) {
|
|
783
|
+
return { settings, rest: left(rest), error: "web.browse.state cannot be combined with storage: a secret's tokens would be written to state/." };
|
|
784
|
+
}
|
|
765
785
|
// Storage is per origin, and seeding it into the wrong one would hand a
|
|
766
786
|
// site another site's signed-in state — the same mistake `cookie_domain`
|
|
767
787
|
// exists to prevent, one storage area over.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type HookRecord } from "./hooks.ts";
|
|
1
2
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
2
3
|
import { type ApiSpec, type Effort } from "./store.ts";
|
|
3
4
|
import type { EventExtra } from "./step-exec.ts";
|
|
@@ -52,6 +53,9 @@ export interface ContainerStepInput {
|
|
|
52
53
|
/** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
|
|
53
54
|
* count reads (limits.ts) — enforced in the pod by the same hook. */
|
|
54
55
|
limits?: Record<string, number>;
|
|
56
|
+
/** `hooks:` names, resolved inside the sandbox against its own roots. */
|
|
57
|
+
hookNames?: string[];
|
|
58
|
+
hookBase?: Record<string, unknown>;
|
|
55
59
|
toolOwners?: Record<string, string>;
|
|
56
60
|
/** tools: [search] — the directories to search, as paths INSIDE the
|
|
57
61
|
* container (/workspace/…, /library/…). */
|
|
@@ -340,6 +344,7 @@ export declare function parseDriverLine(line: string): {
|
|
|
340
344
|
operator?: OperatorEvent;
|
|
341
345
|
subagent?: string;
|
|
342
346
|
check?: boolean;
|
|
347
|
+
hook?: HookRecord;
|
|
343
348
|
} | ({
|
|
344
349
|
e: "done";
|
|
345
350
|
} & ContainerStepOutcome) | null;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// The runner image bundles @foldrun/core itself, so the driver runs the same
|
|
19
19
|
// executeStep the server would — one implementation, two homes.
|
|
20
|
+
import { isHookRecord } from "./hooks.js";
|
|
20
21
|
import fs from "node:fs";
|
|
21
22
|
import os from "node:os";
|
|
22
23
|
import path from "node:path";
|
|
@@ -42,7 +43,9 @@ const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
|
|
|
42
43
|
// there: everything platform-owned (store.ts#isPlatformPath — also never
|
|
43
44
|
// copied in), plus knowledge/, which goes in for reading and is dropped on
|
|
44
45
|
// the way out, and .git/.
|
|
45
|
-
|
|
46
|
+
// hooks/: a step must not rewrite the code that guards it — for this run (the
|
|
47
|
+
// code is read into memory before the model starts, hooks.ts) or the next.
|
|
48
|
+
const DENY_BACK = ["knowledge/", "hooks/", ".git/"];
|
|
46
49
|
// A .git ANYWHERE in the tree, not just at the root. A step that clones a
|
|
47
50
|
// repository into a shared area (files/site, say) was shipping the entire
|
|
48
51
|
// object store back to the workspace, where the harvest then filed thousands
|
|
@@ -531,6 +534,7 @@ try {
|
|
|
531
534
|
schema: input.schema,
|
|
532
535
|
maxTurns: input.maxTurns,
|
|
533
536
|
...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
|
|
537
|
+
...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),
|
|
534
538
|
// Slim browsing: the platform put this step on the slim image to browse
|
|
535
539
|
// through the account's pod, and named the file the web tool logs its
|
|
536
540
|
// reconnects to. A lost pod stops the step; what it wrote decides what
|
|
@@ -983,6 +987,7 @@ export function parseDriverLine(line) {
|
|
|
983
987
|
...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
|
|
984
988
|
...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
|
|
985
989
|
...(parsed.check === true ? { check: true } : {}),
|
|
990
|
+
...(isHookRecord(parsed.hook) ? { hook: parsed.hook } : {}),
|
|
986
991
|
};
|
|
987
992
|
}
|
|
988
993
|
if (parsed.e === "done") {
|
package/dist/src/runner.d.ts
CHANGED
|
@@ -250,6 +250,7 @@ grantsOnly?: boolean): {
|
|
|
250
250
|
FOLDRUN_BROWSER_WEBGPU?: string | undefined;
|
|
251
251
|
FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS?: string | undefined;
|
|
252
252
|
FOLDRUN_BROWSER_STATE_KEY?: string | undefined;
|
|
253
|
+
FOLDRUN_BROWSER_STATE?: string | undefined;
|
|
253
254
|
FOLDRUN_BROWSER_VIDEO?: string | undefined;
|
|
254
255
|
FOLDRUN_BROWSER_LIVE_VIEW?: string | undefined;
|
|
255
256
|
};
|
package/dist/src/runner.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// group receives every prior result, labeled per agent. Optional steps ("2?")
|
|
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
|
+
import { hookNames } from "./hooks.js";
|
|
5
6
|
import fs from "node:fs";
|
|
6
7
|
import os from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
@@ -1214,6 +1215,7 @@ grantsOnly = false) {
|
|
|
1214
1215
|
...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
|
|
1215
1216
|
...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
|
|
1216
1217
|
...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
|
|
1218
|
+
...(browseSettings.state ? { FOLDRUN_BROWSER_STATE: browseSettings.state } : {}),
|
|
1217
1219
|
...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
|
|
1218
1220
|
...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
|
|
1219
1221
|
};
|
|
@@ -2167,6 +2169,18 @@ stepIndex) {
|
|
|
2167
2169
|
const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
|
|
2168
2170
|
if (limitOpts.limits)
|
|
2169
2171
|
push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
|
|
2172
|
+
// `hooks:` — the account's, the workspace's, then the agent's own, each
|
|
2173
|
+
// once; resolved and run inside the step (hooks.ts, step-exec.ts).
|
|
2174
|
+
const stepHookNames = [...new Set([
|
|
2175
|
+
...hookNames(readAgentsMd(accountDir(tenant))?.data?.hooks),
|
|
2176
|
+
...hookNames(readAgentsMd(workspaceRootOf(agentDir))?.data?.hooks),
|
|
2177
|
+
...hookNames(front.hooks),
|
|
2178
|
+
])];
|
|
2179
|
+
const hookOpts = stepHookNames.length
|
|
2180
|
+
? { hookNames: stepHookNames, hookBase: { run_id: runId ?? null, step: stepIndex ?? null, agent: step.agent, workspace: path.basename(workspaceRootOf(agentDir)) } }
|
|
2181
|
+
: {};
|
|
2182
|
+
if (stepHookNames.length)
|
|
2183
|
+
push("info", `hooks: ${stepHookNames.join(", ")}`);
|
|
2170
2184
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
2171
2185
|
if (isolatedRun()) {
|
|
2172
2186
|
// The isolated path: the whole loop — model, built-in tools, scripts —
|
|
@@ -2325,6 +2339,7 @@ stepIndex) {
|
|
|
2325
2339
|
schema: resolvedSchema ?? undefined,
|
|
2326
2340
|
maxTurns: step.maxTurns,
|
|
2327
2341
|
...limitOpts,
|
|
2342
|
+
...hookOpts,
|
|
2328
2343
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
2329
2344
|
// Outward steps keep a browser of their own: an executor that
|
|
2330
2345
|
// could browse from the slim image through the account's pod
|
|
@@ -2683,6 +2698,7 @@ stepIndex) {
|
|
|
2683
2698
|
schema: resolvedSchema ?? undefined,
|
|
2684
2699
|
maxTurns: step.maxTurns,
|
|
2685
2700
|
...limitOpts,
|
|
2701
|
+
...hookOpts,
|
|
2686
2702
|
stopRequested,
|
|
2687
2703
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
2688
2704
|
// the proof a step left names THIS run, not one that came before —
|
package/dist/src/starter.js
CHANGED
|
@@ -263,6 +263,7 @@ only when they asked for exactly that deletion.
|
|
|
263
263
|
| \`memory/\` | what past runs learned — agents write here themselves |
|
|
264
264
|
| \`evals/\` | tests for agents, same idea as unit tests |
|
|
265
265
|
| \`skills/\` | a procedure an agent can follow, named in \`skills:\` |
|
|
266
|
+
| \`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 |
|
|
266
267
|
| \`tools/\` | a folder tool: its definition and its code together |
|
|
267
268
|
| \`scripts/\` | code the agents call, referenced by \`scripts:\` |
|
|
268
269
|
| \`state/\` | what runs accumulate. Yours to read, never deployed over |
|
|
@@ -439,6 +440,7 @@ size: large # small | large | heavy — the sandbox it rents
|
|
|
439
440
|
tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
|
|
440
441
|
disallowedTools: [Bash] # subtract from what it would otherwise have
|
|
441
442
|
skills: [house-style] # from skills/
|
|
443
|
+
hooks: [no-force-push] # from hooks/ or the library: code at every tool call
|
|
442
444
|
scripts: [summarise.py] # from scripts/, each becomes a callable tool
|
|
443
445
|
apis: [{ name: crm }] # an HTTP API, as a tool
|
|
444
446
|
mcpServers: {} # an MCP server, as a tool
|
package/dist/src/step-exec.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type HookDef, type HookRecord } from "./hooks.ts";
|
|
1
2
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
2
3
|
import type { Effort } from "./store.ts";
|
|
3
4
|
import type { TestEffect } from "./test-mode.ts";
|
|
@@ -40,6 +41,14 @@ export interface ExecOutcome {
|
|
|
40
41
|
browserPod?: BrowserPodOutcome;
|
|
41
42
|
}
|
|
42
43
|
export interface ExecOptions {
|
|
44
|
+
/** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
|
|
45
|
+
* inside its sandbox. `hookBase` is what every hook event carries. */
|
|
46
|
+
hooks?: HookDef[];
|
|
47
|
+
/** Or the names (`hooks:` from the account, workspace and agent), resolved
|
|
48
|
+
* here against this step's own roots — in a sandbox those are not the
|
|
49
|
+
* host's paths. A name that resolves to nothing refuses the step. */
|
|
50
|
+
hookNames?: string[];
|
|
51
|
+
hookBase?: Record<string, unknown>;
|
|
43
52
|
agentDir: string;
|
|
44
53
|
workspaceRoot: string;
|
|
45
54
|
libraryRoot: string;
|
|
@@ -151,6 +160,7 @@ export type EventExtra = {
|
|
|
151
160
|
operator?: OperatorEvent;
|
|
152
161
|
subagent?: string;
|
|
153
162
|
check?: boolean;
|
|
163
|
+
hook?: HookRecord;
|
|
154
164
|
};
|
|
155
165
|
/** Conservative per-token rates for a model nothing else can price — an
|
|
156
166
|
* unknown id on a gateway with no catalogue. Opus-class, so a ceiling
|
package/dist/src/step-exec.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// assembles the system prompt and builds MCP servers, then hands this the
|
|
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
|
+
import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks } from "./hooks.js";
|
|
10
11
|
import fs from "node:fs";
|
|
11
12
|
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
@@ -169,8 +170,49 @@ async function executeStepInner(opts, runQuery) {
|
|
|
169
170
|
// `limits:` — this step's counts. A new step, a retry of one, or a
|
|
170
171
|
// fan-out instance each start from zero.
|
|
171
172
|
const counter = new CallCounter(opts.limits);
|
|
173
|
+
// Hooks (hooks.ts): the person's own code at this dispatch, same on every
|
|
174
|
+
// model. Each runs in the agent's directory with a bare environment.
|
|
175
|
+
let hooks = opts.hooks ?? [];
|
|
176
|
+
if (opts.hookNames?.length) {
|
|
177
|
+
const r = resolveHooks({ workspaceRoot, libraryRoot, names: opts.hookNames });
|
|
178
|
+
const bad = [...r.missing.map((n) => `no hook named ${n} in hooks/ or the library`), ...r.errors];
|
|
179
|
+
if (bad.length) {
|
|
180
|
+
emit("error", `hooks: ${bad.join("; ")} — the step is refused rather than run without its hooks`);
|
|
181
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
182
|
+
}
|
|
183
|
+
hooks = [...hooks, ...r.hooks];
|
|
184
|
+
}
|
|
185
|
+
const hookBase = { ...(opts.hookBase ?? {}) };
|
|
186
|
+
const callHook = (hook, event) => runHook(hook, event, { cwd: agentDir, env: opts.env });
|
|
187
|
+
const record = (r, via) => emit(r.did === "error" || r.did === "deny" ? "error" : "info", hookLine(r), { hook: r, ...(via ? { subagent: via } : {}) });
|
|
188
|
+
// step.start — a gate: context it returns goes in front of the task; a
|
|
189
|
+
// hook that fails refuses the step rather than run it unguarded.
|
|
190
|
+
let prompt = opts.prompt;
|
|
191
|
+
for (const hook of hooks.filter((h) => h.on === "step.start")) {
|
|
192
|
+
const a = await callHook(hook, { ...hookBase, event: "step.start", hook: hook.name, prompt });
|
|
193
|
+
if (!a.ok) {
|
|
194
|
+
record({ hook: hook.name, event: "step.start", did: "error", reason: `${a.failure}, so the step is refused`, ms: a.ms });
|
|
195
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
196
|
+
}
|
|
197
|
+
// Exit 2 is a deny on every event (runHook). On a gate that means the
|
|
198
|
+
// step does not run — it used to come back ok with no context, and the
|
|
199
|
+
// step went ahead as if the hook had agreed.
|
|
200
|
+
if (a.decision === "deny") {
|
|
201
|
+
record({ hook: hook.name, event: "step.start", did: "deny", reason: `${a.reason}, so the step is refused`, ms: a.ms });
|
|
202
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
203
|
+
}
|
|
204
|
+
if (a.context) {
|
|
205
|
+
prompt = `${prompt}\n\n${a.context}`;
|
|
206
|
+
record({ hook: hook.name, event: "step.start", did: "context", ms: a.ms });
|
|
207
|
+
}
|
|
208
|
+
else if (a.note) {
|
|
209
|
+
record({ hook: hook.name, event: "step.start", did: "note", reason: a.note, ms: a.ms });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const toolHooks = hooks.filter((h) => h.on === "tool.call");
|
|
213
|
+
const resultHooks = hooks.filter((h) => h.on === "tool.result");
|
|
172
214
|
const q = runQuery({
|
|
173
|
-
prompt
|
|
215
|
+
prompt,
|
|
174
216
|
options: {
|
|
175
217
|
cwd: agentDir,
|
|
176
218
|
abortController: abort,
|
|
@@ -272,6 +314,46 @@ async function executeStepInner(opts, runQuery) {
|
|
|
272
314
|
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal.message } };
|
|
273
315
|
}
|
|
274
316
|
}
|
|
317
|
+
// tool.call hooks — after foldrun's own checks, so a hook never
|
|
318
|
+
// sees a call that was going to be refused anyway, and handed the
|
|
319
|
+
// input as the tool would get it (the workspace/ prefix expanded).
|
|
320
|
+
if (toolHooks.length) {
|
|
321
|
+
const input = (verdict?.updatedInput ?? hookInput.tool_input ?? {});
|
|
322
|
+
// A sub-agent's call says so: a hook that guards the step's own
|
|
323
|
+
// model differently from its delegates can tell them apart.
|
|
324
|
+
const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
|
|
325
|
+
for (const r of v.records)
|
|
326
|
+
record(r, via);
|
|
327
|
+
const refundCall = () => { if (counter.active)
|
|
328
|
+
counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners)); };
|
|
329
|
+
if (v.decision === "deny") {
|
|
330
|
+
refundCall();
|
|
331
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: v.reason } };
|
|
332
|
+
}
|
|
333
|
+
if (v.input) {
|
|
334
|
+
// A rewritten call is answered "allow", and an allow skips
|
|
335
|
+
// canUseTool — where the shell and path confinement live. So
|
|
336
|
+
// the rewrite goes through the same checks here: a hook can
|
|
337
|
+
// narrow what the agent does, never widen it.
|
|
338
|
+
const again = hookInput.tool_name === "Bash"
|
|
339
|
+
? checkBash(String(v.input.command ?? ""))
|
|
340
|
+
: isFilesystemTool(hookInput.tool_name)
|
|
341
|
+
? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
|
|
342
|
+
: { ok: true };
|
|
343
|
+
const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
|
|
344
|
+
// The sub-agent guard read the ORIGINAL input: a rewrite of a
|
|
345
|
+
// delegation could otherwise swap subagent_type for one this
|
|
346
|
+
// step never declared (or the SDK's general-purpose).
|
|
347
|
+
const delegation = guardSubagent ? guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: v.input }) : null;
|
|
348
|
+
if (!again.ok || !granted || delegation) {
|
|
349
|
+
refundCall();
|
|
350
|
+
const why = !granted ? `Tool ${hookInput.tool_name} is not enabled for this agent.` : delegation ? `the rewritten call: ${delegation}` : `the rewritten call: ${again.reason}`;
|
|
351
|
+
emit("error", why, via ? { subagent: via } : undefined);
|
|
352
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: why } };
|
|
353
|
+
}
|
|
354
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: again.updatedInput ?? v.input } };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
275
357
|
if (!verdict?.updatedInput)
|
|
276
358
|
return {};
|
|
277
359
|
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: verdict.updatedInput } };
|
|
@@ -281,12 +363,26 @@ async function executeStepInner(opts, runQuery) {
|
|
|
281
363
|
// after its next tool call, as context — never as a change to what it
|
|
282
364
|
// may do. A model that is only writing, calling no tools, hears it at
|
|
283
365
|
// the next call it makes.
|
|
284
|
-
...(opts.inbox
|
|
366
|
+
...(opts.inbox || resultHooks.length
|
|
285
367
|
? {
|
|
286
368
|
PostToolUse: [{
|
|
287
369
|
hooks: [async (hookInput) => {
|
|
288
370
|
if (hookInput.hook_event_name !== "PostToolUse")
|
|
289
371
|
return {};
|
|
372
|
+
// tool.result hooks watch: the call has happened, so one
|
|
373
|
+
// that fails is recorded and the step goes on.
|
|
374
|
+
for (const hook of resultHooks) {
|
|
375
|
+
if (!matchesTool(hook.tool, hookInput.tool_name))
|
|
376
|
+
continue;
|
|
377
|
+
const response = hookInput.tool_response;
|
|
378
|
+
const a = await callHook(hook, { ...hookBase, event: "tool.result", hook: hook.name, tool: { name: hookInput.tool_name, input: hookInput.tool_input ?? {} }, result: response ?? null });
|
|
379
|
+
if (!a.ok)
|
|
380
|
+
record({ hook: hook.name, event: "tool.result", did: "error", tool: hookInput.tool_name, reason: a.failure, ms: a.ms });
|
|
381
|
+
else if (a.note)
|
|
382
|
+
record({ hook: hook.name, event: "tool.result", did: "note", tool: hookInput.tool_name, reason: a.note, ms: a.ms });
|
|
383
|
+
}
|
|
384
|
+
if (!opts.inbox)
|
|
385
|
+
return {};
|
|
290
386
|
// A sub-agent's call: the message is for the step's own
|
|
291
387
|
// model, and draining it here would hand it to a context
|
|
292
388
|
// that ends with the delegation — and lose it.
|
|
@@ -614,6 +710,21 @@ async function executeStepInner(opts, runQuery) {
|
|
|
614
710
|
status = "failed";
|
|
615
711
|
emit("error", "the model stream ended without a result — the model process likely died (out of memory?)");
|
|
616
712
|
}
|
|
713
|
+
// step.end hooks watch the reply, and may rewrite it. One that fails is
|
|
714
|
+
// recorded and the reply stands: the work it describes is done.
|
|
715
|
+
if (status === "completed") {
|
|
716
|
+
for (const hook of hooks.filter((h) => h.on === "step.end")) {
|
|
717
|
+
const a = await callHook(hook, { ...hookBase, event: "step.end", hook: hook.name, reply: texts.join("\n").trim() });
|
|
718
|
+
if (!a.ok)
|
|
719
|
+
record({ hook: hook.name, event: "step.end", did: "error", reason: a.failure, ms: a.ms });
|
|
720
|
+
else if (a.reply !== undefined) {
|
|
721
|
+
texts.splice(0, texts.length, a.reply);
|
|
722
|
+
record({ hook: hook.name, event: "step.end", did: "reply", ms: a.ms });
|
|
723
|
+
}
|
|
724
|
+
else if (a.note)
|
|
725
|
+
record({ hook: hook.name, event: "step.end", did: "note", reason: a.note, ms: a.ms });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
617
728
|
const result = texts.join("\n").trim() || null;
|
|
618
729
|
// A step's reply is not one message: the model narrates ("Now let me read
|
|
619
730
|
// the outputs…"), calls tools, and answers last. Joining those and reading
|
package/dist/src/store.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { type WireFormat, type AuthShape } from "./providers.ts";
|
|
|
20
20
|
* core needs to know about which one it's in.
|
|
21
21
|
*/
|
|
22
22
|
export { singleWorkspace };
|
|
23
|
-
export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "state"];
|
|
23
|
+
export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "hooks", "state"];
|
|
24
24
|
export declare const STORAGE_DIR = "storage";
|
|
25
25
|
/** What this directory was called before. Read-only: nothing writes here any
|
|
26
26
|
* more, but installs created before the rename have bytes under it, and a
|
|
@@ -893,6 +893,16 @@ export interface RunEvent {
|
|
|
893
893
|
* detail), `output: json`, a schema. What the runner reads to decline a
|
|
894
894
|
* retry of an outward step whose tools already ran. */
|
|
895
895
|
check?: boolean;
|
|
896
|
+
/** Set on the line a hook wrote — a deny, a rewrite, context, a failure
|
|
897
|
+
* (hooks.ts). The run page and the Hooks page read these. */
|
|
898
|
+
hook?: {
|
|
899
|
+
hook: string;
|
|
900
|
+
event: string;
|
|
901
|
+
did: string;
|
|
902
|
+
tool?: string;
|
|
903
|
+
reason?: string;
|
|
904
|
+
ms: number;
|
|
905
|
+
};
|
|
896
906
|
}
|
|
897
907
|
/** A question an agent asked a person while its step ran (tools: [ask]). */
|
|
898
908
|
export interface StepQuestion {
|
package/dist/src/store.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldrun/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Agents are just folders. The format, the runner, the checks.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,23 @@
|
|
|
15
15
|
"default": "./dist/src/*.js"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
|
+
"foldrun": {
|
|
19
|
+
"publicApi": [
|
|
20
|
+
".",
|
|
21
|
+
"./git-hook",
|
|
22
|
+
"./agents-md",
|
|
23
|
+
"./browser-pod",
|
|
24
|
+
"./flow-patterns",
|
|
25
|
+
"./history",
|
|
26
|
+
"./hooks",
|
|
27
|
+
"./kinds",
|
|
28
|
+
"./layout",
|
|
29
|
+
"./limits",
|
|
30
|
+
"./providers",
|
|
31
|
+
"./subagents",
|
|
32
|
+
"./tool-names"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
18
35
|
"main": "./dist/index.js",
|
|
19
36
|
"types": "./dist/index.d.ts",
|
|
20
37
|
"files": [
|