@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/runner.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// flow, so there is a single execution path.
|
|
5
5
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import matter from "gray-matter";
|
|
9
10
|
import { spawn } from "node:child_process";
|
|
@@ -14,13 +15,14 @@ import { resolveAgentPath } from "./confine.ts";
|
|
|
14
15
|
import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.ts";
|
|
15
16
|
import { eventUrl } from "./webhook.ts";
|
|
16
17
|
import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
|
|
17
|
-
import { hostSafeEnv } from "./host-env.ts";
|
|
18
|
+
import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
|
|
19
|
+
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
|
|
18
20
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
19
21
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
|
|
20
22
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
|
|
21
23
|
import { platform } from "./platform.ts";
|
|
22
24
|
import { healthKey } from "./secret-health.ts";
|
|
23
|
-
import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.ts";
|
|
25
|
+
import { TEST_WRITES_DIR, overlayTestWrites, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.ts";
|
|
24
26
|
|
|
25
27
|
/** Does this process run steps in a sandbox — the container core ships, or
|
|
26
28
|
* one the platform registered (a pod)? */
|
|
@@ -85,10 +87,12 @@ import { attachOperations, prefetchOpenApi } from "./openapi.ts";
|
|
|
85
87
|
import { buildSearchTools, buildHistoryTools, buildDeskTools, digestRuns, type SearchRoot, type RunDigest } from "./context-tools.ts";
|
|
86
88
|
import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./translator.ts";
|
|
87
89
|
import { providerPreset } from "./providers.ts";
|
|
88
|
-
import { buildScriptTools, parseScripts, type ExecutionContext } from "./script-tools.ts";
|
|
90
|
+
import { buildScriptTools, parseScripts, type ExecutionContext, type ScriptSpec } from "./script-tools.ts";
|
|
89
91
|
import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.ts";
|
|
90
|
-
import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, type PreparedRuntime, type RuntimeSpec } from "./runtime.ts";
|
|
91
|
-
import {
|
|
92
|
+
import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, stepInstallEnv, type PreparedRuntime, type RuntimeSpec } from "./runtime.ts";
|
|
93
|
+
import { buildInstallTool, INSTALL_TOOL } from "./install-tool.ts";
|
|
94
|
+
import { dataRoot } from "./paths.ts";
|
|
95
|
+
import { materializeFiles, harvestFiles, storageBaseline } from "./storage.ts";
|
|
92
96
|
import { chooseExecutor, ensureImage } from "./container.ts";
|
|
93
97
|
import { stampBundle } from "./okf.ts";
|
|
94
98
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -706,7 +710,7 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
|
|
|
706
710
|
});
|
|
707
711
|
}
|
|
708
712
|
|
|
709
|
-
function agentContext(
|
|
713
|
+
export function agentContext(
|
|
710
714
|
agentDir: string,
|
|
711
715
|
tenant: string,
|
|
712
716
|
tags: string[] = [],
|
|
@@ -760,8 +764,7 @@ function agentContext(
|
|
|
760
764
|
`\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
|
|
761
765
|
`\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
|
|
762
766
|
`and in the shell: the shared file store is \`workspace/storage/\`, what runs ` +
|
|
763
|
-
`carry forward is \`workspace/state
|
|
764
|
-
`still works, so a path like \`../../knowledge/\` below is \`workspace/knowledge/\`.) ` +
|
|
767
|
+
`carry forward is \`workspace/state/\`, its knowledge \`workspace/knowledge/\`. ` +
|
|
765
768
|
`Absolute paths are outside the workspace and will be refused.\n\n` +
|
|
766
769
|
// A flow's whole point is that a later step works on what an earlier one
|
|
767
770
|
// produced, and each agent writes to its own outputs/. A run showed what
|
|
@@ -812,24 +815,7 @@ function agentContext(
|
|
|
812
815
|
// skills for a few tokens each.
|
|
813
816
|
// Own skills, then the workspace's, then the workspace library — nearest
|
|
814
817
|
// definition of a name wins, so a team default can be overridden locally.
|
|
815
|
-
const skills
|
|
816
|
-
const seenSkills = new Set<string>();
|
|
817
|
-
const addSkills = (dir: string, prefix: string, subdir = "skills") => {
|
|
818
|
-
for (const skill of discoverSkills(dir, subdir)) {
|
|
819
|
-
if (seenSkills.has(skill.name)) continue;
|
|
820
|
-
seenSkills.add(skill.name);
|
|
821
|
-
skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
|
|
822
|
-
}
|
|
823
|
-
};
|
|
824
|
-
const workspaceRoot = path.join(agentDir, "..", "..");
|
|
825
|
-
addSkills(agentDir, "");
|
|
826
|
-
addSkills(workspaceRoot, "../../"); // the workspace's skills/
|
|
827
|
-
// The cross-client convention: skills any skills-compatible tool installs
|
|
828
|
-
// under .agents/skills/ are visible here too, and vice versa. Scanned after
|
|
829
|
-
// the native location so a foldrun-native skill wins a name clash, but both
|
|
830
|
-
// are discovered. project scope only — foldrun workspaces are self-contained.
|
|
831
|
-
addSkills(workspaceRoot, "../../", ".agents/skills");
|
|
832
|
-
addSkills(path.join(libraryDir(tenant)), "../../../../library/"); // the account library
|
|
818
|
+
const skills = skillsInScope(agentDir, tenant);
|
|
833
819
|
|
|
834
820
|
// A skill with `when:` only loads when the run carries a matching tag —
|
|
835
821
|
// so an agent with 119 skills costs 119 lines of context only when every
|
|
@@ -919,7 +905,7 @@ function agentContext(
|
|
|
919
905
|
};
|
|
920
906
|
|
|
921
907
|
const own = listDir(path.join(agentDir, "scripts"), "scripts/");
|
|
922
|
-
const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "
|
|
908
|
+
const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "workspace/scripts/");
|
|
923
909
|
// The library's path depends on where this run executes: isolated runs see
|
|
924
910
|
// it at /library, host runs at its real location. Printing the host path
|
|
925
911
|
// into a container's prompt handed the model files it could never open.
|
|
@@ -1141,8 +1127,11 @@ function agentContext(
|
|
|
1141
1127
|
//
|
|
1142
1128
|
// The URL sits in git; the token is a ${SECRET} resolved server-side. An
|
|
1143
1129
|
// agent's own block wins over the workspace's, like everything else here.
|
|
1144
|
-
|
|
1130
|
+
// Then the account's own model key (Settings → Model), when it set one:
|
|
1131
|
+
// an account that brings its key need not write a block anywhere.
|
|
1132
|
+
const fileBlock =
|
|
1145
1133
|
(front.provider as Record<string, unknown> | undefined) ?? workspaceFrontmatter(agentDir, tenant).provider;
|
|
1134
|
+
const providerBlock = fileBlock ?? accountModelBlock((name) => getSecret(tenant, name)?.value ?? null);
|
|
1146
1135
|
let providerEnv: Record<string, string> = {};
|
|
1147
1136
|
let providerLabel: string | null = null;
|
|
1148
1137
|
// Secret values pulled into the provider block, name → value. Kept apart
|
|
@@ -1237,17 +1226,18 @@ function agentContext(
|
|
|
1237
1226
|
let runtime: PreparedRuntime = { interpreters: {}, env: {}, log: [], error: null };
|
|
1238
1227
|
|
|
1239
1228
|
if (executor === "docker" && !grantsOnly) {
|
|
1240
|
-
|
|
1229
|
+
// The script image also carries what the scripts' own languages need: a
|
|
1230
|
+
// .mjs tool with no runtime: block still needs node in its container.
|
|
1231
|
+
// Here only — the run container and the host have node already, and a
|
|
1232
|
+
// spec there sets off the runtime build for nothing.
|
|
1233
|
+
const image = ensureImage(mergeRuntimes(runtimeSpec, scriptLanguages(scriptSpecs)));
|
|
1241
1234
|
runtimeLog.push(...image.log);
|
|
1242
1235
|
runtimeError = image.error;
|
|
1243
1236
|
if (!image.error) {
|
|
1244
1237
|
exec = {
|
|
1245
1238
|
executor: "docker",
|
|
1246
1239
|
image: image.tag,
|
|
1247
|
-
mounts:
|
|
1248
|
-
[path.resolve(agentDir, "..", "..", "scripts")]: "/workspace-scripts",
|
|
1249
|
-
[libraryDir(tenant, "scripts")]: "/library-scripts",
|
|
1250
|
-
},
|
|
1240
|
+
mounts: scriptMounts(agentDir, tenant),
|
|
1251
1241
|
// Only agents that granted something reaching out — an `apis:` entry
|
|
1252
1242
|
// or one of their own tools — get egress.
|
|
1253
1243
|
network: parseApis(front.apis).length > 0 || ownToolNames(front).length > 0,
|
|
@@ -1294,6 +1284,9 @@ function agentContext(
|
|
|
1294
1284
|
// one — the `websearch` library tool reads it. Not a secret; rides with
|
|
1295
1285
|
// the clock so it reaches scripts, the sandbox and verify alike.
|
|
1296
1286
|
...(process.env.FOLDRUN_SEARCH_URL ? { FOLDRUN_SEARCH_URL: process.env.FOLDRUN_SEARCH_URL } : {}),
|
|
1287
|
+
// Where this install's own API answers (host-env.ts platformApiUrl), so a
|
|
1288
|
+
// script calls `$FOLDRUN_API_URL/workspaces/…` instead of naming a host.
|
|
1289
|
+
...(platformApiUrl() ? { FOLDRUN_API_URL: platformApiUrl()! } : {}),
|
|
1297
1290
|
// Whose index our web search asks when the agent named a search API.
|
|
1298
1291
|
// Unset means the account's own engine. The name, never the key.
|
|
1299
1292
|
...(searchChoice.shape === "direct" && searchChoice.provider
|
|
@@ -1381,7 +1374,20 @@ function agentContext(
|
|
|
1381
1374
|
);
|
|
1382
1375
|
if (scriptTools.promptLines.length) {
|
|
1383
1376
|
parts.push(
|
|
1384
|
-
`# Tools from scripts\n\nCall these directly — they run workspace scripts for you
|
|
1377
|
+
`# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
|
|
1378
|
+
`\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
// The web tool's own description lists every action, so an agent whose
|
|
1382
|
+
// search or fetch a model provider answers called web action=search,
|
|
1383
|
+
// was refused, and only then used WebSearch — eight refusals in one
|
|
1384
|
+
// reddit-desk validator step on 6 Oct 2026. Say it before the first call.
|
|
1385
|
+
if (builtins.length) {
|
|
1386
|
+
const named = builtins.map((b) => b.split("="));
|
|
1387
|
+
parts.push(
|
|
1388
|
+
`# Web search and fetch on this run\n\n` +
|
|
1389
|
+
named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
|
|
1390
|
+
`\n\nThe web tool's other actions work as usual.`,
|
|
1385
1391
|
);
|
|
1386
1392
|
}
|
|
1387
1393
|
if (declared.length) {
|
|
@@ -1395,7 +1401,13 @@ function agentContext(
|
|
|
1395
1401
|
"Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
|
|
1396
1402
|
"what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
|
|
1397
1403
|
"for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
|
|
1398
|
-
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool."
|
|
1404
|
+
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool. " +
|
|
1405
|
+
// 45 refusals in 34 runs (6–7 Oct 2026), nearly all an agent rewriting
|
|
1406
|
+
// a plan or draft an earlier run left: the Write tool refuses a file
|
|
1407
|
+
// that exists and has not been Read in this step, before any hook of
|
|
1408
|
+
// ours can see the call. Said once here instead of learned per step.
|
|
1409
|
+
"To replace a file that already exists, Read it first — Write refuses a file you have not Read in this step (a new file needs no Read). " +
|
|
1410
|
+
"Built-in tools are capitalised: Read, Write, Edit, Glob, Grep, Bash.",
|
|
1399
1411
|
);
|
|
1400
1412
|
|
|
1401
1413
|
// The run's one-line summary is the first meaningful line of the last step
|
|
@@ -1465,6 +1477,12 @@ function agentContext(
|
|
|
1465
1477
|
// names it (FOLDRUN_WEB_BUILTIN) rather than asking our engine.
|
|
1466
1478
|
if (toolName === "web" && !ref.linked) {
|
|
1467
1479
|
allowed.push(...providerWebTools);
|
|
1480
|
+
// web writes what it found to files and says "Read that file" — a
|
|
1481
|
+
// screenshot, a crawl, a page saved whole. An agent granted web alone
|
|
1482
|
+
// was told to Read and refused it (blog-desk claim-checker, 7 Oct
|
|
1483
|
+
// 2026: "Read is disabled for this session"). Reading is confined to
|
|
1484
|
+
// the workspace like every file tool; disallowedTools still removes it.
|
|
1485
|
+
if (!allowed.includes("Read")) allowed.push("Read");
|
|
1468
1486
|
continue;
|
|
1469
1487
|
}
|
|
1470
1488
|
|
|
@@ -1486,6 +1504,8 @@ function agentContext(
|
|
|
1486
1504
|
else if (toolName === "ask") wantAsk = true;
|
|
1487
1505
|
else wantDesks = true;
|
|
1488
1506
|
if (available[toolName]) shadowed.push(toolName);
|
|
1507
|
+
} else if (toolName === "install") {
|
|
1508
|
+
// Granted to every agent anyway (below); naming it is harmless.
|
|
1489
1509
|
} else if (BUILTIN_TOOLS.has(toolName)) {
|
|
1490
1510
|
allowed.push(toolName);
|
|
1491
1511
|
if (available[toolName]) shadowed.push(toolName);
|
|
@@ -1506,8 +1526,8 @@ function agentContext(
|
|
|
1506
1526
|
? [
|
|
1507
1527
|
{ label: "knowledge/", dir: path.join(agentDir, "knowledge") },
|
|
1508
1528
|
{ label: "memory/", dir: path.join(agentDir, "memory") },
|
|
1509
|
-
{ label: "
|
|
1510
|
-
{ label: "
|
|
1529
|
+
{ label: "workspace/knowledge/", dir: path.join(wsRoot, "knowledge") },
|
|
1530
|
+
{ label: "workspace/memory/", dir: path.join(wsRoot, "memory") },
|
|
1511
1531
|
{ label: "workspace/state/", dir: path.join(wsRoot, "state") },
|
|
1512
1532
|
{ label: `workspace/${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
|
|
1513
1533
|
{ label: "outputs/", dir: path.join(agentDir, "outputs") },
|
|
@@ -1547,6 +1567,11 @@ function agentContext(
|
|
|
1547
1567
|
// what it exposes, which is the unit a person actually reasons about anyway.
|
|
1548
1568
|
for (const name of mcpNames) allowed.push(`mcp__${name}`, `mcp__${name}__*`);
|
|
1549
1569
|
|
|
1570
|
+
// install — every agent may install what it finds it needs mid-step (apt,
|
|
1571
|
+
// pip, npm), declared or not (install-tool.ts). Granted by default; taken
|
|
1572
|
+
// away by disallowedTools: [install], and absent in plan mode (read-only).
|
|
1573
|
+
allowed.push(INSTALL_TOOL);
|
|
1574
|
+
|
|
1550
1575
|
// Claude Code's spellings, so an agent written for it mostly runs here.
|
|
1551
1576
|
// `permissionMode: plan` is its read-only mode; ours is the `read` group.
|
|
1552
1577
|
if (front.permissionMode === "plan") {
|
|
@@ -1554,6 +1579,7 @@ function agentContext(
|
|
|
1554
1579
|
allowed.push(...TOOL_MAP.read, ...apiTools.toolNames, ...scriptTools.toolNames);
|
|
1555
1580
|
}
|
|
1556
1581
|
const denied: string[] = refNames(front.disallowedTools);
|
|
1582
|
+
if (denied.includes("install")) denied.push(INSTALL_TOOL);
|
|
1557
1583
|
const finalAllowed = allowed.filter((t) => !denied.includes(t));
|
|
1558
1584
|
|
|
1559
1585
|
return {
|
|
@@ -1772,6 +1798,42 @@ async function terminalAsk(question: string, options?: string[]): Promise<string
|
|
|
1772
1798
|
}
|
|
1773
1799
|
}
|
|
1774
1800
|
|
|
1801
|
+
/**
|
|
1802
|
+
* What the person at the gate said, framed for the step's prompt — or "".
|
|
1803
|
+
*
|
|
1804
|
+
* The note is the freshest instruction the step has — later than the flow
|
|
1805
|
+
* file, aimed at this exact run. When the gate asked a question (ask:), the
|
|
1806
|
+
* note IS the answer and is framed as one.
|
|
1807
|
+
*
|
|
1808
|
+
* Both halves go through resolveDocLinks for the same reason the
|
|
1809
|
+
* instruction does: this is authored text on its way to becoming a prompt,
|
|
1810
|
+
* and the rule is that ALL of it expands. A person answering "which list?"
|
|
1811
|
+
* with [[dead-ends]] was sending the model two literal brackets — the one
|
|
1812
|
+
* reference syntax the workspace teaches, and the one place it silently did
|
|
1813
|
+
* nothing. The previous step's results are NOT resolved, deliberately: that
|
|
1814
|
+
* is model output, and a model that happens to emit [[brackets]] must not
|
|
1815
|
+
* thereby name a file.
|
|
1816
|
+
*
|
|
1817
|
+
* An ask: gate approved with no note still says so. It used to add nothing,
|
|
1818
|
+
* so the step never learned its question had been answered: gbp-desk's
|
|
1819
|
+
* rivals sender, told "a plain approval means all offices", saw no answer
|
|
1820
|
+
* at all and stopped BLOCKED (run-muu6r18y-417e, 2026-10-05).
|
|
1821
|
+
*/
|
|
1822
|
+
export function operatorBlock(step: Pick<StepRecord, "ask" | "approvalNote" | "approvedAt">, workspaceRoot: string): string {
|
|
1823
|
+
const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
|
|
1824
|
+
if (step.approvalNote) {
|
|
1825
|
+
const note = resolveDocLinks(step.approvalNote, workspaceRoot);
|
|
1826
|
+
return step.ask
|
|
1827
|
+
? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
|
|
1828
|
+
: `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
|
|
1829
|
+
}
|
|
1830
|
+
if (step.ask && step.approvedAt) {
|
|
1831
|
+
return `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer: they approved it with no note — a plain yes.\n</operator_answer>`;
|
|
1832
|
+
}
|
|
1833
|
+
return "";
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
|
|
1775
1837
|
async function runStep(
|
|
1776
1838
|
agentDir: string,
|
|
1777
1839
|
tenant: string,
|
|
@@ -1823,6 +1885,7 @@ async function runStep(
|
|
|
1823
1885
|
// cover a script's own stdout.
|
|
1824
1886
|
let redactions: [string, string][] = [];
|
|
1825
1887
|
let fileDir: string | null = null;
|
|
1888
|
+
let installDir: string | null = null;
|
|
1826
1889
|
let disposeRuntime: (() => void) | undefined;
|
|
1827
1890
|
// Can this step's tools act outside the workspace? Set once its context is
|
|
1828
1891
|
// read; the verdict at the end of this function reads it.
|
|
@@ -1969,6 +2032,39 @@ async function runStep(
|
|
|
1969
2032
|
// And one more only when something written could not be read as a zone.
|
|
1970
2033
|
for (const problem of clock.problems) push("error", problem);
|
|
1971
2034
|
if (providerLabel) push("info", `provider: ${providerLabel}`);
|
|
2035
|
+
// Which credential the model is called with, on the record: an agent
|
|
2036
|
+
// that names a provider uses its own; otherwise the platform's (in a
|
|
2037
|
+
// run container) or this process's — never a login that just happens
|
|
2038
|
+
// to be on the machine (model-credential.ts).
|
|
2039
|
+
const credential = providerLabel ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
|
|
2040
|
+
step.credential = credential ? credential.label : `provider: ${providerLabel}`;
|
|
2041
|
+
if (credential) {
|
|
2042
|
+
const line = credentialLine(credential);
|
|
2043
|
+
push(line.type, line.text);
|
|
2044
|
+
}
|
|
2045
|
+
// A hosted platform runs only its operator's accounts on its own model
|
|
2046
|
+
// credential; every other account brings its own API key. Refused here,
|
|
2047
|
+
// before a sandbox is rented, with what to do about it — not run on the
|
|
2048
|
+
// operator's key, and not failed later by a provider.
|
|
2049
|
+
if (!platform.platformKeyAllowed(tenant)) {
|
|
2050
|
+
const own = Object.values(providerSecrets).concat(Object.values(providerEnv));
|
|
2051
|
+
if (!providerLabel) {
|
|
2052
|
+
push(
|
|
2053
|
+
"error",
|
|
2054
|
+
`no model key: this account runs on its own API key, and none is set. Add one in Settings → Model ` +
|
|
2055
|
+
`(or \`foldrun model set <provider> --key …\`), or write a provider: block in AGENTS.md`,
|
|
2056
|
+
);
|
|
2057
|
+
step.status = "failed";
|
|
2058
|
+
save();
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (own.some((v) => isClaudeLoginToken(v))) {
|
|
2062
|
+
push("error", "model key: a Claude login token (sk-ant-oat…) cannot be used here — use an API key from your provider");
|
|
2063
|
+
step.status = "failed";
|
|
2064
|
+
save();
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
1972
2068
|
for (const w of providerWarnings) push("error", w);
|
|
1973
2069
|
// A level that wrote a language nobody can read was skipped, not obeyed.
|
|
1974
2070
|
for (const l of language.lines) push("info", l);
|
|
@@ -2149,28 +2245,7 @@ async function runStep(
|
|
|
2149
2245
|
// happening to contain brackets.
|
|
2150
2246
|
prompt += `\n\n<event>\nThis step waited for an external event. What arrived:\n${step.eventPayload}\n</event>`;
|
|
2151
2247
|
}
|
|
2152
|
-
|
|
2153
|
-
// The person at the gate said something while letting the run through.
|
|
2154
|
-
// That is the freshest instruction the step has — later than the flow
|
|
2155
|
-
// file, aimed at this exact run. When the gate asked a question
|
|
2156
|
-
// (ask:), the note IS the answer and is framed as one.
|
|
2157
|
-
//
|
|
2158
|
-
// Both halves go through resolveDocLinks for the same reason the
|
|
2159
|
-
// instruction above does: this is authored text on its way to becoming
|
|
2160
|
-
// a prompt, and the rule is that ALL of it expands. A person answering
|
|
2161
|
-
// "which list?" with [[dead-ends]] was sending the model two literal
|
|
2162
|
-
// brackets — the one reference syntax the workspace teaches, and the
|
|
2163
|
-
// one place it silently did nothing.
|
|
2164
|
-
//
|
|
2165
|
-
// What is NOT resolved, deliberately: the previous step's results
|
|
2166
|
-
// above. That is model output, not something a person wrote, and a
|
|
2167
|
-
// model that happens to emit [[brackets]] must not thereby name a file.
|
|
2168
|
-
const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
|
|
2169
|
-
const note = resolveDocLinks(step.approvalNote, workspaceRoot);
|
|
2170
|
-
prompt += step.ask
|
|
2171
|
-
? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
|
|
2172
|
-
: `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
|
|
2173
|
-
}
|
|
2248
|
+
prompt += operatorBlock(step, workspaceRoot);
|
|
2174
2249
|
if (step.delegate?.length) {
|
|
2175
2250
|
// The chooser's contract. The allowed set comes from the flow file;
|
|
2176
2251
|
// the runner parses exactly this shape back out of the result, so the
|
|
@@ -2216,6 +2291,9 @@ async function runStep(
|
|
|
2216
2291
|
// watched on both paths, because a refusal reads the same from a pod
|
|
2217
2292
|
// and from this process.
|
|
2218
2293
|
let lastRefusal = "";
|
|
2294
|
+
// The step's model connection was cut (isModelConnectionLost): the
|
|
2295
|
+
// platform's side going away under the step, not the step failing.
|
|
2296
|
+
let lastDrop = "";
|
|
2219
2297
|
// And WHY it refused, in words, when anything on the way saw the
|
|
2220
2298
|
// headers that say so: the translator here, the egress proxy over
|
|
2221
2299
|
// there. Kept apart from `lastRefusal` — that one decides whether to
|
|
@@ -2223,6 +2301,7 @@ async function runStep(
|
|
|
2223
2301
|
let refusalWhy = "";
|
|
2224
2302
|
const pushWatching: typeof push = (type, text, extra) => {
|
|
2225
2303
|
if (type === "error" && isProviderRefusal(text)) lastRefusal = text;
|
|
2304
|
+
if (type === "error" && isModelConnectionLost(text)) lastDrop = text;
|
|
2226
2305
|
const why = refusalFromLine(text);
|
|
2227
2306
|
if (why) refusalWhy = why;
|
|
2228
2307
|
push(type, text, extra);
|
|
@@ -2517,6 +2596,7 @@ async function runStep(
|
|
|
2517
2596
|
lastArgs = a;
|
|
2518
2597
|
return runIsolated(a);
|
|
2519
2598
|
};
|
|
2599
|
+
let attemptFrom = step.events.length;
|
|
2520
2600
|
let outcome = await runTracked(firstArgs);
|
|
2521
2601
|
step.sandbox = null; // whatever happens next starts its own
|
|
2522
2602
|
// A 401 from a token that rotated mid-step is not a broken key: the
|
|
@@ -2554,6 +2634,48 @@ async function runStep(
|
|
|
2554
2634
|
outcome = await runTracked(again);
|
|
2555
2635
|
outcome = withEarlierTiming(outcome, previous);
|
|
2556
2636
|
}
|
|
2637
|
+
// The model connection was cut under the step — the egress proxy or the
|
|
2638
|
+
// worker went away (6 Oct 2026: a worker restarted every 4.5 minutes and
|
|
2639
|
+
// every desk running failed on "Connection refused"). That is the
|
|
2640
|
+
// platform's fault, so the step runs again, whatever its `retry:` —
|
|
2641
|
+
// unless it can act outside the workspace and its tools already ran:
|
|
2642
|
+
// the post may be up, and a second attempt would post it again.
|
|
2643
|
+
for (let n = 1; n <= DROP_RETRIES && outcome.status === "failed" && lastDrop; n++) {
|
|
2644
|
+
if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
|
|
2645
|
+
push("info", `the model connection dropped after this step's tools ran — not retried: they can act outside the workspace, and another attempt could repeat what they did`);
|
|
2646
|
+
break;
|
|
2647
|
+
}
|
|
2648
|
+
const wait = Math.round(retryBaseMs() * 2 * n);
|
|
2649
|
+
push("info", `the model connection dropped (${lastDrop.slice(0, 80)}) — the platform's side, not the step's; running the step again in ${Math.round(wait / 1000)}s (${n} of ${DROP_RETRIES})`);
|
|
2650
|
+
await sleep(wait);
|
|
2651
|
+
lastDrop = "";
|
|
2652
|
+
lastRefusal = "";
|
|
2653
|
+
attemptFrom = step.events.length;
|
|
2654
|
+
const previous = outcome.timing;
|
|
2655
|
+
const again = isolatedArgs(platformModelEnv());
|
|
2656
|
+
await lease?.commit();
|
|
2657
|
+
outcome = withEarlierTiming(await runTracked(again), previous);
|
|
2658
|
+
}
|
|
2659
|
+
// The model wrote its tool call out as text — `<invoke name="…">` in
|
|
2660
|
+
// its last message — and stopped. Nothing ran, so nothing was done:
|
|
2661
|
+
// seo-digest's editor three times (27 Sep, 4 and 5 Oct 2026), its own
|
|
2662
|
+
// retry included, because the retry asked the same way. Run it once
|
|
2663
|
+
// more with that said plainly, on the same safety rule as a dropped
|
|
2664
|
+
// connection: never again after an outward tool already ran.
|
|
2665
|
+
for (let n = 1; n <= TEXT_CALL_RETRIES && wroteToolCallAsText(attemptText(outcome.result, step.events.slice(attemptFrom))); n++) {
|
|
2666
|
+
if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
|
|
2667
|
+
push("info", "the model wrote a tool call as text instead of making it — not run again: this step's tools can act outside the workspace and already ran");
|
|
2668
|
+
break;
|
|
2669
|
+
}
|
|
2670
|
+
push("info", `the model wrote a tool call as text instead of making it, so nothing ran — running the step again with a note (${n} of ${TEXT_CALL_RETRIES})`);
|
|
2671
|
+
attemptFrom = step.events.length;
|
|
2672
|
+
lastRefusal = "";
|
|
2673
|
+
const previous = outcome.timing;
|
|
2674
|
+
const base = isolatedArgs(platformModelEnv());
|
|
2675
|
+
const again = { ...base, input: { ...base.input, prompt: `${base.input.prompt}\n\n${TEXT_CALL_NOTE}` } };
|
|
2676
|
+
await lease?.commit();
|
|
2677
|
+
outcome = withEarlierTiming(await runTracked(again), previous);
|
|
2678
|
+
}
|
|
2557
2679
|
// The second supply, tried exactly once, and only when the primary
|
|
2558
2680
|
// refused over money/auth/limits rather than the work failing. The
|
|
2559
2681
|
// retry is a fresh sandbox — the failed one is gone, and driveRun's
|
|
@@ -2617,9 +2739,13 @@ async function runStep(
|
|
|
2617
2739
|
};
|
|
2618
2740
|
const again = { ...lastArgs, input: { ...lastArgs.input, image: "full" as const }, resume: null };
|
|
2619
2741
|
await lease?.commit();
|
|
2620
|
-
|
|
2621
|
-
|
|
2742
|
+
// Before the call, not after: a re-run that throws (the full
|
|
2743
|
+
// sandbox never started) is still the full go on the record — its
|
|
2744
|
+
// failed row read "slim" when this was set only on success.
|
|
2745
|
+
step.image = { variant: "full", why: "re-run after the browser pod was lost" };
|
|
2622
2746
|
step.browserPod = { ...first.browserPod!, fallback: RERAN_ON_FULL };
|
|
2747
|
+
outcome = await runTracked(again);
|
|
2748
|
+
step.image = outcome.image ?? step.image;
|
|
2623
2749
|
} else {
|
|
2624
2750
|
push("error", decision.message);
|
|
2625
2751
|
step.browserPod = { ...outcome.browserPod, failure: decision.message };
|
|
@@ -2692,9 +2818,13 @@ async function runStep(
|
|
|
2692
2818
|
push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
|
|
2693
2819
|
}
|
|
2694
2820
|
const stepSecrets = held.env;
|
|
2821
|
+
// The model credential for this machine's Claude Code — the agent's
|
|
2822
|
+
// own provider when it names one. The host allowlist drops it, so
|
|
2823
|
+
// without this every in-process step ran on the machine's login.
|
|
2824
|
+
const localModelEnv: Record<string, string> = Object.keys(providerEnv).length ? providerEnv : resolveModelCredential().env;
|
|
2695
2825
|
const consultTools = buildConsultTools(
|
|
2696
2826
|
consults,
|
|
2697
|
-
{ ...hostSafeEnv(), ...stepSecrets, ...
|
|
2827
|
+
{ ...hostSafeEnv(), ...stepSecrets, ...localModelEnv },
|
|
2698
2828
|
(type, text) => push(type, text),
|
|
2699
2829
|
);
|
|
2700
2830
|
// On this machine nobody can write in and there is no proxy: the
|
|
@@ -2704,6 +2834,20 @@ async function runStep(
|
|
|
2704
2834
|
// finally — once, however many attempts follow.
|
|
2705
2835
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
2706
2836
|
fileDir = mat.dir;
|
|
2837
|
+
// install: pip and npm land in a directory of this step's own, gone in
|
|
2838
|
+
// the finally; apt has no helper on a bare host and says so.
|
|
2839
|
+
installDir = allowed.includes(INSTALL_TOOL) ? fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-step-")) : null;
|
|
2840
|
+
const installEnv = installDir ? stepInstallEnv(installDir, { ...process.env, ...mat.env }) : {};
|
|
2841
|
+
const installTools = installDir
|
|
2842
|
+
? buildInstallTool({
|
|
2843
|
+
dir: installDir,
|
|
2844
|
+
socket: null,
|
|
2845
|
+
cacheRoot: path.join(dataRoot(), tenant, ".runtimes"),
|
|
2846
|
+
python: mat.env.VIRTUAL_ENV ? path.join(mat.env.VIRTUAL_ENV, "bin", "python") : "python3",
|
|
2847
|
+
env: { ...hostSafeEnv(), ...mat.env, ...installEnv },
|
|
2848
|
+
emit: (type, text) => push(type, text),
|
|
2849
|
+
})
|
|
2850
|
+
: null;
|
|
2707
2851
|
const attemptInProcess = (modelEnv: Record<string, string | undefined>) => executeStep({
|
|
2708
2852
|
agentDir,
|
|
2709
2853
|
workspaceRoot,
|
|
@@ -2723,6 +2867,7 @@ async function runStep(
|
|
|
2723
2867
|
...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
|
|
2724
2868
|
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
2725
2869
|
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
2870
|
+
...(installTools ? { foldrun_install: installTools.server } : {}),
|
|
2726
2871
|
...mcpServers,
|
|
2727
2872
|
},
|
|
2728
2873
|
// Declared secrets reach the agent's scripts as env vars; the model
|
|
@@ -2730,7 +2875,7 @@ async function runStep(
|
|
|
2730
2875
|
// the allowlisted host environment, not process.env: the SDK's
|
|
2731
2876
|
// Bash tool runs with this, and on a platform process.env holds
|
|
2732
2877
|
// every tenant's root key and the datastore credentials.
|
|
2733
|
-
env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
|
|
2878
|
+
env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...installEnv, ...modelEnv },
|
|
2734
2879
|
timeoutSec: step.timeout,
|
|
2735
2880
|
budgetUsd: stepBudgetUsd,
|
|
2736
2881
|
budgetNote: stepBudgetNote,
|
|
@@ -2763,6 +2908,10 @@ async function runStep(
|
|
|
2763
2908
|
// under the run and the originals come back.
|
|
2764
2909
|
const attemptThrough = async (modelEnv: Record<string, string | undefined>, spec: TranslatorSpec | null) => {
|
|
2765
2910
|
const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
|
|
2911
|
+
// What earlier steps of this test run wrote, laid over the real files
|
|
2912
|
+
// for this attempt; restoreDivertedDirs puts the originals back.
|
|
2913
|
+
const handed =
|
|
2914
|
+
before && runId && overlayTestWrites(workspaceRoot, divertFor(runId).to).length ? snapshotDivertedDirs(workspaceRoot) : before;
|
|
2766
2915
|
const t = spec ? await startTranslator(spec) : null;
|
|
2767
2916
|
try {
|
|
2768
2917
|
return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
|
|
@@ -2773,12 +2922,12 @@ async function runStep(
|
|
|
2773
2922
|
}
|
|
2774
2923
|
if (before && runId) {
|
|
2775
2924
|
const d = divertFor(runId);
|
|
2776
|
-
restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
|
|
2925
|
+
restoreDivertedDirs(workspaceRoot, before, d.to, d.note, handed ?? before);
|
|
2777
2926
|
}
|
|
2778
2927
|
}
|
|
2779
2928
|
};
|
|
2780
2929
|
let credentialUsed = platformModelCredential();
|
|
2781
|
-
let outcome = await attemptThrough(
|
|
2930
|
+
let outcome = await attemptThrough(localModelEnv, translator);
|
|
2782
2931
|
// Same rule as the isolated path: a credential that rotated under the
|
|
2783
2932
|
// step is waited out and retried before anything else is considered.
|
|
2784
2933
|
if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
|
|
@@ -2788,7 +2937,7 @@ async function runStep(
|
|
|
2788
2937
|
push("info", "the credential rotated under this step; retrying on the new one");
|
|
2789
2938
|
credentialUsed = rotated;
|
|
2790
2939
|
lastRefusal = "";
|
|
2791
|
-
outcome = await attemptThrough(
|
|
2940
|
+
outcome = await attemptThrough(localModelEnv, translator);
|
|
2792
2941
|
} else {
|
|
2793
2942
|
push("info", "the credential did not change — this is the key itself, not a rotation");
|
|
2794
2943
|
}
|
|
@@ -2800,7 +2949,7 @@ async function runStep(
|
|
|
2800
2949
|
push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
|
|
2801
2950
|
await sleep(wait);
|
|
2802
2951
|
lastRefusal = "";
|
|
2803
|
-
outcome = await attemptThrough(
|
|
2952
|
+
outcome = await attemptThrough(localModelEnv, translator);
|
|
2804
2953
|
}
|
|
2805
2954
|
// Same rule as the isolated path: one retry, on a refusal, on the
|
|
2806
2955
|
// declared or platform second supply.
|
|
@@ -2834,6 +2983,7 @@ async function runStep(
|
|
|
2834
2983
|
} finally {
|
|
2835
2984
|
try {
|
|
2836
2985
|
cleanupFileSecrets(fileDir);
|
|
2986
|
+
if (installDir) fs.rmSync(installDir, { recursive: true, force: true });
|
|
2837
2987
|
} catch {
|
|
2838
2988
|
// best-effort — a leftover scratch file is swept with the run's outputs
|
|
2839
2989
|
}
|
|
@@ -2990,8 +3140,12 @@ function platformFallbackEnv(): Record<string, string> | null {
|
|
|
2990
3140
|
const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
|
|
2991
3141
|
const CREDENTIAL_POLL_MS = 2_000;
|
|
2992
3142
|
|
|
2993
|
-
|
|
2994
|
-
|
|
3143
|
+
/** A refusal of the credential itself, in any of the wordings Claude Code
|
|
3144
|
+
* has used. "Failed to authenticate: OAuth token revoked" (9 Oct 2026, a
|
|
3145
|
+
* step started one second before the 09:07:52Z refresh) matched none of the
|
|
3146
|
+
* older two, so the step failed instead of waiting for the new token. */
|
|
3147
|
+
export function isAuthRefusal(text: string): boolean {
|
|
3148
|
+
return /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i.test(text);
|
|
2995
3149
|
}
|
|
2996
3150
|
|
|
2997
3151
|
/**
|
|
@@ -3042,6 +3196,49 @@ function isProviderRefusal(text: string): boolean {
|
|
|
3042
3196
|
);
|
|
3043
3197
|
}
|
|
3044
3198
|
|
|
3199
|
+
/** A step's own model connection cut: the egress proxy or the worker it
|
|
3200
|
+
* runs through went away. Only the run's final error, never a tool's — a
|
|
3201
|
+
* WebFetch that could not connect is the step's business, not ours. */
|
|
3202
|
+
export function isModelConnectionLost(text: string): boolean {
|
|
3203
|
+
return /^Claude Code returned an error result: API Error: (Connection (refused|dropped|error)|.*\b(ECONNREFUSED|ECONNRESET|socket hang up)\b)/i.test(text);
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
/** A tool call written out as text rather than made: the model's last
|
|
3207
|
+
* message holds `<invoke name=…>` / `<function_calls>` markup. Only the tail
|
|
3208
|
+
* counts — a reply that quotes such markup mid-way and goes on is fine. */
|
|
3209
|
+
export function wroteToolCallAsText(text: string): boolean {
|
|
3210
|
+
const tail = text.slice(-1500);
|
|
3211
|
+
const open = tail.search(/<(?:antml:)?(?:invoke|function_calls)\b[^>]*>/);
|
|
3212
|
+
if (open < 0) return false;
|
|
3213
|
+
// Quoted mid-reply and then the reply carries on: not a stalled call.
|
|
3214
|
+
const after = tail.slice(open).replace(/<\/(?:antml:)?(?:invoke|function_calls)>/g, "\u0000");
|
|
3215
|
+
const lastClose = after.lastIndexOf("\u0000");
|
|
3216
|
+
return lastClose < 0 || after.slice(lastClose + 1).trim().length < 200;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
/** What an attempt said last: its result, or — when it ended without one —
|
|
3220
|
+
* the error that carried the model's final text. */
|
|
3221
|
+
function attemptText(result: string | null | undefined, events: { type: string; text?: string }[]): string {
|
|
3222
|
+
if (result && result.trim()) return result;
|
|
3223
|
+
const lastError = [...events].reverse().find((e) => e.type === "error");
|
|
3224
|
+
return lastError?.text ?? "";
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/** Runs again after a tool call written as text. */
|
|
3228
|
+
export const TEXT_CALL_RETRIES = 1;
|
|
3229
|
+
export const TEXT_CALL_NOTE =
|
|
3230
|
+
"Note from the platform: your last attempt ended by writing a tool call out as text (an <invoke …> block), so it was not run and nothing happened. " +
|
|
3231
|
+
"Make every tool call as a real tool call — never type its markup into your message.";
|
|
3232
|
+
|
|
3233
|
+
/** How many times a step whose model connection was cut runs again. */
|
|
3234
|
+
export const DROP_RETRIES = 2;
|
|
3235
|
+
|
|
3236
|
+
/** May a step whose connection dropped run again? Yes, unless its tools can
|
|
3237
|
+
* act outward and one ran in this attempt (actedThenFailedCheck's rule). */
|
|
3238
|
+
export function dropRetrySafe(canActOutward: boolean, attemptEvents: { type: string }[]): boolean {
|
|
3239
|
+
return !(canActOutward && attemptEvents.some((e) => e.type === "tool"));
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3045
3242
|
/** How many times a busy provider is given another moment, and the longest
|
|
3046
3243
|
* any one wait may be. Three is the industry's habit and the point where
|
|
3047
3244
|
* waiting longer stops being kinder than failing. */
|
|
@@ -3631,6 +3828,32 @@ function driveRunInner(
|
|
|
3631
3828
|
//
|
|
3632
3829
|
// Runs on resume too — a parked run's container is long gone, and the
|
|
3633
3830
|
// one it comes back in needs the same files the first one had.
|
|
3831
|
+
//
|
|
3832
|
+
// A run re-driven after its worker died is the exception to "the store
|
|
3833
|
+
// is newer than the disk": a park harvests before it parks, a crash does
|
|
3834
|
+
// not, so what this run's finished steps wrote to storage/ is only on
|
|
3835
|
+
// disk, and materializing first wrote the store's older copy over it.
|
|
3836
|
+
// On 6 Oct 2026 a worker restart put reddit-desk's 2 Oct post back over
|
|
3837
|
+
// that morning's draft, and a rerun from the validator would have
|
|
3838
|
+
// posted it a second time. So a run that has been here before (its
|
|
3839
|
+
// baseline is on disk) stores what it changed first; files it did not
|
|
3840
|
+
// touch still come fresh from the store.
|
|
3841
|
+
const baselineFile = path.join(runRoot, "runs", run.id, "storage-baseline.json");
|
|
3842
|
+
if (fs.existsSync(baselineFile)) {
|
|
3843
|
+
try {
|
|
3844
|
+
const changedFrom = JSON.parse(fs.readFileSync(baselineFile, "utf8")) as Record<string, string>;
|
|
3845
|
+
const { saved } = await harvestFiles(tenant, workspace, `run:${run.id}`, { changedFrom });
|
|
3846
|
+
if (saved.length) {
|
|
3847
|
+
run.steps[0]?.events.push({
|
|
3848
|
+
t: new Date().toISOString(),
|
|
3849
|
+
type: "info",
|
|
3850
|
+
text: `files: kept ${saved.join(", ")} written before the run was picked up again`,
|
|
3851
|
+
});
|
|
3852
|
+
}
|
|
3853
|
+
} catch {
|
|
3854
|
+
// the materialize below still runs; at worst this is the old behaviour
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3634
3857
|
try {
|
|
3635
3858
|
const brought = await materializeFiles(tenant, workspace);
|
|
3636
3859
|
if (brought.length) {
|
|
@@ -3640,6 +3863,8 @@ function driveRunInner(
|
|
|
3640
3863
|
text: `files: ${brought.length} brought into the workspace`,
|
|
3641
3864
|
});
|
|
3642
3865
|
}
|
|
3866
|
+
fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
|
|
3867
|
+
fs.writeFileSync(baselineFile, JSON.stringify(storageBaseline(tenant, workspace)));
|
|
3643
3868
|
} catch (err) {
|
|
3644
3869
|
// A file store that is down must not take the run with it: most flows
|
|
3645
3870
|
// never touch storage/, and the ones that do will fail their own verify.
|
|
@@ -4105,7 +4330,7 @@ function driveRunInner(
|
|
|
4105
4330
|
save();
|
|
4106
4331
|
if (freshGroup.every((s) => s.status !== "pending")) {
|
|
4107
4332
|
// everything in this group was rejected
|
|
4108
|
-
if (freshGroup.some((s) => s.status === "failed" && !s.optional)) {
|
|
4333
|
+
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
|
|
4109
4334
|
run.status = "failed";
|
|
4110
4335
|
break;
|
|
4111
4336
|
}
|
|
@@ -4347,8 +4572,13 @@ function driveRunInner(
|
|
|
4347
4572
|
effort: step.effort,
|
|
4348
4573
|
timeout: step.timeout,
|
|
4349
4574
|
// The rescuer inherits the contract, not just the job: a step
|
|
4350
|
-
// that promised data still owes data when someone else does it
|
|
4575
|
+
// that promised data still owes data when someone else does it,
|
|
4576
|
+
// and a step that had to pass a check still has to pass it — a
|
|
4577
|
+
// rescue whose result stood unchecked was a way round the very
|
|
4578
|
+
// check that failed (found 7 Oct 2026, adding rescuers to
|
|
4579
|
+
// medium-desk).
|
|
4351
4580
|
output: step.output,
|
|
4581
|
+
...(step.verify ? { verify: step.verify } : {}),
|
|
4352
4582
|
attempts: 0,
|
|
4353
4583
|
status: "pending",
|
|
4354
4584
|
events: [],
|
|
@@ -4383,7 +4613,11 @@ function driveRunInner(
|
|
|
4383
4613
|
save();
|
|
4384
4614
|
}
|
|
4385
4615
|
|
|
4386
|
-
|
|
4616
|
+
// A step carried into a re-run failed in the run it came from, not
|
|
4617
|
+
// here: the person chose to start after it. Counting it stopped
|
|
4618
|
+
// fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
|
|
4619
|
+
// a second, before the one step it was asked to run.
|
|
4620
|
+
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
|
|
4387
4621
|
if (requiredFailed) {
|
|
4388
4622
|
run.status = "failed";
|
|
4389
4623
|
break;
|
|
@@ -4824,3 +5058,82 @@ export function runResult(run: RunRecord): string | null {
|
|
|
4824
5058
|
}
|
|
4825
5059
|
return null;
|
|
4826
5060
|
}
|
|
5061
|
+
|
|
5062
|
+
/**
|
|
5063
|
+
* What a Docker-run script sees of the workspace and the account: their
|
|
5064
|
+
* scripts/ and their tools/, read-only. A folder tool's code lives in its
|
|
5065
|
+
* tools/<name>/ folder, not in scripts/ — without those two mounts every
|
|
5066
|
+
* folder tool (the hello template's wordcount among them) was handed a host
|
|
5067
|
+
* path the container did not have, and failed on every call. A gallery tool
|
|
5068
|
+
* nobody installed runs from the gallery's own shelf, so that is mounted too
|
|
5069
|
+
* when there is one. A folder that does not exist is skipped by the copy.
|
|
5070
|
+
*/
|
|
5071
|
+
export function scriptMounts(agentDir: string, tenant: string): Record<string, string> {
|
|
5072
|
+
const workspaceRoot = path.resolve(agentDir, "..", "..");
|
|
5073
|
+
const mounts: Record<string, string> = {
|
|
5074
|
+
[path.join(workspaceRoot, "scripts")]: "/workspace-scripts",
|
|
5075
|
+
[path.join(workspaceRoot, "tools")]: "/workspace-tools",
|
|
5076
|
+
[libraryDir(tenant, "scripts")]: "/library-scripts",
|
|
5077
|
+
[libraryDir(tenant, "tools")]: "/library-tools",
|
|
5078
|
+
};
|
|
5079
|
+
const gallery = platform.galleryDir();
|
|
5080
|
+
if (gallery) mounts[path.join(gallery, "tools")] = "/gallery-tools";
|
|
5081
|
+
return mounts;
|
|
5082
|
+
}
|
|
5083
|
+
|
|
5084
|
+
/**
|
|
5085
|
+
* The skills an agent can see, nearest first, each with the path the agent
|
|
5086
|
+
* is told to read it at. Own skills, then the workspace's, then the
|
|
5087
|
+
* workspace's `.agents/skills/` (the cross-client convention, scanned after
|
|
5088
|
+
* the native location so a foldrun-native skill wins a name clash), then the
|
|
5089
|
+
* account library. The nearest definition of a name wins.
|
|
5090
|
+
*
|
|
5091
|
+
* The workspace's are written `workspace/skills/…`, the one spelling for the
|
|
5092
|
+
* workspace root, which the file tools expand themselves. `../../skills/…`
|
|
5093
|
+
* left the model to resolve it against a path it had to guess, and on the
|
|
5094
|
+
* hello template it climbed one level too far and was refused.
|
|
5095
|
+
*/
|
|
5096
|
+
export function skillsInScope(agentDir: string, tenant: string): DiscoveredSkill[] {
|
|
5097
|
+
const skills: DiscoveredSkill[] = [];
|
|
5098
|
+
const seen = new Set<string>();
|
|
5099
|
+
const add = (dir: string, prefix: string, subdir = "skills") => {
|
|
5100
|
+
for (const skill of discoverSkills(dir, subdir)) {
|
|
5101
|
+
if (seen.has(skill.name)) continue;
|
|
5102
|
+
seen.add(skill.name);
|
|
5103
|
+
skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
|
|
5104
|
+
}
|
|
5105
|
+
};
|
|
5106
|
+
const workspaceRoot = path.join(agentDir, "..", "..");
|
|
5107
|
+
add(agentDir, "");
|
|
5108
|
+
add(workspaceRoot, "workspace/");
|
|
5109
|
+
add(workspaceRoot, "workspace/", ".agents/skills");
|
|
5110
|
+
// The account library keeps its relative path: `account/` is expanded by
|
|
5111
|
+
// the file tools only, and a skill's bundled scripts are run from the shell.
|
|
5112
|
+
add(path.join(libraryDir(tenant)), "../../../../library/");
|
|
5113
|
+
return skills;
|
|
5114
|
+
}
|
|
5115
|
+
|
|
5116
|
+
/**
|
|
5117
|
+
* The interpreters a set of scripts needs, read from what they are: the
|
|
5118
|
+
* extension of `run:` (or of inline code), or an explicit `interpreter:`.
|
|
5119
|
+
* Docker's script image is built from the runtime spec, and the base one is
|
|
5120
|
+
* Python only, so a JavaScript tool that declared no `runtime:` (the demo's
|
|
5121
|
+
* inline crm-upload, any folder tool's run.mjs) failed with no node to run
|
|
5122
|
+
* it. Python is named too whenever a .py script is present, because asking
|
|
5123
|
+
* for node alone builds on node:22-slim, which has no python3. Null when
|
|
5124
|
+
* nothing beyond the default is needed.
|
|
5125
|
+
*/
|
|
5126
|
+
export function scriptLanguages(scripts: ScriptSpec[]): RuntimeSpec | null {
|
|
5127
|
+
const kind = (sc: ScriptSpec) => {
|
|
5128
|
+
const via = (sc.interpreter ?? "").split(/[\\/]/).pop() ?? "";
|
|
5129
|
+
if (/^node/.test(via)) return "node";
|
|
5130
|
+
if (/^python/.test(via)) return "python";
|
|
5131
|
+
const ext = (sc.run ? path.extname(sc.run) : sc.codeExt ?? "").toLowerCase();
|
|
5132
|
+
if ([".js", ".mjs", ".cjs", ".ts"].includes(ext)) return "node";
|
|
5133
|
+
if (ext === ".py") return "python";
|
|
5134
|
+
return null;
|
|
5135
|
+
};
|
|
5136
|
+
const kinds = new Set(scripts.map(kind));
|
|
5137
|
+
if (!kinds.has("node")) return null;
|
|
5138
|
+
return { node: true, ...(kinds.has("python") ? { python: true } : {}), packages: [], npm: [] };
|
|
5139
|
+
}
|