@foldrun/core 0.5.0 → 0.7.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 +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -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/browser-pod.d.ts +20 -1
- package/dist/src/browser-pod.js +58 -9
- 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/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +24 -4
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +31 -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/mail.js +3 -1
- package/dist/src/model-credential.d.ts +30 -0
- package/dist/src/model-credential.js +98 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- 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/run-container.d.ts +6 -1
- package/dist/src/run-container.js +93 -20
- package/dist/src/runner.d.ts +239 -0
- package/dist/src/runner.js +380 -72
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +16 -0
- package/dist/src/script-tools.js +50 -8
- 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 +36 -4
- package/dist/src/step-exec.d.ts +22 -2
- package/dist/src/step-exec.js +70 -7
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +24 -0
- package/dist/src/store.js +91 -10
- package/dist/src/subagents.js +1 -1
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- 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 +4 -0
- package/package.json +1 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/browser-pod.ts +46 -9
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +28 -3
- package/src/evals.ts +23 -2
- package/src/host-env.ts +32 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/mail.ts +3 -1
- package/src/model-credential.ts +112 -0
- package/src/model-env.ts +66 -0
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/run-container.ts +92 -19
- package/src/runner.ts +385 -72
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +48 -6
- package/src/secrets.ts +34 -6
- package/src/starter.ts +42 -4
- package/src/step-exec.ts +75 -10
- package/src/storage.ts +13 -0
- package/src/store.ts +91 -9
- package/src/subagents.ts +1 -1
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +3 -3
- package/templates/hello/agents/notetaker/agent.md +2 -2
package/src/step-exec.ts
CHANGED
|
@@ -329,6 +329,13 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
329
329
|
// declares no tools gets none, instead of seeing the full Claude
|
|
330
330
|
// Code toolset and burning turns on denied calls.
|
|
331
331
|
tools: opts.allowed,
|
|
332
|
+
// Stated, never left to the SDK. Unset, it chose its own mode for the
|
|
333
|
+
// login it ran under, and in auto mode its classifier refused a granted
|
|
334
|
+
// script before canUseTool saw it: "denied by the Claude Code auto mode
|
|
335
|
+
// classifier. Reason: [Irreversible Local Destruction]" for backlink-
|
|
336
|
+
// desk's outreach_reset, which only marks old bodies STALE (2026-10-04
|
|
337
|
+
// and 05). The decision is ours: canUseTool and the PreToolUse hook.
|
|
338
|
+
permissionMode: "default",
|
|
332
339
|
// Deliberately NOT allowedTools: listing them there auto-approves and
|
|
333
340
|
// skips canUseTool entirely, which is where confinement happens. The
|
|
334
341
|
// toolset is still restricted by `tools` above.
|
|
@@ -354,8 +361,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
354
361
|
// With sub-agents, the SDK would also offer its built-in ones
|
|
355
362
|
// (general-purpose has every tool the session has). Only the declared
|
|
356
363
|
// ones may run; the guard in the hook below refuses the rest as well.
|
|
357
|
-
env:
|
|
364
|
+
env: sdkEnv(opts.env, subagents ? { CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : {}),
|
|
358
365
|
mcpServers: opts.mcpServers,
|
|
366
|
+
// Only the servers foldrun passes. See sdkEnv for the claude.ai half.
|
|
367
|
+
strictMcpConfig: true,
|
|
359
368
|
// canUseTool is only asked when the SDK wants permission, and it never
|
|
360
369
|
// asks for a read inside the cwd — so `Read workspace/storage/x` went
|
|
361
370
|
// to `<agentDir>/workspace/storage/x` unchecked and unrewritten. On
|
|
@@ -630,11 +639,23 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
630
639
|
...(block.is_error ? { err: true } : {}),
|
|
631
640
|
...(open.subagent ? { subagent: open.subagent } : {}),
|
|
632
641
|
});
|
|
642
|
+
// Why it failed, on the trail. The span above carried only `err`,
|
|
643
|
+
// so a refused call left the agent's own guess as the only account
|
|
644
|
+
// of it: backlink-desk's outreach_reset "denied by the permission
|
|
645
|
+
// classifier" (run-muufboma-2t6l, 2026-10-05) could not be checked.
|
|
646
|
+
if (block.is_error) {
|
|
647
|
+
const why = toolResultText(block.content).replace(/\s+/g, " ").trim().slice(0, 300);
|
|
648
|
+
if (why) emit("error", `${open.name}: ${why}`, { call: block.tool_use_id, ...(open.subagent ? { subagent: open.subagent } : {}) });
|
|
649
|
+
}
|
|
633
650
|
readPod(block.tool_use_id);
|
|
634
651
|
}
|
|
635
652
|
}
|
|
636
653
|
} else if (message.type === "result") {
|
|
637
654
|
status = message.subtype === "success" ? "completed" : "failed";
|
|
655
|
+
// Calls the SDK itself refused, which canUseTool never saw.
|
|
656
|
+
for (const d of ("permission_denials" in message && Array.isArray(message.permission_denials) ? message.permission_denials : [])) {
|
|
657
|
+
emit("error", `refused before it ran: ${String((d as { tool_name?: unknown }).tool_name ?? "a tool")}`);
|
|
658
|
+
}
|
|
638
659
|
if (message.subtype === "error_max_turns") {
|
|
639
660
|
emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
|
|
640
661
|
}
|
|
@@ -875,8 +896,8 @@ export async function checkVerify(
|
|
|
875
896
|
* and `matches:` test this, because it is also what the run's headline
|
|
876
897
|
* is read from: a reporter that narrated between tool calls ("Now let
|
|
877
898
|
* me write the report…") failed `matches: ^BAD\b` with a correct
|
|
878
|
-
* headline while the check read every turn joined. `judge:`
|
|
879
|
-
* the whole result. */
|
|
899
|
+
* headline while the check read every turn joined. `judge:` grades this
|
|
900
|
+
* too, as "the reply", with the whole result beside it as the work. */
|
|
880
901
|
conclusion?: string | null;
|
|
881
902
|
data?: unknown;
|
|
882
903
|
/** The step's own model environment, for `judge:` — it grades on the
|
|
@@ -939,7 +960,13 @@ export async function checkVerify(
|
|
|
939
960
|
return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
|
|
940
961
|
}
|
|
941
962
|
case "judge": {
|
|
942
|
-
|
|
963
|
+
// The reply is the final turn, as everywhere else; every turn goes
|
|
964
|
+
// beside it as the work, so a claim about what the step did can still
|
|
965
|
+
// be checked. Both as one <reply> failed `judge: the reply opens with
|
|
966
|
+
// GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
|
|
967
|
+
// text opened with the narration before it.
|
|
968
|
+
const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
|
|
969
|
+
const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
|
|
943
970
|
const ok = /^\s*PASS\b/i.test(verdict);
|
|
944
971
|
return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
|
|
945
972
|
}
|
|
@@ -952,9 +979,24 @@ export async function checkVerify(
|
|
|
952
979
|
* Same shape as the eval judge, and answered with one word first so the
|
|
953
980
|
* verdict is a prefix test rather than a reading.
|
|
954
981
|
*/
|
|
982
|
+
export function judgePrompt(rubric: string, reply: string, work: string): string {
|
|
983
|
+
const shown = reply.slice(0, 20_000);
|
|
984
|
+
const context = work.trim() && work.trim() !== reply.trim()
|
|
985
|
+
? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
|
|
986
|
+
`never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
|
|
987
|
+
: "";
|
|
988
|
+
return (
|
|
989
|
+
`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
990
|
+
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
991
|
+
context +
|
|
992
|
+
`<reply>\n${shown}\n</reply>`
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
955
996
|
async function judgeReply(
|
|
956
997
|
rubric: string,
|
|
957
|
-
|
|
998
|
+
reply: string,
|
|
999
|
+
work: string,
|
|
958
1000
|
env: Record<string, string | undefined>,
|
|
959
1001
|
signal?: AbortSignal,
|
|
960
1002
|
): Promise<string> {
|
|
@@ -964,16 +1006,14 @@ async function judgeReply(
|
|
|
964
1006
|
signal?.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
965
1007
|
try {
|
|
966
1008
|
const q = query({
|
|
967
|
-
prompt:
|
|
968
|
-
`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
969
|
-
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
970
|
-
`<reply>\n${output.slice(0, 40_000)}\n</reply>`,
|
|
1009
|
+
prompt: judgePrompt(rubric, reply, work),
|
|
971
1010
|
options: {
|
|
972
1011
|
model: "haiku",
|
|
973
1012
|
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
974
1013
|
tools: [],
|
|
975
1014
|
settingSources: [],
|
|
976
|
-
env,
|
|
1015
|
+
env: sdkEnv(env),
|
|
1016
|
+
strictMcpConfig: true,
|
|
977
1017
|
maxTurns: 1,
|
|
978
1018
|
abortController,
|
|
979
1019
|
},
|
|
@@ -1043,3 +1083,28 @@ function runVerify(
|
|
|
1043
1083
|
child.on("close", (code) => resolve({ code, out }));
|
|
1044
1084
|
});
|
|
1045
1085
|
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* The environment an Agent SDK session starts with: the step's, minus the
|
|
1089
|
+
* connectors of whoever is signed in to claude.ai on this machine. Claude
|
|
1090
|
+
* Code loads an account's claude.ai MCP servers (Docs, Drive, …) unless told
|
|
1091
|
+
* not to, and `settingSources: []` does not reach them — they come with the
|
|
1092
|
+
* login, not from a settings file. A local `foldrun run` handed every agent
|
|
1093
|
+
* the person's own connectors: the hello template's agents reported that
|
|
1094
|
+
* "the only tools I have are the Claude Docs ones". `strictMcpConfig` keeps
|
|
1095
|
+
* the rest of the on-disk MCP configuration out; this keeps the account's.
|
|
1096
|
+
*/
|
|
1097
|
+
export function sdkEnv(base: Record<string, string | undefined> | undefined, extra: Record<string, string> = {}): Record<string, string | undefined> {
|
|
1098
|
+
return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** The text of a tool result, whichever shape it came in: a string, or a
|
|
1102
|
+
* list of content blocks of which only the text ones say anything. */
|
|
1103
|
+
export function toolResultText(content: unknown): string {
|
|
1104
|
+
if (typeof content === "string") return content;
|
|
1105
|
+
if (!Array.isArray(content)) return "";
|
|
1106
|
+
return content
|
|
1107
|
+
.filter((b): b is { type: "text"; text: string } => !!b && (b as { type?: unknown }).type === "text" && typeof (b as { text?: unknown }).text === "string")
|
|
1108
|
+
.map((b) => b.text)
|
|
1109
|
+
.join(" ");
|
|
1110
|
+
}
|
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
|
|
@@ -317,6 +318,19 @@ export function assertCanonicalCase(rel: string) {
|
|
|
317
318
|
// fire (trigger-log.ts). Before it was listed here every deploy erased it.
|
|
318
319
|
export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
|
|
319
320
|
|
|
321
|
+
// The agent engine's own home files. Each step's sandbox runs the Claude
|
|
322
|
+
// Agent SDK — the engine, whatever model answers — with its home in the
|
|
323
|
+
// agent's folder, and the engine writes its settings (.claude.json, its
|
|
324
|
+
// backups), plugin caches and the full transcript of every session
|
|
325
|
+
// (.claude/projects/*.jsonl) there. Nothing in foldrun reads them. Copied
|
|
326
|
+
// back after every step, they had piled up to 5,274 files and 19 MB in one
|
|
327
|
+
// agent's folder on the box by 6 Oct 2026, carried every past conversation
|
|
328
|
+
// into every sandbox, and their clashing rewrites were the "write-back
|
|
329
|
+
// conflict on .claude.json" lines (and the big merges) of that day. A
|
|
330
|
+
// workspace's own .claude/agents/*.md — Claude Code subagents an author
|
|
331
|
+
// writes, imported as agents — sits at the workspace root and is untouched.
|
|
332
|
+
const ENGINE_HOME_RE = /^agents\/[^/]+\/\.claude([./]|$)/;
|
|
333
|
+
|
|
320
334
|
/** True for anything the platform, not the author or the agent, writes. */
|
|
321
335
|
export function isPlatformPath(rel: string): boolean {
|
|
322
336
|
const norm = rel.replaceAll("\\", "/");
|
|
@@ -325,6 +339,7 @@ export function isPlatformPath(rel: string): boolean {
|
|
|
325
339
|
// are the vault, as far as anything outside the platform is concerned.
|
|
326
340
|
if (/(^|\/)secrets\.json\./.test(norm)) return true;
|
|
327
341
|
if (norm === "trigger-log.jsonl.tmp") return true;
|
|
342
|
+
if (ENGINE_HOME_RE.test(norm)) return true;
|
|
328
343
|
return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
|
|
329
344
|
}
|
|
330
345
|
|
|
@@ -531,7 +546,7 @@ export function buildMemoryIndex(dir: string, prefix = ""): string | null {
|
|
|
531
546
|
// Workspace-scoped shared assets: skills/, memory/, tools/ sit beside agents/
|
|
532
547
|
// and are available to every agent in that workspace.
|
|
533
548
|
export function workspaceMemoryIndex(tenant: string, workspace: string): string | null {
|
|
534
|
-
return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "
|
|
549
|
+
return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "workspace/memory/");
|
|
535
550
|
}
|
|
536
551
|
|
|
537
552
|
// Knowledge is what you *gave* an agent — stable reference material an agent
|
|
@@ -543,7 +558,7 @@ export function knowledgeIndex(dir: string, prefix = ""): string | null {
|
|
|
543
558
|
}
|
|
544
559
|
|
|
545
560
|
export function workspaceKnowledgeIndex(tenant: string, workspace: string): string | null {
|
|
546
|
-
return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "
|
|
561
|
+
return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "workspace/knowledge/");
|
|
547
562
|
}
|
|
548
563
|
|
|
549
564
|
// One noun for capability. A tools/<name>.md file declares either an HTTP
|
|
@@ -625,6 +640,27 @@ export function toolIsOutward(data: Record<string, unknown>): boolean {
|
|
|
625
640
|
return data.outward === true;
|
|
626
641
|
}
|
|
627
642
|
|
|
643
|
+
/** How much of a tool.md body rides to the model with the tool. */
|
|
644
|
+
export const TOOL_GUIDE_MAX = 6000;
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The prose under a tool.md's frontmatter: how to call it, what it refuses,
|
|
648
|
+
* what its output means. Only `description:` used to reach the model, so
|
|
649
|
+
* agents invented what the body documented (5 Oct 2026). The fenced program
|
|
650
|
+
* of a single-file tool is code, not guidance, and stays out.
|
|
651
|
+
*/
|
|
652
|
+
export function toolGuide(body: string | undefined, inlineProgram = false): string | undefined {
|
|
653
|
+
if (!body) return undefined;
|
|
654
|
+
let text = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
|
655
|
+
// Only a single-file tool's program is cut; in a `run:` tool every fenced
|
|
656
|
+
// block is an example of a call, which is exactly what the model needs.
|
|
657
|
+
const inline = inlineProgram ? fencedCodeBlock(text) : null;
|
|
658
|
+
if (inline) text = text.slice(0, inline.start) + text.slice(inline.end);
|
|
659
|
+
text = text.trim();
|
|
660
|
+
if (!text) return undefined;
|
|
661
|
+
return text.length > TOOL_GUIDE_MAX ? `${text.slice(0, TOOL_GUIDE_MAX)}\n\n[the rest of tool.md is cut here]` : text;
|
|
662
|
+
}
|
|
663
|
+
|
|
628
664
|
export function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null {
|
|
629
665
|
const name = typeof data.name === "string" ? data.name : fallbackName;
|
|
630
666
|
// `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
|
|
@@ -659,16 +695,24 @@ export function parseToolDef(data: Record<string, unknown>, fallbackName: string
|
|
|
659
695
|
};
|
|
660
696
|
}
|
|
661
697
|
if (kind === "script") {
|
|
698
|
+
// `guide` is the body's, never the frontmatter's: an author's `guide:`
|
|
699
|
+
// would otherwise ride to the model in place of the manual.
|
|
700
|
+
if ("guide" in data) { data = { ...data }; delete data.guide; }
|
|
662
701
|
// The program is a run: path, or the file's own fenced code block — the
|
|
663
702
|
// single-file form, which is what lets a script tool read and edit like
|
|
664
703
|
// every other markdown document.
|
|
665
|
-
if (typeof data.run === "string")
|
|
704
|
+
if (typeof data.run === "string") {
|
|
705
|
+
const guide = toolGuide(body);
|
|
706
|
+
return { kind: "script", name, spec: { ...data, name, ...(guide ? { guide } : {}) } };
|
|
707
|
+
}
|
|
666
708
|
const inline = body ? fencedCode(body) : null;
|
|
667
709
|
if (!inline) return null;
|
|
668
|
-
|
|
710
|
+
const guide = toolGuide(body, true);
|
|
711
|
+
return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext, ...(guide ? { guide } : {}) } };
|
|
669
712
|
}
|
|
670
713
|
const [spec] = parseApis([{ ...data, name }]);
|
|
671
|
-
|
|
714
|
+
const guide = toolGuide(body);
|
|
715
|
+
return spec ? { kind: "http", name, spec: guide ? { ...spec, guide } : spec } : null;
|
|
672
716
|
}
|
|
673
717
|
|
|
674
718
|
/**
|
|
@@ -864,6 +908,8 @@ export interface ApiSpec {
|
|
|
864
908
|
name: string;
|
|
865
909
|
base: string;
|
|
866
910
|
description: string;
|
|
911
|
+
/** The tool.md body, for the model (see toolGuide). */
|
|
912
|
+
guide?: string;
|
|
867
913
|
headers: Record<string, string>; // values may contain ${SECRET_NAME}
|
|
868
914
|
query: Record<string, string>; // always-appended query params (e.g. api keys)
|
|
869
915
|
methods: string[]; // allowed HTTP methods; default GET only
|
|
@@ -1136,7 +1182,9 @@ export function parseApis(raw: unknown): ApiSpec[] {
|
|
|
1136
1182
|
out.push({
|
|
1137
1183
|
name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
1138
1184
|
...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
|
|
1139
|
-
|
|
1185
|
+
// `${FOLDRUN_API_URL}` and `${FOLDRUN_PUBLIC_URL}` are the install's own
|
|
1186
|
+
// addresses, filled here on the host so a desk never names one.
|
|
1187
|
+
base: trimSlashes(expandPlatformVars(base)),
|
|
1140
1188
|
description: typeof e.description === "string" ? e.description : "",
|
|
1141
1189
|
headers: asRecordLocal(e.headers),
|
|
1142
1190
|
query: asRecordLocal(e.query),
|
|
@@ -2875,6 +2923,12 @@ export function setWorkspaceDescription(tenant: string, workspace: string, descr
|
|
|
2875
2923
|
* this copy still said `type: Agent` after the CLI's had moved to `kind:`,
|
|
2876
2924
|
* so every workspace made from the dashboard was born in the old format.
|
|
2877
2925
|
*/
|
|
2926
|
+
/** What the dashboard's "Create workspace" makes: blank (blankWorkspaceFiles),
|
|
2927
|
+
* minus the laptop-only files, for the same reason as templateFiles. */
|
|
2928
|
+
export function blankTemplateFiles(workspace: string): DeployFile[] {
|
|
2929
|
+
return blankWorkspaceFiles(workspace).filter((f) => f.path === "AGENTS.md" || IN_WORKSPACE_DIR.test(f.path));
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2878
2932
|
export function templateFiles(workspace: string): DeployFile[] {
|
|
2879
2933
|
// The starter minus what only matters on a laptop's disk: .gitignore
|
|
2880
2934
|
// guards a local clone's secrets, but the hosted store never keeps
|
|
@@ -3085,6 +3139,10 @@ export interface StepRecord {
|
|
|
3085
3139
|
* through the account's browser pod. The last attempt's; absent where
|
|
3086
3140
|
* the executor has one image (docker, in-process). */
|
|
3087
3141
|
image?: { variant: "full" | "slim"; why?: string; pod?: boolean } | null;
|
|
3142
|
+
/** Which model credential the step ran on, by its variable — "API key
|
|
3143
|
+
* (ANTHROPIC_API_KEY)", "Claude login token (CLAUDE_CODE_OAUTH_TOKEN)",
|
|
3144
|
+
* "provider: …" — or "none set" (model-credential.ts). */
|
|
3145
|
+
credential?: string;
|
|
3088
3146
|
/** Slim browsing through the account's pod (browser-pod.ts): reconnect
|
|
3089
3147
|
* tries and how many got through; when the pod was lost, why and what
|
|
3090
3148
|
* the step had written by then; and what happened — `fallback` (the
|
|
@@ -3384,7 +3442,23 @@ export function runOutputPath(
|
|
|
3384
3442
|
|
|
3385
3443
|
export function readRun(tenant: string, workspace: string, runId: string): RunRecord | null {
|
|
3386
3444
|
const p = runFilePath(tenant, workspace, runId);
|
|
3387
|
-
|
|
3445
|
+
// EFS/NFS: writeRun() replaces this file by rename, so an open()+read()
|
|
3446
|
+
// here can land on the just-unlinked inode and come back ESTALE (errno
|
|
3447
|
+
// -116, surfaced by Node as "Unknown system error -116", syscall read).
|
|
3448
|
+
// That isn't corruption — the next open() by path sees the committed new
|
|
3449
|
+
// inode — so re-read a few times on ESTALE and rethrow anything else.
|
|
3450
|
+
// (On dev-aws this surfaced as a CLI `--wait` call returning HTTP 500
|
|
3451
|
+
// from waitForRun's poll while the run itself completed fine.)
|
|
3452
|
+
const attempts = 5;
|
|
3453
|
+
for (let i = 1; ; i++) {
|
|
3454
|
+
try {
|
|
3455
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
|
|
3456
|
+
} catch (e) {
|
|
3457
|
+
const err = e as NodeJS.ErrnoException;
|
|
3458
|
+
if ((err.code === "ESTALE" || err.errno === -116) && i < attempts) continue;
|
|
3459
|
+
throw e;
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3388
3462
|
}
|
|
3389
3463
|
|
|
3390
3464
|
export function writeRun(tenant: string, workspace: string, run: RunRecord) {
|
|
@@ -3702,6 +3776,12 @@ export function runCost(run: RunRecord): number {
|
|
|
3702
3776
|
* fan-out template is a row in the trace rather than work — billing any of
|
|
3703
3777
|
* them would charge for steps the customer can see never executed, which is
|
|
3704
3778
|
* the fastest way to lose an argument about an invoice.
|
|
3779
|
+
*
|
|
3780
|
+
* Tokens count only for steps that ran on the platform's own model
|
|
3781
|
+
* credential. A step on the account's own key (`credential` "provider: …" —
|
|
3782
|
+
* a provider: block, or Settings → Model) was paid for at the provider, and
|
|
3783
|
+
* billing it again here charged a BYOK customer twice. And a carried step's
|
|
3784
|
+
* tokens were billed where it ran, like its compute.
|
|
3705
3785
|
*/
|
|
3706
3786
|
export function runMeter(run: RunRecord): {
|
|
3707
3787
|
tokenCostUsd: number;
|
|
@@ -3716,17 +3796,19 @@ export function runMeter(run: RunRecord): {
|
|
|
3716
3796
|
let computeSecs = 0;
|
|
3717
3797
|
let smallSecs = 0;
|
|
3718
3798
|
let netBytes = 0;
|
|
3799
|
+
let tokenCostUsd = 0;
|
|
3719
3800
|
for (const s of run.steps) {
|
|
3720
3801
|
if (s.status !== "completed" && s.status !== "failed") continue;
|
|
3721
3802
|
// A carried step ran — and was billed — in the run it was carried from.
|
|
3722
3803
|
if (s.carriedFrom) continue;
|
|
3723
3804
|
steps += 1;
|
|
3805
|
+
if (!(s.credential ?? "").startsWith("provider:")) tokenCostUsd += s.costUsd ?? 0;
|
|
3724
3806
|
const secs = s.computeSecs ?? 0;
|
|
3725
3807
|
computeSecs += secs;
|
|
3726
3808
|
if (s.size === "small") smallSecs += secs;
|
|
3727
3809
|
netBytes += (s.actual?.rxBytes ?? 0) + (s.actual?.txBytes ?? 0);
|
|
3728
3810
|
}
|
|
3729
|
-
return { tokenCostUsd
|
|
3811
|
+
return { tokenCostUsd, steps, computeSecs, smallSecs, netBytes };
|
|
3730
3812
|
}
|
|
3731
3813
|
|
|
3732
3814
|
/**
|
package/src/subagents.ts
CHANGED
|
@@ -97,7 +97,7 @@ export function subagentPrompt(o: {
|
|
|
97
97
|
`# Where you are\n\n` +
|
|
98
98
|
`You are ${o.name}, a sub-agent started by ${o.parent} to do one job and report back. ` +
|
|
99
99
|
`You work in ${o.parent}'s directory, \`agents/${o.parent}/\` in the \`${o.workspace}\` workspace: ` +
|
|
100
|
-
|
|
100
|
+
`\`workspace/\` is the workspace root, its knowledge is at \`workspace/knowledge/\`, and absolute paths are refused. ` +
|
|
101
101
|
`You have only the tools listed for you. When the job is done, reply with what you found or made — ` +
|
|
102
102
|
`your reply is all ${o.parent} sees of your work, so put the facts and file paths in it.`,
|
|
103
103
|
...(o.shared?.trim() ? [o.shared.trim()] : []),
|
package/src/test-mode.ts
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
// a test run; the env var says TEST_MODE_WITHHELD instead, so a script
|
|
19
19
|
// that forgot to check fails at the provider with an auth error
|
|
20
20
|
// - the write-back: state/ and storage/ go INTO the sandbox as usual and
|
|
21
|
-
// come out into runs/<id>/test-writes/ instead of the real directories
|
|
21
|
+
// come out into runs/<id>/test-writes/ instead of the real directories;
|
|
22
|
+
// the next step's copy has those writes laid over it (overlayTestWrites),
|
|
23
|
+
// so the run reads its own work and the workspace never sees it
|
|
22
24
|
//
|
|
23
25
|
// Nothing here weakens a live run: every function is a no-op unless the run
|
|
24
26
|
// says `test: true`.
|
|
@@ -270,25 +272,32 @@ export function snapshotDivertedDirs(wsRoot: string): Map<string, Buffer> {
|
|
|
270
272
|
|
|
271
273
|
/** Undo a step's writes to the diverted directories, keeping each changed
|
|
272
274
|
* or new file under `to` by its workspace-relative path. Deletions are
|
|
273
|
-
* undone too — the original is put back.
|
|
275
|
+
* undone too — the original is put back. `handed` is what the step was
|
|
276
|
+
* given when that was more than the workspace (the run's earlier test
|
|
277
|
+
* writes, laid over it): only what the step changed from THAT is its
|
|
278
|
+
* write. Returns what was set aside. */
|
|
274
279
|
export function restoreDivertedDirs(
|
|
275
280
|
wsRoot: string,
|
|
276
281
|
before: Map<string, Buffer>,
|
|
277
282
|
to: string,
|
|
278
283
|
note: (rel: string, summary: string) => void,
|
|
284
|
+
handed: Map<string, Buffer> = before,
|
|
279
285
|
): string[] {
|
|
280
286
|
const now = snapshotDivertedDirs(wsRoot);
|
|
281
287
|
const aside: string[] = [];
|
|
282
288
|
for (const [rel, next] of now) {
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
const given = handed.get(rel) ?? null;
|
|
290
|
+
if (!(given && given.equals(next))) {
|
|
291
|
+
const dest = path.join(to, rel);
|
|
292
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
293
|
+
fs.writeFileSync(dest, next);
|
|
294
|
+
aside.push(rel);
|
|
295
|
+
note(rel, divertedSummary(rel, before.get(rel) ?? null, next));
|
|
296
|
+
}
|
|
297
|
+
const orig = before.get(rel);
|
|
298
|
+
if (orig) {
|
|
299
|
+
if (!orig.equals(next)) fs.writeFileSync(path.join(wsRoot, rel), orig);
|
|
300
|
+
} else fs.rmSync(path.join(wsRoot, rel), { force: true });
|
|
292
301
|
}
|
|
293
302
|
for (const [rel, was] of before) {
|
|
294
303
|
if (now.has(rel)) continue;
|
|
@@ -300,6 +309,37 @@ export function restoreDivertedDirs(
|
|
|
300
309
|
return aside;
|
|
301
310
|
}
|
|
302
311
|
|
|
312
|
+
/**
|
|
313
|
+
* A test run's later steps see what its earlier steps wrote. Those writes
|
|
314
|
+
* are kept under runs/<id>/test-writes/, never on the workspace, so each
|
|
315
|
+
* step's copy of the workspace gets them laid over it — otherwise a step
|
|
316
|
+
* reads the real files, and a flow that hands work on through storage/
|
|
317
|
+
* tests something no live run does: on 2026-10-03 a strata-desk test run's
|
|
318
|
+
* compiler wrote today's PASS report, and the mailer was handed the
|
|
319
|
+
* morning's FAIL one and refused. Times are kept, so a verify that asks
|
|
320
|
+
* whether a file is fresh answers for the step that wrote it. Returns the
|
|
321
|
+
* workspace-relative paths laid over.
|
|
322
|
+
*/
|
|
323
|
+
export function overlayTestWrites(wsCopy: string, from: string): string[] {
|
|
324
|
+
const laid: string[] = [];
|
|
325
|
+
for (const dir of TEST_DIVERTED_DIRS) {
|
|
326
|
+
const root = path.join(from, dir);
|
|
327
|
+
if (!fs.existsSync(root)) continue;
|
|
328
|
+
for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) {
|
|
329
|
+
if (!entry.isFile()) continue;
|
|
330
|
+
const abs = path.join(entry.parentPath, entry.name);
|
|
331
|
+
const rel = path.relative(from, abs).replaceAll("\\", "/");
|
|
332
|
+
const dest = path.join(wsCopy, rel);
|
|
333
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
334
|
+
fs.copyFileSync(abs, dest);
|
|
335
|
+
const st = fs.statSync(abs);
|
|
336
|
+
fs.utimesSync(dest, st.atime, st.mtime);
|
|
337
|
+
laid.push(rel);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return laid;
|
|
341
|
+
}
|
|
342
|
+
|
|
303
343
|
/** `[test] ` in front of a headline, once. */
|
|
304
344
|
export function testHeadline(summary: string): string {
|
|
305
345
|
return summary.startsWith("[test]") ? summary : `[test] ${summary}`;
|
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) {
|