@foldrun/core 0.5.0 → 0.6.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 +1 -0
- package/dist/index.js +1 -0
- package/dist/src/browser-pod.d.ts +20 -1
- package/dist/src/browser-pod.js +58 -9
- 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/evals.js +3 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/mail.js +3 -1
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/run-container.d.ts +1 -1
- package/dist/src/run-container.js +27 -7
- package/dist/src/runner.d.ts +35 -0
- package/dist/src/runner.js +124 -37
- package/dist/src/script-tools.d.ts +14 -0
- package/dist/src/script-tools.js +47 -7
- package/dist/src/step-exec.d.ts +11 -0
- package/dist/src/step-exec.js +18 -2
- package/dist/src/store.d.ts +4 -0
- package/dist/src/store.js +2 -2
- 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/index.ts +1 -0
- package/package.json +1 -1
- package/src/browser-pod.ts +46 -9
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/evals.ts +3 -0
- package/src/host-env.ts +5 -0
- package/src/mail.ts +3 -1
- package/src/model-credential.ts +71 -0
- package/src/run-container.ts +27 -7
- package/src/runner.ts +122 -37
- package/src/script-tools.ts +43 -5
- package/src/step-exec.ts +19 -2
- package/src/store.ts +6 -2
- package/src/subagents.ts +1 -1
- package/src/test-mode.ts +51 -11
- 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/dist/src/runner.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SubagentSpec } from "./subagents.ts";
|
|
2
2
|
import { type FlowStep, type RunRecord, type StepRecord } from "./store.ts";
|
|
3
3
|
import { type Catalog } from "./catalog.ts";
|
|
4
|
+
import { type ScriptSpec } from "./script-tools.ts";
|
|
4
5
|
import { type RuntimeSpec } from "./runtime.ts";
|
|
5
6
|
import { type LanguageChoice } from "./language.ts";
|
|
6
7
|
import { type RegionChoice } from "./locale.ts";
|
|
@@ -408,3 +409,37 @@ export declare function waitForRun(tenant: string, workspace: string, runId: str
|
|
|
408
409
|
* nothing is what makes `?wait=true` useful rather than merely blocking.
|
|
409
410
|
*/
|
|
410
411
|
export declare function runResult(run: RunRecord): string | null;
|
|
412
|
+
/**
|
|
413
|
+
* What a Docker-run script sees of the workspace and the account: their
|
|
414
|
+
* scripts/ and their tools/, read-only. A folder tool's code lives in its
|
|
415
|
+
* tools/<name>/ folder, not in scripts/ — without those two mounts every
|
|
416
|
+
* folder tool (the hello template's wordcount among them) was handed a host
|
|
417
|
+
* path the container did not have, and failed on every call. A gallery tool
|
|
418
|
+
* nobody installed runs from the gallery's own shelf, so that is mounted too
|
|
419
|
+
* when there is one. A folder that does not exist is skipped by the copy.
|
|
420
|
+
*/
|
|
421
|
+
export declare function scriptMounts(agentDir: string, tenant: string): Record<string, string>;
|
|
422
|
+
/**
|
|
423
|
+
* The skills an agent can see, nearest first, each with the path the agent
|
|
424
|
+
* is told to read it at. Own skills, then the workspace's, then the
|
|
425
|
+
* workspace's `.agents/skills/` (the cross-client convention, scanned after
|
|
426
|
+
* the native location so a foldrun-native skill wins a name clash), then the
|
|
427
|
+
* account library. The nearest definition of a name wins.
|
|
428
|
+
*
|
|
429
|
+
* The workspace's are written `workspace/skills/…`, the one spelling for the
|
|
430
|
+
* workspace root, which the file tools expand themselves. `../../skills/…`
|
|
431
|
+
* left the model to resolve it against a path it had to guess, and on the
|
|
432
|
+
* hello template it climbed one level too far and was refused.
|
|
433
|
+
*/
|
|
434
|
+
export declare function skillsInScope(agentDir: string, tenant: string): DiscoveredSkill[];
|
|
435
|
+
/**
|
|
436
|
+
* The interpreters a set of scripts needs, read from what they are: the
|
|
437
|
+
* extension of `run:` (or of inline code), or an explicit `interpreter:`.
|
|
438
|
+
* Docker's script image is built from the runtime spec, and the base one is
|
|
439
|
+
* Python only, so a JavaScript tool that declared no `runtime:` (the demo's
|
|
440
|
+
* inline crm-upload, any folder tool's run.mjs) failed with no node to run
|
|
441
|
+
* it. Python is named too whenever a .py script is present, because asking
|
|
442
|
+
* for node alone builds on node:22-slim, which has no python3. Null when
|
|
443
|
+
* nothing beyond the default is needed.
|
|
444
|
+
*/
|
|
445
|
+
export declare function scriptLanguages(scripts: ScriptSpec[]): RuntimeSpec | null;
|
package/dist/src/runner.js
CHANGED
|
@@ -12,12 +12,13 @@ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js
|
|
|
12
12
|
import { eventUrl } from "./webhook.js";
|
|
13
13
|
import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
|
|
14
14
|
import { hostSafeEnv } from "./host-env.js";
|
|
15
|
+
import { resolveModelCredential, credentialLine } from "./model-credential.js";
|
|
15
16
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
|
|
16
17
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
|
|
17
18
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
|
|
18
19
|
import { platform } from "./platform.js";
|
|
19
20
|
import { healthKey } from "./secret-health.js";
|
|
20
|
-
import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
|
|
21
|
+
import { TEST_WRITES_DIR, overlayTestWrites, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
|
|
21
22
|
/** Does this process run steps in a sandbox — the container core ships, or
|
|
22
23
|
* one the platform registered (a pod)? */
|
|
23
24
|
const isolatedRun = () => {
|
|
@@ -657,8 +658,7 @@ grantsOnly = false) {
|
|
|
657
658
|
`\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
|
|
658
659
|
`\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
|
|
659
660
|
`and in the shell: the shared file store is \`workspace/storage/\`, what runs ` +
|
|
660
|
-
`carry forward is \`workspace/state
|
|
661
|
-
`still works, so a path like \`../../knowledge/\` below is \`workspace/knowledge/\`.) ` +
|
|
661
|
+
`carry forward is \`workspace/state/\`, its knowledge \`workspace/knowledge/\`. ` +
|
|
662
662
|
`Absolute paths are outside the workspace and will be refused.\n\n` +
|
|
663
663
|
// A flow's whole point is that a later step works on what an earlier one
|
|
664
664
|
// produced, and each agent writes to its own outputs/. A run showed what
|
|
@@ -707,25 +707,7 @@ grantsOnly = false) {
|
|
|
707
707
|
// skills for a few tokens each.
|
|
708
708
|
// Own skills, then the workspace's, then the workspace library — nearest
|
|
709
709
|
// definition of a name wins, so a team default can be overridden locally.
|
|
710
|
-
const skills =
|
|
711
|
-
const seenSkills = new Set();
|
|
712
|
-
const addSkills = (dir, prefix, subdir = "skills") => {
|
|
713
|
-
for (const skill of discoverSkills(dir, subdir)) {
|
|
714
|
-
if (seenSkills.has(skill.name))
|
|
715
|
-
continue;
|
|
716
|
-
seenSkills.add(skill.name);
|
|
717
|
-
skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
|
|
718
|
-
}
|
|
719
|
-
};
|
|
720
|
-
const workspaceRoot = path.join(agentDir, "..", "..");
|
|
721
|
-
addSkills(agentDir, "");
|
|
722
|
-
addSkills(workspaceRoot, "../../"); // the workspace's skills/
|
|
723
|
-
// The cross-client convention: skills any skills-compatible tool installs
|
|
724
|
-
// under .agents/skills/ are visible here too, and vice versa. Scanned after
|
|
725
|
-
// the native location so a foldrun-native skill wins a name clash, but both
|
|
726
|
-
// are discovered. project scope only — foldrun workspaces are self-contained.
|
|
727
|
-
addSkills(workspaceRoot, "../../", ".agents/skills");
|
|
728
|
-
addSkills(path.join(libraryDir(tenant)), "../../../../library/"); // the account library
|
|
710
|
+
const skills = skillsInScope(agentDir, tenant);
|
|
729
711
|
// A skill with `when:` only loads when the run carries a matching tag —
|
|
730
712
|
// so an agent with 119 skills costs 119 lines of context only when every
|
|
731
713
|
// one is genuinely relevant.
|
|
@@ -804,7 +786,7 @@ grantsOnly = false) {
|
|
|
804
786
|
return out.sort();
|
|
805
787
|
};
|
|
806
788
|
const own = listDir(path.join(agentDir, "scripts"), "scripts/");
|
|
807
|
-
const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "
|
|
789
|
+
const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "workspace/scripts/");
|
|
808
790
|
// The library's path depends on where this run executes: isolated runs see
|
|
809
791
|
// it at /library, host runs at its real location. Printing the host path
|
|
810
792
|
// into a container's prompt handed the model files it could never open.
|
|
@@ -1110,17 +1092,18 @@ grantsOnly = false) {
|
|
|
1110
1092
|
let exec = null;
|
|
1111
1093
|
let runtime = { interpreters: {}, env: {}, log: [], error: null };
|
|
1112
1094
|
if (executor === "docker" && !grantsOnly) {
|
|
1113
|
-
|
|
1095
|
+
// The script image also carries what the scripts' own languages need: a
|
|
1096
|
+
// .mjs tool with no runtime: block still needs node in its container.
|
|
1097
|
+
// Here only — the run container and the host have node already, and a
|
|
1098
|
+
// spec there sets off the runtime build for nothing.
|
|
1099
|
+
const image = ensureImage(mergeRuntimes(runtimeSpec, scriptLanguages(scriptSpecs)));
|
|
1114
1100
|
runtimeLog.push(...image.log);
|
|
1115
1101
|
runtimeError = image.error;
|
|
1116
1102
|
if (!image.error) {
|
|
1117
1103
|
exec = {
|
|
1118
1104
|
executor: "docker",
|
|
1119
1105
|
image: image.tag,
|
|
1120
|
-
mounts:
|
|
1121
|
-
[path.resolve(agentDir, "..", "..", "scripts")]: "/workspace-scripts",
|
|
1122
|
-
[libraryDir(tenant, "scripts")]: "/library-scripts",
|
|
1123
|
-
},
|
|
1106
|
+
mounts: scriptMounts(agentDir, tenant),
|
|
1124
1107
|
// Only agents that granted something reaching out — an `apis:` entry
|
|
1125
1108
|
// or one of their own tools — get egress.
|
|
1126
1109
|
network: parseApis(front.apis).length > 0 || ownToolNames(front).length > 0,
|
|
@@ -1367,8 +1350,8 @@ grantsOnly = false) {
|
|
|
1367
1350
|
? [
|
|
1368
1351
|
{ label: "knowledge/", dir: path.join(agentDir, "knowledge") },
|
|
1369
1352
|
{ label: "memory/", dir: path.join(agentDir, "memory") },
|
|
1370
|
-
{ label: "
|
|
1371
|
-
{ label: "
|
|
1353
|
+
{ label: "workspace/knowledge/", dir: path.join(wsRoot, "knowledge") },
|
|
1354
|
+
{ label: "workspace/memory/", dir: path.join(wsRoot, "memory") },
|
|
1372
1355
|
{ label: "workspace/state/", dir: path.join(wsRoot, "state") },
|
|
1373
1356
|
{ label: `workspace/${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
|
|
1374
1357
|
{ label: "outputs/", dir: path.join(agentDir, "outputs") },
|
|
@@ -1791,6 +1774,16 @@ stepIndex) {
|
|
|
1791
1774
|
push("error", problem);
|
|
1792
1775
|
if (providerLabel)
|
|
1793
1776
|
push("info", `provider: ${providerLabel}`);
|
|
1777
|
+
// Which credential the model is called with, on the record: an agent
|
|
1778
|
+
// that names a provider uses its own; otherwise the platform's (in a
|
|
1779
|
+
// run container) or this process's — never a login that just happens
|
|
1780
|
+
// to be on the machine (model-credential.ts).
|
|
1781
|
+
const credential = providerLabel ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
|
|
1782
|
+
step.credential = credential ? credential.label : `provider: ${providerLabel}`;
|
|
1783
|
+
if (credential) {
|
|
1784
|
+
const line = credentialLine(credential);
|
|
1785
|
+
push(line.type, line.text);
|
|
1786
|
+
}
|
|
1794
1787
|
for (const w of providerWarnings)
|
|
1795
1788
|
push("error", w);
|
|
1796
1789
|
// A level that wrote a language nobody can read was skipped, not obeyed.
|
|
@@ -2420,9 +2413,13 @@ stepIndex) {
|
|
|
2420
2413
|
};
|
|
2421
2414
|
const again = { ...lastArgs, input: { ...lastArgs.input, image: "full" }, resume: null };
|
|
2422
2415
|
await lease?.commit();
|
|
2423
|
-
|
|
2424
|
-
|
|
2416
|
+
// Before the call, not after: a re-run that throws (the full
|
|
2417
|
+
// sandbox never started) is still the full go on the record — its
|
|
2418
|
+
// failed row read "slim" when this was set only on success.
|
|
2419
|
+
step.image = { variant: "full", why: "re-run after the browser pod was lost" };
|
|
2425
2420
|
step.browserPod = { ...first.browserPod, fallback: RERAN_ON_FULL };
|
|
2421
|
+
outcome = await runTracked(again);
|
|
2422
|
+
step.image = outcome.image ?? step.image;
|
|
2426
2423
|
}
|
|
2427
2424
|
else {
|
|
2428
2425
|
push("error", decision.message);
|
|
@@ -2499,7 +2496,11 @@ stepIndex) {
|
|
|
2499
2496
|
push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
|
|
2500
2497
|
}
|
|
2501
2498
|
const stepSecrets = held.env;
|
|
2502
|
-
|
|
2499
|
+
// The model credential for this machine's Claude Code — the agent's
|
|
2500
|
+
// own provider when it names one. The host allowlist drops it, so
|
|
2501
|
+
// without this every in-process step ran on the machine's login.
|
|
2502
|
+
const localModelEnv = Object.keys(providerEnv).length ? providerEnv : resolveModelCredential().env;
|
|
2503
|
+
const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...localModelEnv }, (type, text) => push(type, text));
|
|
2503
2504
|
// On this machine nobody can write in and there is no proxy: the
|
|
2504
2505
|
// question goes to the terminal when there is one, else unanswered.
|
|
2505
2506
|
const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
|
|
@@ -2566,6 +2567,9 @@ stepIndex) {
|
|
|
2566
2567
|
// under the run and the originals come back.
|
|
2567
2568
|
const attemptThrough = async (modelEnv, spec) => {
|
|
2568
2569
|
const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
|
|
2570
|
+
// What earlier steps of this test run wrote, laid over the real files
|
|
2571
|
+
// for this attempt; restoreDivertedDirs puts the originals back.
|
|
2572
|
+
const handed = before && runId && overlayTestWrites(workspaceRoot, divertFor(runId).to).length ? snapshotDivertedDirs(workspaceRoot) : before;
|
|
2569
2573
|
const t = spec ? await startTranslator(spec) : null;
|
|
2570
2574
|
try {
|
|
2571
2575
|
return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
|
|
@@ -2578,12 +2582,12 @@ stepIndex) {
|
|
|
2578
2582
|
}
|
|
2579
2583
|
if (before && runId) {
|
|
2580
2584
|
const d = divertFor(runId);
|
|
2581
|
-
restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
|
|
2585
|
+
restoreDivertedDirs(workspaceRoot, before, d.to, d.note, handed ?? before);
|
|
2582
2586
|
}
|
|
2583
2587
|
}
|
|
2584
2588
|
};
|
|
2585
2589
|
let credentialUsed = platformModelCredential();
|
|
2586
|
-
let outcome = await attemptThrough(
|
|
2590
|
+
let outcome = await attemptThrough(localModelEnv, translator);
|
|
2587
2591
|
// Same rule as the isolated path: a credential that rotated under the
|
|
2588
2592
|
// step is waited out and retried before anything else is considered.
|
|
2589
2593
|
if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
|
|
@@ -2593,7 +2597,7 @@ stepIndex) {
|
|
|
2593
2597
|
push("info", "the credential rotated under this step; retrying on the new one");
|
|
2594
2598
|
credentialUsed = rotated;
|
|
2595
2599
|
lastRefusal = "";
|
|
2596
|
-
outcome = await attemptThrough(
|
|
2600
|
+
outcome = await attemptThrough(localModelEnv, translator);
|
|
2597
2601
|
}
|
|
2598
2602
|
else {
|
|
2599
2603
|
push("info", "the credential did not change — this is the key itself, not a rotation");
|
|
@@ -2606,7 +2610,7 @@ stepIndex) {
|
|
|
2606
2610
|
push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
|
|
2607
2611
|
await sleep(wait);
|
|
2608
2612
|
lastRefusal = "";
|
|
2609
|
-
outcome = await attemptThrough(
|
|
2613
|
+
outcome = await attemptThrough(localModelEnv, translator);
|
|
2610
2614
|
}
|
|
2611
2615
|
// Same rule as the isolated path: one retry, on a refusal, on the
|
|
2612
2616
|
// declared or platform second supply.
|
|
@@ -4550,3 +4554,86 @@ export function runResult(run) {
|
|
|
4550
4554
|
}
|
|
4551
4555
|
return null;
|
|
4552
4556
|
}
|
|
4557
|
+
/**
|
|
4558
|
+
* What a Docker-run script sees of the workspace and the account: their
|
|
4559
|
+
* scripts/ and their tools/, read-only. A folder tool's code lives in its
|
|
4560
|
+
* tools/<name>/ folder, not in scripts/ — without those two mounts every
|
|
4561
|
+
* folder tool (the hello template's wordcount among them) was handed a host
|
|
4562
|
+
* path the container did not have, and failed on every call. A gallery tool
|
|
4563
|
+
* nobody installed runs from the gallery's own shelf, so that is mounted too
|
|
4564
|
+
* when there is one. A folder that does not exist is skipped by the copy.
|
|
4565
|
+
*/
|
|
4566
|
+
export function scriptMounts(agentDir, tenant) {
|
|
4567
|
+
const workspaceRoot = path.resolve(agentDir, "..", "..");
|
|
4568
|
+
const mounts = {
|
|
4569
|
+
[path.join(workspaceRoot, "scripts")]: "/workspace-scripts",
|
|
4570
|
+
[path.join(workspaceRoot, "tools")]: "/workspace-tools",
|
|
4571
|
+
[libraryDir(tenant, "scripts")]: "/library-scripts",
|
|
4572
|
+
[libraryDir(tenant, "tools")]: "/library-tools",
|
|
4573
|
+
};
|
|
4574
|
+
const gallery = platform.galleryDir();
|
|
4575
|
+
if (gallery)
|
|
4576
|
+
mounts[path.join(gallery, "tools")] = "/gallery-tools";
|
|
4577
|
+
return mounts;
|
|
4578
|
+
}
|
|
4579
|
+
/**
|
|
4580
|
+
* The skills an agent can see, nearest first, each with the path the agent
|
|
4581
|
+
* is told to read it at. Own skills, then the workspace's, then the
|
|
4582
|
+
* workspace's `.agents/skills/` (the cross-client convention, scanned after
|
|
4583
|
+
* the native location so a foldrun-native skill wins a name clash), then the
|
|
4584
|
+
* account library. The nearest definition of a name wins.
|
|
4585
|
+
*
|
|
4586
|
+
* The workspace's are written `workspace/skills/…`, the one spelling for the
|
|
4587
|
+
* workspace root, which the file tools expand themselves. `../../skills/…`
|
|
4588
|
+
* left the model to resolve it against a path it had to guess, and on the
|
|
4589
|
+
* hello template it climbed one level too far and was refused.
|
|
4590
|
+
*/
|
|
4591
|
+
export function skillsInScope(agentDir, tenant) {
|
|
4592
|
+
const skills = [];
|
|
4593
|
+
const seen = new Set();
|
|
4594
|
+
const add = (dir, prefix, subdir = "skills") => {
|
|
4595
|
+
for (const skill of discoverSkills(dir, subdir)) {
|
|
4596
|
+
if (seen.has(skill.name))
|
|
4597
|
+
continue;
|
|
4598
|
+
seen.add(skill.name);
|
|
4599
|
+
skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
|
|
4600
|
+
}
|
|
4601
|
+
};
|
|
4602
|
+
const workspaceRoot = path.join(agentDir, "..", "..");
|
|
4603
|
+
add(agentDir, "");
|
|
4604
|
+
add(workspaceRoot, "workspace/");
|
|
4605
|
+
add(workspaceRoot, "workspace/", ".agents/skills");
|
|
4606
|
+
// The account library keeps its relative path: `account/` is expanded by
|
|
4607
|
+
// the file tools only, and a skill's bundled scripts are run from the shell.
|
|
4608
|
+
add(path.join(libraryDir(tenant)), "../../../../library/");
|
|
4609
|
+
return skills;
|
|
4610
|
+
}
|
|
4611
|
+
/**
|
|
4612
|
+
* The interpreters a set of scripts needs, read from what they are: the
|
|
4613
|
+
* extension of `run:` (or of inline code), or an explicit `interpreter:`.
|
|
4614
|
+
* Docker's script image is built from the runtime spec, and the base one is
|
|
4615
|
+
* Python only, so a JavaScript tool that declared no `runtime:` (the demo's
|
|
4616
|
+
* inline crm-upload, any folder tool's run.mjs) failed with no node to run
|
|
4617
|
+
* it. Python is named too whenever a .py script is present, because asking
|
|
4618
|
+
* for node alone builds on node:22-slim, which has no python3. Null when
|
|
4619
|
+
* nothing beyond the default is needed.
|
|
4620
|
+
*/
|
|
4621
|
+
export function scriptLanguages(scripts) {
|
|
4622
|
+
const kind = (sc) => {
|
|
4623
|
+
const via = (sc.interpreter ?? "").split(/[\\/]/).pop() ?? "";
|
|
4624
|
+
if (/^node/.test(via))
|
|
4625
|
+
return "node";
|
|
4626
|
+
if (/^python/.test(via))
|
|
4627
|
+
return "python";
|
|
4628
|
+
const ext = (sc.run ? path.extname(sc.run) : sc.codeExt ?? "").toLowerCase();
|
|
4629
|
+
if ([".js", ".mjs", ".cjs", ".ts"].includes(ext))
|
|
4630
|
+
return "node";
|
|
4631
|
+
if (ext === ".py")
|
|
4632
|
+
return "python";
|
|
4633
|
+
return null;
|
|
4634
|
+
};
|
|
4635
|
+
const kinds = new Set(scripts.map(kind));
|
|
4636
|
+
if (!kinds.has("node"))
|
|
4637
|
+
return null;
|
|
4638
|
+
return { node: true, ...(kinds.has("python") ? { python: true } : {}), packages: [], npm: [] };
|
|
4639
|
+
}
|
|
@@ -45,6 +45,20 @@ export declare function runScript(cwd: string, spec: ScriptSpec, values: Record<
|
|
|
45
45
|
code: number | null;
|
|
46
46
|
out: string;
|
|
47
47
|
}>;
|
|
48
|
+
/**
|
|
49
|
+
* A host path as the script's container sees it: under the agent's own
|
|
50
|
+
* directory it is /workspace, under a mounted folder it is that mount, and
|
|
51
|
+
* anything else (an argument that only looks like a path) is left alone.
|
|
52
|
+
* Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
|
|
53
|
+
* `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
|
|
54
|
+
*/
|
|
55
|
+
export declare function containerPath(p: string, agentDir: string, mounts: Record<string, string>): string;
|
|
56
|
+
/** Where a `workspace/…` script argument is staged in a script container,
|
|
57
|
+
* or null when it is not one (or climbs out of the workspace). */
|
|
58
|
+
export declare function stageWorkspaceArg(arg: string, workspaceRoot: string): {
|
|
59
|
+
host: string;
|
|
60
|
+
container: string;
|
|
61
|
+
} | null;
|
|
48
62
|
export interface ExecutionContext {
|
|
49
63
|
/** "docker" nests each script in its own container; "sandbox" spawns it
|
|
50
64
|
* directly inside the isolation the step already has. */
|
package/dist/src/script-tools.js
CHANGED
|
@@ -183,14 +183,18 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
183
183
|
// Container path: same command, executed inside an isolated container
|
|
184
184
|
// with only the agent's directory (plus declared shared dirs) mounted.
|
|
185
185
|
if (exec && exec.executor === "docker") {
|
|
186
|
+
// `workspace/…` is the workspace root on the host (the step's link)
|
|
187
|
+
// and in a run container; here there is no link, so each such
|
|
188
|
+
// argument is staged: copied in, pointed at, and copied back after.
|
|
189
|
+
const workspaceRoot = path.resolve(cwd, "..", "..");
|
|
190
|
+
const staged = [];
|
|
186
191
|
const inContainer = (p) => {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return p;
|
|
192
|
+
const w = stageWorkspaceArg(p, workspaceRoot);
|
|
193
|
+
if (!w)
|
|
194
|
+
return containerPath(p, cwd, exec.mounts);
|
|
195
|
+
if (!staged.some((s) => s.host === w.host))
|
|
196
|
+
staged.push(w);
|
|
197
|
+
return w.container;
|
|
194
198
|
};
|
|
195
199
|
const argv = [
|
|
196
200
|
// The interpreter inside the image, not the host's venv path.
|
|
@@ -203,6 +207,7 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
203
207
|
runInContainer({
|
|
204
208
|
agentDir: path.resolve(cwd),
|
|
205
209
|
readOnly: exec.mounts,
|
|
210
|
+
staged,
|
|
206
211
|
image: exec.image,
|
|
207
212
|
argv,
|
|
208
213
|
env,
|
|
@@ -238,6 +243,41 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
238
243
|
}));
|
|
239
244
|
});
|
|
240
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* A host path as the script's container sees it: under the agent's own
|
|
248
|
+
* directory it is /workspace, under a mounted folder it is that mount, and
|
|
249
|
+
* anything else (an argument that only looks like a path) is left alone.
|
|
250
|
+
* Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
|
|
251
|
+
* `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
|
|
252
|
+
*/
|
|
253
|
+
export function containerPath(p, agentDir, mounts) {
|
|
254
|
+
if (!path.isAbsolute(p))
|
|
255
|
+
return p;
|
|
256
|
+
const under = (root) => {
|
|
257
|
+
const rel = path.relative(root, p);
|
|
258
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) ? rel : null;
|
|
259
|
+
};
|
|
260
|
+
const own = under(path.resolve(agentDir));
|
|
261
|
+
if (own !== null)
|
|
262
|
+
return path.posix.join("/workspace", own.split(path.sep).join("/"));
|
|
263
|
+
const hit = Object.entries(mounts)
|
|
264
|
+
.map(([host, mount]) => ({ mount, rel: under(path.resolve(host)), len: host.length }))
|
|
265
|
+
.filter((m) => m.rel !== null)
|
|
266
|
+
.sort((a, b) => b.len - a.len)[0];
|
|
267
|
+
return hit ? path.posix.join(hit.mount, hit.rel.split(path.sep).join("/")) : p;
|
|
268
|
+
}
|
|
269
|
+
/** Where a `workspace/…` script argument is staged in a script container,
|
|
270
|
+
* or null when it is not one (or climbs out of the workspace). */
|
|
271
|
+
export function stageWorkspaceArg(arg, workspaceRoot) {
|
|
272
|
+
if (arg !== "workspace" && !arg.startsWith("workspace/"))
|
|
273
|
+
return null;
|
|
274
|
+
const rel = arg.slice("workspace".length).replace(/^\/+/, "");
|
|
275
|
+
const host = path.resolve(workspaceRoot, rel);
|
|
276
|
+
const within = path.relative(workspaceRoot, host);
|
|
277
|
+
if (within.startsWith("..") || path.isAbsolute(within))
|
|
278
|
+
return null;
|
|
279
|
+
return { host, container: path.posix.join("/workspace-root", within.split(path.sep).join("/")) };
|
|
280
|
+
}
|
|
241
281
|
// `run:` accepts:
|
|
242
282
|
// scripts/x.py this agent's own script
|
|
243
283
|
// workspace/tools/<t>/x.py a folder tool's own code, in this workspace
|
package/dist/src/step-exec.d.ts
CHANGED
|
@@ -249,3 +249,14 @@ export declare function checkVerify(agentDir: string, verify: string, ctx: {
|
|
|
249
249
|
* person asked the run to stop. */
|
|
250
250
|
signal?: AbortSignal;
|
|
251
251
|
}): Promise<VerifyVerdict>;
|
|
252
|
+
/**
|
|
253
|
+
* The environment an Agent SDK session starts with: the step's, minus the
|
|
254
|
+
* connectors of whoever is signed in to claude.ai on this machine. Claude
|
|
255
|
+
* Code loads an account's claude.ai MCP servers (Docs, Drive, …) unless told
|
|
256
|
+
* not to, and `settingSources: []` does not reach them — they come with the
|
|
257
|
+
* login, not from a settings file. A local `foldrun run` handed every agent
|
|
258
|
+
* the person's own connectors: the hello template's agents reported that
|
|
259
|
+
* "the only tools I have are the Claude Docs ones". `strictMcpConfig` keeps
|
|
260
|
+
* the rest of the on-disk MCP configuration out; this keeps the account's.
|
|
261
|
+
*/
|
|
262
|
+
export declare function sdkEnv(base: Record<string, string | undefined> | undefined, extra?: Record<string, string>): Record<string, string | undefined>;
|
package/dist/src/step-exec.js
CHANGED
|
@@ -213,8 +213,10 @@ async function executeStepInner(opts, runQuery) {
|
|
|
213
213
|
// With sub-agents, the SDK would also offer its built-in ones
|
|
214
214
|
// (general-purpose has every tool the session has). Only the declared
|
|
215
215
|
// ones may run; the guard in the hook below refuses the rest as well.
|
|
216
|
-
env:
|
|
216
|
+
env: sdkEnv(opts.env, subagents ? { CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : {}),
|
|
217
217
|
mcpServers: opts.mcpServers,
|
|
218
|
+
// Only the servers foldrun passes. See sdkEnv for the claude.ai half.
|
|
219
|
+
strictMcpConfig: true,
|
|
218
220
|
// canUseTool is only asked when the SDK wants permission, and it never
|
|
219
221
|
// asks for a read inside the cwd — so `Read workspace/storage/x` went
|
|
220
222
|
// to `<agentDir>/workspace/storage/x` unchecked and unrewritten. On
|
|
@@ -827,7 +829,8 @@ async function judgeReply(rubric, output, env, signal) {
|
|
|
827
829
|
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
828
830
|
tools: [],
|
|
829
831
|
settingSources: [],
|
|
830
|
-
env,
|
|
832
|
+
env: sdkEnv(env),
|
|
833
|
+
strictMcpConfig: true,
|
|
831
834
|
maxTurns: 1,
|
|
832
835
|
abortController,
|
|
833
836
|
},
|
|
@@ -898,3 +901,16 @@ function runVerify(agentDir, command, env, data, signal) {
|
|
|
898
901
|
child.on("close", (code) => resolve({ code, out }));
|
|
899
902
|
});
|
|
900
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* The environment an Agent SDK session starts with: the step's, minus the
|
|
906
|
+
* connectors of whoever is signed in to claude.ai on this machine. Claude
|
|
907
|
+
* Code loads an account's claude.ai MCP servers (Docs, Drive, …) unless told
|
|
908
|
+
* not to, and `settingSources: []` does not reach them — they come with the
|
|
909
|
+
* login, not from a settings file. A local `foldrun run` handed every agent
|
|
910
|
+
* the person's own connectors: the hello template's agents reported that
|
|
911
|
+
* "the only tools I have are the Claude Docs ones". `strictMcpConfig` keeps
|
|
912
|
+
* the rest of the on-disk MCP configuration out; this keeps the account's.
|
|
913
|
+
*/
|
|
914
|
+
export function sdkEnv(base, extra = {}) {
|
|
915
|
+
return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
|
|
916
|
+
}
|
package/dist/src/store.d.ts
CHANGED
|
@@ -992,6 +992,10 @@ export interface StepRecord {
|
|
|
992
992
|
why?: string;
|
|
993
993
|
pod?: boolean;
|
|
994
994
|
} | null;
|
|
995
|
+
/** Which model credential the step ran on, by its variable — "API key
|
|
996
|
+
* (ANTHROPIC_API_KEY)", "Claude login token (CLAUDE_CODE_OAUTH_TOKEN)",
|
|
997
|
+
* "provider: …" — or "none set" (model-credential.ts). */
|
|
998
|
+
credential?: string;
|
|
995
999
|
/** Slim browsing through the account's pod (browser-pod.ts): reconnect
|
|
996
1000
|
* tries and how many got through; when the pod was lost, why and what
|
|
997
1001
|
* the step had written by then; and what happened — `fallback` (the
|
package/dist/src/store.js
CHANGED
|
@@ -483,7 +483,7 @@ export function buildMemoryIndex(dir, prefix = "") {
|
|
|
483
483
|
// Workspace-scoped shared assets: skills/, memory/, tools/ sit beside agents/
|
|
484
484
|
// and are available to every agent in that workspace.
|
|
485
485
|
export function workspaceMemoryIndex(tenant, workspace) {
|
|
486
|
-
return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "
|
|
486
|
+
return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "workspace/memory/");
|
|
487
487
|
}
|
|
488
488
|
// Knowledge is what you *gave* an agent — stable reference material an agent
|
|
489
489
|
// reads and never rewrites. Memory is what it *learned* and authors itself.
|
|
@@ -493,7 +493,7 @@ export function knowledgeIndex(dir, prefix = "") {
|
|
|
493
493
|
return buildMemoryIndex(dir, prefix);
|
|
494
494
|
}
|
|
495
495
|
export function workspaceKnowledgeIndex(tenant, workspace) {
|
|
496
|
-
return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "
|
|
496
|
+
return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "workspace/knowledge/");
|
|
497
497
|
}
|
|
498
498
|
// One noun for capability. A tools/<name>.md file declares either an HTTP
|
|
499
499
|
// endpoint or a script — `type:` says which, and it's inferred when omitted
|
package/dist/src/subagents.js
CHANGED
|
@@ -71,7 +71,7 @@ export function subagentPrompt(o) {
|
|
|
71
71
|
`# Where you are\n\n` +
|
|
72
72
|
`You are ${o.name}, a sub-agent started by ${o.parent} to do one job and report back. ` +
|
|
73
73
|
`You work in ${o.parent}'s directory, \`agents/${o.parent}/\` in the \`${o.workspace}\` workspace: ` +
|
|
74
|
-
|
|
74
|
+
`\`workspace/\` is the workspace root, its knowledge is at \`workspace/knowledge/\`, and absolute paths are refused. ` +
|
|
75
75
|
`You have only the tools listed for you. When the job is done, reply with what you found or made — ` +
|
|
76
76
|
`your reply is all ${o.parent} sees of your work, so put the facts and file paths in it.`,
|
|
77
77
|
...(o.shared?.trim() ? [o.shared.trim()] : []),
|
package/dist/src/test-mode.d.ts
CHANGED
|
@@ -103,8 +103,23 @@ export declare function divertedSummary(rel: string, was: Buffer | null, next: B
|
|
|
103
103
|
export declare function snapshotDivertedDirs(wsRoot: string): Map<string, Buffer>;
|
|
104
104
|
/** Undo a step's writes to the diverted directories, keeping each changed
|
|
105
105
|
* or new file under `to` by its workspace-relative path. Deletions are
|
|
106
|
-
* undone too — the original is put back.
|
|
107
|
-
|
|
106
|
+
* undone too — the original is put back. `handed` is what the step was
|
|
107
|
+
* given when that was more than the workspace (the run's earlier test
|
|
108
|
+
* writes, laid over it): only what the step changed from THAT is its
|
|
109
|
+
* write. Returns what was set aside. */
|
|
110
|
+
export declare function restoreDivertedDirs(wsRoot: string, before: Map<string, Buffer>, to: string, note: (rel: string, summary: string) => void, handed?: Map<string, Buffer>): string[];
|
|
111
|
+
/**
|
|
112
|
+
* A test run's later steps see what its earlier steps wrote. Those writes
|
|
113
|
+
* are kept under runs/<id>/test-writes/, never on the workspace, so each
|
|
114
|
+
* step's copy of the workspace gets them laid over it — otherwise a step
|
|
115
|
+
* reads the real files, and a flow that hands work on through storage/
|
|
116
|
+
* tests something no live run does: on 2026-10-03 a strata-desk test run's
|
|
117
|
+
* compiler wrote today's PASS report, and the mailer was handed the
|
|
118
|
+
* morning's FAIL one and refused. Times are kept, so a verify that asks
|
|
119
|
+
* whether a file is fresh answers for the step that wrote it. Returns the
|
|
120
|
+
* workspace-relative paths laid over.
|
|
121
|
+
*/
|
|
122
|
+
export declare function overlayTestWrites(wsCopy: string, from: string): string[];
|
|
108
123
|
/** `[test] ` in front of a headline, once. */
|
|
109
124
|
export declare function testHeadline(summary: string): string;
|
|
110
125
|
/** The env every test-run sandbox gets, beside its secrets. */
|
package/dist/src/test-mode.js
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`.
|
|
@@ -247,21 +249,27 @@ export function snapshotDivertedDirs(wsRoot) {
|
|
|
247
249
|
}
|
|
248
250
|
/** Undo a step's writes to the diverted directories, keeping each changed
|
|
249
251
|
* or new file under `to` by its workspace-relative path. Deletions are
|
|
250
|
-
* undone too — the original is put back.
|
|
251
|
-
|
|
252
|
+
* undone too — the original is put back. `handed` is what the step was
|
|
253
|
+
* given when that was more than the workspace (the run's earlier test
|
|
254
|
+
* writes, laid over it): only what the step changed from THAT is its
|
|
255
|
+
* write. Returns what was set aside. */
|
|
256
|
+
export function restoreDivertedDirs(wsRoot, before, to, note, handed = before) {
|
|
252
257
|
const now = snapshotDivertedDirs(wsRoot);
|
|
253
258
|
const aside = [];
|
|
254
259
|
for (const [rel, next] of now) {
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
260
|
+
const given = handed.get(rel) ?? null;
|
|
261
|
+
if (!(given && given.equals(next))) {
|
|
262
|
+
const dest = path.join(to, rel);
|
|
263
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
264
|
+
fs.writeFileSync(dest, next);
|
|
265
|
+
aside.push(rel);
|
|
266
|
+
note(rel, divertedSummary(rel, before.get(rel) ?? null, next));
|
|
267
|
+
}
|
|
268
|
+
const orig = before.get(rel);
|
|
269
|
+
if (orig) {
|
|
270
|
+
if (!orig.equals(next))
|
|
271
|
+
fs.writeFileSync(path.join(wsRoot, rel), orig);
|
|
272
|
+
}
|
|
265
273
|
else
|
|
266
274
|
fs.rmSync(path.join(wsRoot, rel), { force: true });
|
|
267
275
|
}
|
|
@@ -275,6 +283,38 @@ export function restoreDivertedDirs(wsRoot, before, to, note) {
|
|
|
275
283
|
}
|
|
276
284
|
return aside;
|
|
277
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* A test run's later steps see what its earlier steps wrote. Those writes
|
|
288
|
+
* are kept under runs/<id>/test-writes/, never on the workspace, so each
|
|
289
|
+
* step's copy of the workspace gets them laid over it — otherwise a step
|
|
290
|
+
* reads the real files, and a flow that hands work on through storage/
|
|
291
|
+
* tests something no live run does: on 2026-10-03 a strata-desk test run's
|
|
292
|
+
* compiler wrote today's PASS report, and the mailer was handed the
|
|
293
|
+
* morning's FAIL one and refused. Times are kept, so a verify that asks
|
|
294
|
+
* whether a file is fresh answers for the step that wrote it. Returns the
|
|
295
|
+
* workspace-relative paths laid over.
|
|
296
|
+
*/
|
|
297
|
+
export function overlayTestWrites(wsCopy, from) {
|
|
298
|
+
const laid = [];
|
|
299
|
+
for (const dir of TEST_DIVERTED_DIRS) {
|
|
300
|
+
const root = path.join(from, dir);
|
|
301
|
+
if (!fs.existsSync(root))
|
|
302
|
+
continue;
|
|
303
|
+
for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) {
|
|
304
|
+
if (!entry.isFile())
|
|
305
|
+
continue;
|
|
306
|
+
const abs = path.join(entry.parentPath, entry.name);
|
|
307
|
+
const rel = path.relative(from, abs).replaceAll("\\", "/");
|
|
308
|
+
const dest = path.join(wsCopy, rel);
|
|
309
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
310
|
+
fs.copyFileSync(abs, dest);
|
|
311
|
+
const st = fs.statSync(abs);
|
|
312
|
+
fs.utimesSync(dest, st.atime, st.mtime);
|
|
313
|
+
laid.push(rel);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return laid;
|
|
317
|
+
}
|
|
278
318
|
/** `[test] ` in front of a headline, once. */
|
|
279
319
|
export function testHeadline(summary) {
|
|
280
320
|
return summary.startsWith("[test]") ? summary : `[test] ${summary}`;
|
package/index.ts
CHANGED