@foldrun/core 0.4.0 → 0.5.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/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +6 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +403 -42
- package/dist/src/runner.d.ts +142 -9
- package/dist/src/runner.js +1027 -122
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +3 -2
- package/dist/src/script-tools.js +35 -3
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +61 -2
- package/dist/src/step-exec.js +414 -36
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +218 -37
- package/dist/src/store.js +347 -60
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +3 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +313 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +11 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +439 -43
- package/src/runner.ts +1050 -122
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +38 -5
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +429 -37
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +500 -68
- package/src/subagents.ts +189 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/dist/src/runner.js
CHANGED
|
@@ -6,11 +6,15 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import matter from "gray-matter";
|
|
8
8
|
import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
|
|
9
|
+
import { cascadeLimits, toolOwners } from "./limits.js";
|
|
10
|
+
import { resolveAgentPath } from "./confine.js";
|
|
11
|
+
import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js";
|
|
9
12
|
import { eventUrl } from "./webhook.js";
|
|
10
13
|
import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
|
|
11
14
|
import { hostSafeEnv } from "./host-env.js";
|
|
12
15
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
|
|
13
16
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
|
|
17
|
+
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
|
|
14
18
|
import { platform } from "./platform.js";
|
|
15
19
|
import { healthKey } from "./secret-health.js";
|
|
16
20
|
import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
|
|
@@ -21,9 +25,12 @@ const isolatedRun = () => {
|
|
|
21
25
|
return mode === "container" || (!!mode && mode in platform.isolation);
|
|
22
26
|
};
|
|
23
27
|
import { gatherConsults, buildConsultTools } from "./agent-tools.js";
|
|
24
|
-
import {
|
|
28
|
+
import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools } from "./subagents.js";
|
|
29
|
+
import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.js";
|
|
30
|
+
import { sendPlainNotification } from "./notify.js";
|
|
31
|
+
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
|
|
25
32
|
import { refNames } from "./refs.js";
|
|
26
|
-
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
33
|
+
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
27
34
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
|
|
28
35
|
import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
|
|
29
36
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
@@ -34,11 +41,17 @@ import { startTranslator, translatorSpecFor } from "./translator.js";
|
|
|
34
41
|
import { providerPreset } from "./providers.js";
|
|
35
42
|
import { buildScriptTools, parseScripts } from "./script-tools.js";
|
|
36
43
|
import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.js";
|
|
37
|
-
import { mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
|
|
44
|
+
import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
|
|
38
45
|
import { materializeFiles, harvestFiles } from "./storage.js";
|
|
39
46
|
import { chooseExecutor, ensureImage } from "./container.js";
|
|
40
47
|
import { stampBundle } from "./okf.js";
|
|
48
|
+
import { readBrowseSettings, readSearchSettings, resolveSearch, searchSettingsEnv, webConfig, browseSessionProblems } from "./providers.js";
|
|
49
|
+
import { NEWER_ACTIONS, resolveActionApi } from "./web-actions.js";
|
|
50
|
+
import { resolveLanguage, languageName } from "./language.js";
|
|
51
|
+
import { resolveRegion, deriveLocale, localeProse, regionName } from "./locale.js";
|
|
41
52
|
import { trimChars } from "./paths.js";
|
|
53
|
+
import { resolveClock, localDate } from "./clock.js";
|
|
54
|
+
export { localDate } from "./clock.js";
|
|
42
55
|
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
43
56
|
// in env and headers resolve server-side, so a credential reaches the server
|
|
44
57
|
// process without ever entering the model's context.
|
|
@@ -213,6 +226,30 @@ export function discoverSkills(agentDir, subdir = "skills") {
|
|
|
213
226
|
* Returns null rather than throwing on unparseable YAML: a broken shared file
|
|
214
227
|
* must not take down every agent under it.
|
|
215
228
|
*/
|
|
229
|
+
/**
|
|
230
|
+
* AGENTS.md without the block a coding tool reads.
|
|
231
|
+
*
|
|
232
|
+
* AGENTS.md is two things in a foldrun folder: the account's (or
|
|
233
|
+
* workspace's) own context, which every running agent is given, and — the
|
|
234
|
+
* convention Next.js set — the file a coding agent (Claude Code, Cursor,
|
|
235
|
+
* Codex) reads before it edits the folder. The CLI writes a short managed
|
|
236
|
+
* block for the second reader between these markers; it is about editing
|
|
237
|
+
* the files, not about doing the work, so no running agent is shown it.
|
|
238
|
+
*/
|
|
239
|
+
export const AGENT_RULES_START = "<!-- BEGIN:foldrun-agent-rules -->";
|
|
240
|
+
export const AGENT_RULES_END = "<!-- END:foldrun-agent-rules -->";
|
|
241
|
+
export function withoutAgentRules(body) {
|
|
242
|
+
let out = body;
|
|
243
|
+
for (;;) {
|
|
244
|
+
const start = out.indexOf(AGENT_RULES_START);
|
|
245
|
+
if (start === -1)
|
|
246
|
+
return out;
|
|
247
|
+
const end = out.indexOf(AGENT_RULES_END, start);
|
|
248
|
+
if (end === -1)
|
|
249
|
+
return out;
|
|
250
|
+
out = out.slice(0, start) + out.slice(end + AGENT_RULES_END.length);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
216
253
|
export function readAgentsMd(dir) {
|
|
217
254
|
// Convert a pre-rename `mdagent_version:` before parsing. One name in the
|
|
218
255
|
// code, one on disk — the reader no longer knows two spellings, so a file
|
|
@@ -224,7 +261,7 @@ export function readAgentsMd(dir) {
|
|
|
224
261
|
continue;
|
|
225
262
|
try {
|
|
226
263
|
const parsed = matter(fs.readFileSync(file, "utf8"));
|
|
227
|
-
return { data: parsed.data, body: parsed.content.trim() };
|
|
264
|
+
return { data: parsed.data, body: withoutAgentRules(parsed.content).trim() };
|
|
228
265
|
}
|
|
229
266
|
catch {
|
|
230
267
|
return null;
|
|
@@ -242,6 +279,53 @@ const workspaceRootOf = (agentDir) => path.join(agentDir, "..", "..");
|
|
|
242
279
|
* writing instructions means *as well as*, because an account rule nobody can
|
|
243
280
|
* silently drop is the point of having one.
|
|
244
281
|
*/
|
|
282
|
+
/** An agent's own `runtime:` block, else its workspace's (AGENTS.md, the
|
|
283
|
+
* Linux Foundation standard name; project.md stays accepted so nothing
|
|
284
|
+
* that already exists breaks). */
|
|
285
|
+
function ownRuntime(front, agentDir, tenant) {
|
|
286
|
+
return parseRuntime(front.runtime) ?? parseRuntime(workspaceFrontmatter(agentDir, tenant).runtime);
|
|
287
|
+
}
|
|
288
|
+
/** What one granted script tool's program needs. */
|
|
289
|
+
function toolRuntime(def) {
|
|
290
|
+
return def.kind === "script" ? parseRuntime(def.spec.runtime) : null;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The runtime one agent's steps are built with: its own declaration (else its
|
|
294
|
+
* workspace's), merged with every script tool it grants. The runner builds
|
|
295
|
+
* exactly this for a step; the platform builds it ahead of time when the
|
|
296
|
+
* workspace is deployed, so a package that will not install is reported at
|
|
297
|
+
* deploy and a scheduled step finds its environment ready. One function, so
|
|
298
|
+
* the two can never disagree about what an agent needs.
|
|
299
|
+
*/
|
|
300
|
+
export function agentRuntimeSpec(tenant, workspace, agentDir) {
|
|
301
|
+
const file = path.join(agentDir, "agent.md");
|
|
302
|
+
if (!fs.existsSync(file))
|
|
303
|
+
return null;
|
|
304
|
+
const front = safeMatter(fs.readFileSync(file, "utf8")).data;
|
|
305
|
+
const available = { ...libraryTools(tenant), ...workspaceTools(tenant, workspace) };
|
|
306
|
+
const tools = ownToolNames(front)
|
|
307
|
+
.map((name) => available[name])
|
|
308
|
+
.filter((def) => Boolean(def))
|
|
309
|
+
.map(toolRuntime);
|
|
310
|
+
return mergeRuntimes(ownRuntime(front, agentDir, tenant), ...tools);
|
|
311
|
+
}
|
|
312
|
+
/** Every distinct runtime a workspace's agents need, with who needs it. */
|
|
313
|
+
export function workspaceRuntimePlan(tenant, workspace) {
|
|
314
|
+
const byFp = new Map();
|
|
315
|
+
const agentsRoot = path.join(workspaceDir(tenant, workspace), "agents");
|
|
316
|
+
if (!fs.existsSync(agentsRoot))
|
|
317
|
+
return [];
|
|
318
|
+
for (const name of fs.readdirSync(agentsRoot).sort()) {
|
|
319
|
+
const spec = agentRuntimeSpec(tenant, workspace, path.join(agentsRoot, name));
|
|
320
|
+
if (!spec)
|
|
321
|
+
continue;
|
|
322
|
+
const fp = fingerprint(spec);
|
|
323
|
+
const entry = byFp.get(fp) ?? { fingerprint: fp, spec, agents: [] };
|
|
324
|
+
entry.agents.push(name);
|
|
325
|
+
byFp.set(fp, entry);
|
|
326
|
+
}
|
|
327
|
+
return [...byFp.values()];
|
|
328
|
+
}
|
|
245
329
|
function workspaceFrontmatter(agentDir, tenant) {
|
|
246
330
|
return {
|
|
247
331
|
...(readAgentsMd(accountDir(tenant))?.data ?? {}),
|
|
@@ -249,31 +333,60 @@ function workspaceFrontmatter(agentDir, tenant) {
|
|
|
249
333
|
};
|
|
250
334
|
}
|
|
251
335
|
/**
|
|
252
|
-
* The calendar
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* An unknown name falls back to UTC rather than failing every step.
|
|
336
|
+
* The calendar a set of frontmatter asks for, with no cascade around it —
|
|
337
|
+
* one merged record in, one zone out. The full cascade lives in
|
|
338
|
+
* `agentClock` below and in clock.ts; this is what the account-level parts
|
|
339
|
+
* of the platform (a wallet's month, a budget window) call.
|
|
257
340
|
*/
|
|
258
341
|
export function resolveTimezone(front) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
342
|
+
return resolveClock([{ level: "workspace", value: front.timezone }]).timezone;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The clock this step works to: agent, then flow, then workspace, then
|
|
346
|
+
* account, then FOLDRUN_TIMEZONE, then UTC. Nearest wins, and a value that
|
|
347
|
+
* is not a zone falls through to the next level with a line to say so —
|
|
348
|
+
* see clock.ts for why this exists at all.
|
|
349
|
+
*/
|
|
350
|
+
export function agentClock(agentDir, tenant, agentFront, flowTimezone) {
|
|
351
|
+
return resolveClock([
|
|
352
|
+
{ level: "agent", value: agentFront.timezone },
|
|
353
|
+
{ level: "flow", value: flowTimezone },
|
|
354
|
+
{ level: "workspace", value: readAgentsMd(workspaceRootOf(agentDir))?.data?.timezone },
|
|
355
|
+
{ level: "account", value: readAgentsMd(accountDir(tenant))?.data?.timezone },
|
|
356
|
+
]);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The language this step works in: agent, then workspace, then account, then
|
|
360
|
+
* FOLDRUN_LANGUAGE, then English. The flow level is not read yet — a flow's
|
|
361
|
+
* timezone: travels through three signatures to get here, and this will
|
|
362
|
+
* take the same road when a flow needs it.
|
|
363
|
+
*/
|
|
364
|
+
export function agentLanguage(agentDir, tenant, agentFront) {
|
|
365
|
+
return resolveLanguage([
|
|
366
|
+
{ level: "agent", value: agentFront.language },
|
|
367
|
+
{ level: "workspace", value: readAgentsMd(workspaceRootOf(agentDir))?.data?.language },
|
|
368
|
+
{ level: "account", value: readAgentsMd(accountDir(tenant))?.data?.language },
|
|
369
|
+
]);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The country this step works for, on the same road as the language, then
|
|
373
|
+
* FOLDRUN_REGION, then whatever the language tag carried (`en-AU` says AU).
|
|
374
|
+
* Everything a country changes — currency, units, calendar, weekend, text
|
|
375
|
+
* direction — is derived from it in locale.ts; the three overrides
|
|
376
|
+
* (`currency:`, `calendar:`, `units:`) are read at the nearest level that
|
|
377
|
+
* wrote them.
|
|
378
|
+
*/
|
|
379
|
+
export function agentRegion(agentDir, tenant, agentFront, languageTag) {
|
|
380
|
+
return resolveRegion([
|
|
381
|
+
{ level: "agent", value: agentFront.region },
|
|
382
|
+
{ level: "workspace", value: readAgentsMd(workspaceRootOf(agentDir))?.data?.region },
|
|
383
|
+
{ level: "account", value: readAgentsMd(accountDir(tenant))?.data?.region },
|
|
384
|
+
], languageTag);
|
|
268
385
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
year: "numeric",
|
|
274
|
-
month: "2-digit",
|
|
275
|
-
day: "2-digit",
|
|
276
|
-
}).format(now);
|
|
386
|
+
export function agentLocaleOverrides(agentDir, tenant, agentFront) {
|
|
387
|
+
const levels = [agentFront, readAgentsMd(workspaceRootOf(agentDir))?.data ?? {}, readAgentsMd(accountDir(tenant))?.data ?? {}];
|
|
388
|
+
const pick = (k) => levels.map((l) => l[k]).find((v) => v !== undefined && v !== null && v !== "");
|
|
389
|
+
return { currency: pick("currency"), calendar: pick("calendar"), units: pick("units") };
|
|
277
390
|
}
|
|
278
391
|
/**
|
|
279
392
|
* The prose from AGENTS.md, outermost first.
|
|
@@ -282,6 +395,56 @@ export function localDate(timeZone, now = new Date()) {
|
|
|
282
395
|
* template as "context every agent here shares" — and never read. Only the
|
|
283
396
|
* frontmatter was, so the instructions people actually wrote reached no model.
|
|
284
397
|
*/
|
|
398
|
+
/**
|
|
399
|
+
* The sub-agents a step may delegate to (`subagents:`), as the SDK will get
|
|
400
|
+
* them: each resolved through agentContext exactly as its own step would be
|
|
401
|
+
* (grants only — no runtime; it runs inside its parent's), then cut to what
|
|
402
|
+
* the parent holds. Problems are lines on the step, never a failed step.
|
|
403
|
+
*/
|
|
404
|
+
export function buildSubagentSpecs(o) {
|
|
405
|
+
const out = [];
|
|
406
|
+
const { found, missing } = gatherSubagents(o.workspaceRoot, o.names);
|
|
407
|
+
for (const m of missing)
|
|
408
|
+
o.push("error", `subagents: "${m}" is not an agent in this workspace — not delegated to`);
|
|
409
|
+
for (const sub of found) {
|
|
410
|
+
if (sub.name === o.self) {
|
|
411
|
+
o.push("error", `subagents: "${sub.name}" is this agent itself — an agent does not delegate to itself`);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const tags = o.tags ?? [];
|
|
416
|
+
const own = agentContext(sub.dir, o.tenant, tags, { runId: o.runId, agent: sub.name }, o.flowTimezone, true);
|
|
417
|
+
const description = typeof own.front.description === "string" ? own.front.description.trim() : "";
|
|
418
|
+
if (!description) {
|
|
419
|
+
o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const tools = subagentTools(own.allowed, o.parentAllowed);
|
|
423
|
+
if (!tools.length) {
|
|
424
|
+
o.push("error", `subagents: "${sub.name}" holds none of its tools inside this step — it can only answer from its prompt (use agents: for that)`);
|
|
425
|
+
}
|
|
426
|
+
const body = withoutAgentRules(matter(fs.readFileSync(path.join(sub.dir, "agent.md"), "utf8")).content);
|
|
427
|
+
out.push({
|
|
428
|
+
name: sub.name,
|
|
429
|
+
description,
|
|
430
|
+
prompt: subagentPrompt({
|
|
431
|
+
body: resolveDocLinks(body, o.workspaceRoot),
|
|
432
|
+
name: sub.name,
|
|
433
|
+
parent: o.self,
|
|
434
|
+
workspace: path.basename(o.workspaceRoot),
|
|
435
|
+
shared: sharedInstructions(sub.dir, o.tenant),
|
|
436
|
+
}),
|
|
437
|
+
tools,
|
|
438
|
+
disallowedTools: refNames(own.front.disallowedTools),
|
|
439
|
+
model: own.front.model ? resolveModel(own.front.model) : "inherit",
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
o.push("error", `subagents: "${sub.name}" could not be read — ${err instanceof Error ? err.message : String(err)}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
285
448
|
export function sharedInstructions(agentDir, tenant) {
|
|
286
449
|
const sections = [
|
|
287
450
|
["Everyone in this account", readAgentsMd(accountDir(tenant))?.body],
|
|
@@ -359,8 +522,10 @@ export function resolveDocLinks(text, workspaceRoot) {
|
|
|
359
522
|
// instruction names needs to say WHERE without naming a file. Added
|
|
360
523
|
// first, so a document that happens to share a folder's name still wins.
|
|
361
524
|
for (const dir of [STORAGE_DIR, "state", "knowledge", "memory", "skills", "outputs"]) {
|
|
525
|
+
// storage/ and state/ by the one spelling that works in every tool,
|
|
526
|
+
// the shell included; the rest as they always were.
|
|
362
527
|
if (fs.existsSync(path.join(workspaceRoot, dir)))
|
|
363
|
-
map.set(norm(dir), `../../${dir}/`);
|
|
528
|
+
map.set(norm(dir), dir === STORAGE_DIR || dir === "state" ? `workspace/${dir}/` : `../../${dir}/`);
|
|
364
529
|
}
|
|
365
530
|
for (const kind of ["knowledge", "memory"]) {
|
|
366
531
|
const dir = path.join(workspaceRoot, kind);
|
|
@@ -405,7 +570,7 @@ export function resolveDocLinks(text, workspaceRoot) {
|
|
|
405
570
|
continue;
|
|
406
571
|
}
|
|
407
572
|
const fwd = entry.split(path.sep).join("/");
|
|
408
|
-
const rel =
|
|
573
|
+
const rel = `workspace/state/${fwd}`;
|
|
409
574
|
map.set(norm(`state/${fwd}`), rel);
|
|
410
575
|
map.set(norm(fwd), rel);
|
|
411
576
|
}
|
|
@@ -417,7 +582,7 @@ export function resolveDocLinks(text, workspaceRoot) {
|
|
|
417
582
|
// through to the plain-directory walk below.
|
|
418
583
|
const index = JSON.parse(fs.readFileSync(path.join(workspaceRoot, "..", "..", STORAGE_DIR, path.basename(workspaceRoot), "index.json"), "utf8"));
|
|
419
584
|
for (const f of index.files ?? [])
|
|
420
|
-
map.set(norm(`${STORAGE_DIR}/${f.path}`),
|
|
585
|
+
map.set(norm(`${STORAGE_DIR}/${f.path}`), `workspace/${STORAGE_DIR}/${f.path}`);
|
|
421
586
|
}
|
|
422
587
|
catch {
|
|
423
588
|
// no file store index is normal
|
|
@@ -436,8 +601,8 @@ export function resolveDocLinks(text, workspaceRoot) {
|
|
|
436
601
|
const fwd = entry.split(path.sep).join("/");
|
|
437
602
|
if (fwd.startsWith(".store/"))
|
|
438
603
|
continue;
|
|
439
|
-
map.set(norm(`${STORAGE_DIR}/${fwd}`),
|
|
440
|
-
map.set(norm(fwd),
|
|
604
|
+
map.set(norm(`${STORAGE_DIR}/${fwd}`), `workspace/${STORAGE_DIR}/${fwd}`);
|
|
605
|
+
map.set(norm(fwd), `workspace/${STORAGE_DIR}/${fwd}`);
|
|
441
606
|
}
|
|
442
607
|
}
|
|
443
608
|
// `[` excluded from the name: with it allowed, a run of `[[` makes the
|
|
@@ -450,7 +615,13 @@ export function resolveDocLinks(text, workspaceRoot) {
|
|
|
450
615
|
}
|
|
451
616
|
function agentContext(agentDir, tenant, tags = [],
|
|
452
617
|
/** Who is running, so scripts can stamp what they write with its provenance. */
|
|
453
|
-
identity = {}
|
|
618
|
+
identity = {},
|
|
619
|
+
/** The flow's own `timezone:`, when this step belongs to one. Between the
|
|
620
|
+
* agent's frontmatter and the workspace's AGENTS.md in the cascade. */
|
|
621
|
+
flowTimezone,
|
|
622
|
+
/** Only what the agent is granted — no runtime prepared. For a sub-agent,
|
|
623
|
+
* whose tools are cut to its parent's and run in the parent's runtime. */
|
|
624
|
+
grantsOnly = false) {
|
|
454
625
|
// agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
|
|
455
626
|
// workspace a secret should resolve against is two levels up.
|
|
456
627
|
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
@@ -461,12 +632,34 @@ identity = {}) {
|
|
|
461
632
|
// than implied: told to read `../../knowledge/index.md`, the model asked for
|
|
462
633
|
// `/knowledge/index.md` twice before working it out. Two denials and a turn,
|
|
463
634
|
// for want of one sentence.
|
|
635
|
+
// The language this step works in, resolved before the prompt is built:
|
|
636
|
+
// its sentence goes in here, the env the tools read is set below, and the
|
|
637
|
+
// step prints any level that wrote something unreadable.
|
|
638
|
+
const language = agentLanguage(agentDir, tenant, front);
|
|
639
|
+
const region = agentRegion(agentDir, tenant, front, language.language);
|
|
640
|
+
// The clock is resolved further down for the env; the locale needs its
|
|
641
|
+
// zone now so "today" in a Jalali or Hijri calendar is today where the
|
|
642
|
+
// step works, not in UTC.
|
|
643
|
+
const localeZone = agentClock(agentDir, tenant, front, flowTimezone).timezone;
|
|
644
|
+
const locale = deriveLocale(language.language, region.region, agentLocaleOverrides(agentDir, tenant, front), localeZone);
|
|
645
|
+
// Said only when someone set it: an agent that never mentions a language
|
|
646
|
+
// or a region reads exactly the prompt it always did.
|
|
647
|
+
const languageSet = language.from !== "default" && language.from !== "env";
|
|
648
|
+
const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
|
|
649
|
+
if (languageSet || regionSet) {
|
|
650
|
+
parts.push(`# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
|
|
651
|
+
`Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
|
|
652
|
+
`use these currency, units and calendar conventions in anything you produce. ` +
|
|
653
|
+
`Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`);
|
|
654
|
+
}
|
|
464
655
|
parts.push(`# Where you are\n\n` +
|
|
465
656
|
`Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
|
|
466
657
|
`\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
|
|
467
|
-
`\`outputs/\` is yours, and
|
|
468
|
-
`
|
|
469
|
-
`workspace
|
|
658
|
+
`\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
|
|
659
|
+
`and in the shell: the shared file store is \`workspace/storage/\`, what runs ` +
|
|
660
|
+
`carry forward is \`workspace/state/\`. (\`../../\` reaches the same root and ` +
|
|
661
|
+
`still works, so a path like \`../../knowledge/\` below is \`workspace/knowledge/\`.) ` +
|
|
662
|
+
`Absolute paths are outside the workspace and will be refused.\n\n` +
|
|
470
663
|
// A flow's whole point is that a later step works on what an earlier one
|
|
471
664
|
// produced, and each agent writes to its own outputs/. A run showed what
|
|
472
665
|
// omitting this costs: the checker looked in its own empty outputs/ and
|
|
@@ -487,10 +680,18 @@ identity = {}) {
|
|
|
487
680
|
// adoptLegacyFilesDir's "move files/ to storage/" is a no-op by the time
|
|
488
681
|
// the run ends — a deliverable written where this text pointed was
|
|
489
682
|
// harvested from nowhere and silently never appeared.
|
|
490
|
-
|
|
683
|
+
`\`workspace/storage/\` is the workspace file store: anything you leave there is kept ` +
|
|
491
684
|
`after the run and shown on the Storage page for people to download. Write ` +
|
|
492
|
-
`deliverables people asked for — CSVs, reports, PDFs, images — to
|
|
493
|
-
`use \`outputs/\` for working text the next step reads
|
|
685
|
+
`deliverables people asked for — CSVs, reports, PDFs, images — to \`workspace/storage/\`; ` +
|
|
686
|
+
`use \`outputs/\` for working text the next step reads. ` +
|
|
687
|
+
// A Glob PATTERN that climbs (`../../storage/draft/*.md`) always answered
|
|
688
|
+
// "No files found", even when the file was there. On blog-desk that made
|
|
689
|
+
// every loop pass look like a first pass: the illustrator never saw the
|
|
690
|
+
// fact-check report, re-picked the cover, and 2 of 3 publish runs failed
|
|
691
|
+
// (run-munef2am-439a, 2026-09-30). checkPaths now splits such a pattern
|
|
692
|
+
// into Glob's `path` and the rest; the advice stays, as the plain form.
|
|
693
|
+
`To look for files there, open a known file with Read by its exact path, or ` +
|
|
694
|
+
`Glob with \`path: "workspace/storage/..."\` and a pattern like \`*.md\`.`);
|
|
494
695
|
// Shared context before anything derived — an account or workspace rule is
|
|
495
696
|
// background the rest of the prompt is read against, not an afterthought.
|
|
496
697
|
const sharedContext = sharedInstructions(agentDir, tenant);
|
|
@@ -628,7 +829,7 @@ identity = {}) {
|
|
|
628
829
|
// for it. The difference is that state is small enough that inlining would
|
|
629
830
|
// usually be free — that is a threshold worth adding once there is a real
|
|
630
831
|
// file to size it against, not before.
|
|
631
|
-
const state = listDir(path.join(agentDir, "..", "..", "state"), "
|
|
832
|
+
const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
|
|
632
833
|
if (state.length) {
|
|
633
834
|
parts.push(`# State — what you carry between runs\n\n` +
|
|
634
835
|
`What this workspace keeps across runs: a count, a cursor, where you got to. ` +
|
|
@@ -662,17 +863,32 @@ identity = {}) {
|
|
|
662
863
|
// below. The tool is the unit of code, so its dependencies live in tool.md
|
|
663
864
|
// and travel with it; the agent should not have to repeat them.
|
|
664
865
|
const toolRuntimes = [];
|
|
866
|
+
// The secrets a granted script tool's file lists (`secrets: [RESEND_API_KEY,
|
|
867
|
+
// EMAIL_FROM]`): granting the tool grants them, the way an API tool's
|
|
868
|
+
// referenced credentials always travelled with it. Before, only the
|
|
869
|
+
// agent's own `secrets:` counted, so an agent that granted desk_email and
|
|
870
|
+
// declared nothing failed with "RESEND_API_KEY is not set" (strata-desk,
|
|
871
|
+
// 1 Oct). `proxied` / `materialised` say how, not what, and add nothing.
|
|
872
|
+
const toolSecrets = [];
|
|
873
|
+
// Does any granted tool act outside the workspace (`outward: true`)? The
|
|
874
|
+
// retry policy reads it: see launchStep.
|
|
875
|
+
let outward = false;
|
|
665
876
|
const grantOwnTool = (name) => {
|
|
666
877
|
const def = available[name];
|
|
667
878
|
if (!def) {
|
|
668
879
|
missingTools.push(name);
|
|
669
880
|
return;
|
|
670
881
|
}
|
|
882
|
+
if (def.outward)
|
|
883
|
+
outward = true;
|
|
671
884
|
if (def.kind === "http")
|
|
672
885
|
apis.push(def.spec);
|
|
673
886
|
else if (def.kind === "script") {
|
|
674
887
|
scriptSpecs.push(...parseScripts([def.spec]));
|
|
675
|
-
const
|
|
888
|
+
const listed = def.spec.secrets;
|
|
889
|
+
if (Array.isArray(listed))
|
|
890
|
+
toolSecrets.push(...listed.map(String).filter((n) => /^[A-Z][A-Z0-9_]*$/.test(n)));
|
|
891
|
+
const rt = toolRuntime(def);
|
|
676
892
|
if (rt)
|
|
677
893
|
toolRuntimes.push(rt);
|
|
678
894
|
}
|
|
@@ -716,7 +932,69 @@ identity = {}) {
|
|
|
716
932
|
//
|
|
717
933
|
// Declared-and-absent is still an error; referenced-and-absent is the API
|
|
718
934
|
// tool's own missingSecrets, reported where the tool is described.
|
|
719
|
-
|
|
935
|
+
// `web: {search: …, fetch: …}` name who answers; resolved here, early,
|
|
936
|
+
// because three later things hang off the answer — the secret the step
|
|
937
|
+
// must hold, the env the tool reads, and the egress grant for the host.
|
|
938
|
+
// `web.search` carries the account engine's settings (engines, categories,
|
|
939
|
+
// safesearch, …) beside the provider name, read off first the way
|
|
940
|
+
// browse's are, so the provider resolver sees only its own part.
|
|
941
|
+
const web = webConfig(front, workspaceFrontmatter(agentDir, tenant));
|
|
942
|
+
const searchRead = readSearchSettings(web.raw.search);
|
|
943
|
+
const searchChoice = resolveSearch(searchRead.rest, "search");
|
|
944
|
+
if (searchRead.error)
|
|
945
|
+
searchChoice.error = searchChoice.error ?? searchRead.error;
|
|
946
|
+
const fetchChoice = resolveSearch(web.raw.fetch, "fetch");
|
|
947
|
+
// A remote browser's key is materialised, not proxied: CDP is a websocket
|
|
948
|
+
// and the wrapper opens the session itself, the way it seeds a cookie.
|
|
949
|
+
// `web.browse` carries two things in one key: how the browser presents
|
|
950
|
+
// itself (engine, user agent, device, locale, timezone) and, optionally,
|
|
951
|
+
// which remote vendor renders the page. Settings are read off first so the
|
|
952
|
+
// vendor resolver sees only the vendor part, and a block of settings alone
|
|
953
|
+
// means the account's own browser with those defaults. Nearest wins: the
|
|
954
|
+
// agent's block, else the workspace's, else the account's — the shape
|
|
955
|
+
// `provider:` already uses.
|
|
956
|
+
const browseRead = readBrowseSettings(web.raw.browse);
|
|
957
|
+
const browseSettings = browseRead.settings;
|
|
958
|
+
const browseChoice = resolveSearch(browseRead.rest, "browse");
|
|
959
|
+
if (browseRead.error)
|
|
960
|
+
browseChoice.error = browseChoice.error ?? browseRead.error;
|
|
961
|
+
// What the vendor's own session is asked for, checked against what that
|
|
962
|
+
// vendor offers; a problem is on the record and the session is not sent.
|
|
963
|
+
const sessionProblems = browseChoice.error ? [] : browseSessionProblems(browseSettings, browseChoice.provider);
|
|
964
|
+
const browseSession = browseChoice.shape === "direct" && browseChoice.provider && !sessionProblems.length ? browseSettings.vendor_session : undefined;
|
|
965
|
+
// crawl, map, extract, answer, monitor: a provider each, or foldrun's own.
|
|
966
|
+
// Same three consequences as a search API — the secret the step holds, the
|
|
967
|
+
// env the web tool reads, the one host the key may reach.
|
|
968
|
+
const actionChoices = NEWER_ACTIONS.map((action) => ({ action, ...resolveActionApi(action, web.raw[action]) }));
|
|
969
|
+
const builtins = [
|
|
970
|
+
...(searchChoice.provider && searchChoice.shape !== "direct" && !searchChoice.error ? ["search=WebSearch"] : []),
|
|
971
|
+
...(fetchChoice.provider && fetchChoice.shape !== "direct" && !fetchChoice.error ? ["fetch=WebFetch"] : []),
|
|
972
|
+
];
|
|
973
|
+
const webBuiltin = builtins.length ? { FOLDRUN_WEB_BUILTIN: builtins.join(",") } : {};
|
|
974
|
+
// A direct search API is paid for with the customer's own key, stored
|
|
975
|
+
// under a fixed vault name (EXA_API_KEY, BRAVE_SEARCH_API_KEY, …). Naming
|
|
976
|
+
// the API in frontmatter is declaring that secret — nobody should have to
|
|
977
|
+
// write it twice, and a run that forgot the second line would search
|
|
978
|
+
// nothing and say so only at the provider.
|
|
979
|
+
const declared = [
|
|
980
|
+
...(Array.isArray(front.secrets) ? front.secrets.map(String) : []),
|
|
981
|
+
...toolSecrets,
|
|
982
|
+
...actionChoices.flatMap((c) => (c.provider ? [c.secret] : [])),
|
|
983
|
+
// Your own proxy for a vendor's session: its URL is a secret, declared by
|
|
984
|
+
// naming it, like a provider's key.
|
|
985
|
+
...(browseSession && typeof browseSession.proxy === "object" && browseSession.proxy.own ? [browseSession.proxy.own] : []),
|
|
986
|
+
...[searchChoice, fetchChoice, browseChoice].flatMap((c) => {
|
|
987
|
+
if (c.shape !== "direct" || !c.secret)
|
|
988
|
+
return [];
|
|
989
|
+
// Jina's reader answers without a key. Declaring an absent optional
|
|
990
|
+
// key would fail the step for a credential it never needed.
|
|
991
|
+
if (c.secretOptional && !getSecret(tenant, c.secret, workspace))
|
|
992
|
+
return [];
|
|
993
|
+
return [c.secret];
|
|
994
|
+
}),
|
|
995
|
+
];
|
|
996
|
+
// One name once, however many places asked for it.
|
|
997
|
+
declared.splice(0, declared.length, ...new Set(declared));
|
|
720
998
|
const referenced = [...new Set(apis.flatMap(secretsUsedByApi))];
|
|
721
999
|
const { env: secretEnv, from: secretScopes, } = resolveSecrets(tenant, [...new Set([...declared, ...referenced])], workspace);
|
|
722
1000
|
const { missing: missingDeclared } = resolveSecrets(tenant, declared, workspace);
|
|
@@ -822,15 +1100,8 @@ identity = {}) {
|
|
|
822
1100
|
// Runtime: an agent's own `runtime:` block, else its workspace's. Built once
|
|
823
1101
|
// per declaration and cached, so scripts get their dependencies without
|
|
824
1102
|
// polluting the host.
|
|
825
|
-
let runtimeSpec = parseRuntime(front.runtime);
|
|
826
|
-
if (!runtimeSpec) {
|
|
827
|
-
// AGENTS.md is the Linux Foundation standard name (60k+ repos, same
|
|
828
|
-
// nearest-wins cascade we already use); project.md stays accepted so
|
|
829
|
-
// nothing that already exists breaks.
|
|
830
|
-
runtimeSpec = parseRuntime(workspaceFrontmatter(agentDir, tenant).runtime);
|
|
831
|
-
}
|
|
832
1103
|
// Plus whatever the tools this agent granted declared for themselves.
|
|
833
|
-
runtimeSpec = mergeRuntimes(
|
|
1104
|
+
let runtimeSpec = mergeRuntimes(ownRuntime(front, agentDir, tenant), ...toolRuntimes);
|
|
834
1105
|
// Execution: a container per run when Docker is available (real isolation),
|
|
835
1106
|
// otherwise the host venv path so local development still works.
|
|
836
1107
|
const executor = chooseExecutor();
|
|
@@ -838,7 +1109,7 @@ identity = {}) {
|
|
|
838
1109
|
let runtimeError = null;
|
|
839
1110
|
let exec = null;
|
|
840
1111
|
let runtime = { interpreters: {}, env: {}, log: [], error: null };
|
|
841
|
-
if (executor === "docker") {
|
|
1112
|
+
if (executor === "docker" && !grantsOnly) {
|
|
842
1113
|
const image = ensureImage(runtimeSpec);
|
|
843
1114
|
runtimeLog.push(...image.log);
|
|
844
1115
|
runtimeError = image.error;
|
|
@@ -856,22 +1127,103 @@ identity = {}) {
|
|
|
856
1127
|
};
|
|
857
1128
|
}
|
|
858
1129
|
}
|
|
1130
|
+
else if (isolatedRun() || grantsOnly) {
|
|
1131
|
+
// (A sub-agent's grants are all that is asked of it: it runs inside its
|
|
1132
|
+
// parent's step and runtime.)
|
|
1133
|
+
// The sandbox builds it (run-container.ts, beside pip's network and the
|
|
1134
|
+
// mounted cache); building it here as well was pure waste, and on a
|
|
1135
|
+
// worker image without python it was worse than waste — it logged
|
|
1136
|
+
// "python3 is not available on this host" as an error on every Python
|
|
1137
|
+
// step, above a sandbox build that had succeeded, and sent every reader
|
|
1138
|
+
// of the trace after the wrong machine.
|
|
1139
|
+
}
|
|
859
1140
|
else {
|
|
860
1141
|
runtime = prepareRuntime(tenant, runtimeSpec);
|
|
861
1142
|
runtimeLog.push(...runtime.log);
|
|
862
1143
|
runtimeError = runtime.error;
|
|
863
1144
|
}
|
|
864
|
-
// What day it is, in the
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
|
|
1145
|
+
// What day it is, in the calendar this step works to — the agent's own
|
|
1146
|
+
// `timezone:`, else its flow's, else the workspace's, else the account's.
|
|
1147
|
+
// Not a secret, so it travels beside secretEnv rather than inside it — a
|
|
1148
|
+
// value in there is redacted from every log line that quotes it.
|
|
1149
|
+
const clock = agentClock(agentDir, tenant, front, flowTimezone);
|
|
1150
|
+
const timezone = clock.timezone;
|
|
868
1151
|
const clockEnv = {
|
|
869
|
-
|
|
1152
|
+
// The language the agent works in — what the search asks in, the locale
|
|
1153
|
+
// the browser reports, the Accept-Language a fetch sends. Not a secret.
|
|
1154
|
+
FOLDRUN_LANGUAGE: language.language,
|
|
1155
|
+
// The country and what follows from it — web search's gl, a script's
|
|
1156
|
+
// currency. Unset where nobody said, so nothing is invented.
|
|
1157
|
+
...(region.region ? { FOLDRUN_REGION: region.region } : {}),
|
|
1158
|
+
...(locale.currency ? { FOLDRUN_CURRENCY: locale.currency } : {}),
|
|
1159
|
+
FOLDRUN_UNITS: locale.units,
|
|
1160
|
+
FOLDRUN_CALENDAR: locale.calendar,
|
|
1161
|
+
...(locale.dateLocal ? { FOLDRUN_DATE_LOCAL: locale.dateLocal } : {}),
|
|
1162
|
+
FOLDRUN_RTL: locale.direction === "rtl" ? "1" : "0",
|
|
1163
|
+
// TZ is the form the sandbox's shell and Node both read; for a fixed
|
|
1164
|
+
// offset that is not the same string Intl was given (see clock.ts).
|
|
1165
|
+
TZ: clock.tz,
|
|
870
1166
|
FOLDRUN_DATE: localDate(timezone),
|
|
871
1167
|
// Where the account's own search engine answers, when the install runs
|
|
872
1168
|
// one — the `websearch` library tool reads it. Not a secret; rides with
|
|
873
1169
|
// the clock so it reaches scripts, the sandbox and verify alike.
|
|
874
1170
|
...(process.env.FOLDRUN_SEARCH_URL ? { FOLDRUN_SEARCH_URL: process.env.FOLDRUN_SEARCH_URL } : {}),
|
|
1171
|
+
// Whose index our web search asks when the agent named a search API.
|
|
1172
|
+
// Unset means the account's own engine. The name, never the key.
|
|
1173
|
+
...(searchChoice.shape === "direct" && searchChoice.provider
|
|
1174
|
+
? { FOLDRUN_WEB_SEARCH_VIA: searchChoice.provider, FOLDRUN_WEB_SEARCH_SECRET: searchChoice.secret ?? "" }
|
|
1175
|
+
: {}),
|
|
1176
|
+
// The `web.search` block's SearXNG settings, as defaults the tool reads.
|
|
1177
|
+
...searchSettingsEnv(searchRead.settings),
|
|
1178
|
+
...(fetchChoice.shape === "direct" && fetchChoice.provider
|
|
1179
|
+
? { FOLDRUN_WEB_FETCH_VIA: fetchChoice.provider, FOLDRUN_WEB_FETCH_SECRET: fetchChoice.secret ?? "" }
|
|
1180
|
+
: {}),
|
|
1181
|
+
...Object.fromEntries(actionChoices.flatMap((c) => (c.provider
|
|
1182
|
+
? [[`FOLDRUN_WEB_${c.action.toUpperCase()}_VIA`, c.provider], [`FOLDRUN_WEB_${c.action.toUpperCase()}_SECRET`, c.secret]]
|
|
1183
|
+
: []))),
|
|
1184
|
+
// Which actions the web tool may take (`web: {actions: [...]}`); unset is all.
|
|
1185
|
+
...(web.actions ? { FOLDRUN_WEB_ACTIONS: web.actions.join(",") } : {}),
|
|
1186
|
+
// A model provider's own search or fetch, which the web tool hands over to.
|
|
1187
|
+
...webBuiltin,
|
|
1188
|
+
// A remote browser: the wrapper connects over CDP to this vendor with
|
|
1189
|
+
// the named key, and the platform leaves the account's pod alone.
|
|
1190
|
+
...(browseChoice.shape === "direct" && browseChoice.provider
|
|
1191
|
+
? { FOLDRUN_BROWSER_VENDOR: browseChoice.provider, FOLDRUN_BROWSER_SECRET: browseChoice.secret ?? "" }
|
|
1192
|
+
: {}),
|
|
1193
|
+
// The vendor session's settings (`web.browse.vendor_session:`), as JSON
|
|
1194
|
+
// the tool maps onto that vendor's own option names. Under both names
|
|
1195
|
+
// for one release: a web tool from before the rename reads the old one.
|
|
1196
|
+
...(browseSession ? { FOLDRUN_BROWSER_VENDOR_SESSION: JSON.stringify(browseSession), FOLDRUN_BROWSER_SESSION: JSON.stringify(browseSession) } : {}),
|
|
1197
|
+
// The `web.browse` block, as defaults the tool reads when the call did
|
|
1198
|
+
// not say otherwise. Env rather than arguments because the tool is a
|
|
1199
|
+
// script the model calls: a default nobody typed should not have to be
|
|
1200
|
+
// typed by the model either.
|
|
1201
|
+
...(browseSettings.engine ? { FOLDRUN_BROWSER_ENGINE: browseSettings.engine } : {}),
|
|
1202
|
+
...(browseSettings.user_agent ? { FOLDRUN_BROWSER_USER_AGENT: browseSettings.user_agent } : {}),
|
|
1203
|
+
...(browseSettings.device ? { FOLDRUN_BROWSER_DEVICE: browseSettings.device } : {}),
|
|
1204
|
+
...(browseSettings.locale ? { FOLDRUN_BROWSER_LOCALE: browseSettings.locale } : {}),
|
|
1205
|
+
...(browseSettings.timezone ? { FOLDRUN_BROWSER_TIMEZONE: browseSettings.timezone } : {}),
|
|
1206
|
+
...(browseSettings.cookies ? { FOLDRUN_BROWSER_COOKIES: browseSettings.cookies } : {}),
|
|
1207
|
+
...(browseSettings.cookie_domain ? { FOLDRUN_BROWSER_COOKIE_DOMAIN: browseSettings.cookie_domain } : {}),
|
|
1208
|
+
...(browseSettings.storage ? { FOLDRUN_BROWSER_STORAGE: browseSettings.storage } : {}),
|
|
1209
|
+
...(browseSettings.storage_origin ? { FOLDRUN_BROWSER_STORAGE_ORIGIN: browseSettings.storage_origin } : {}),
|
|
1210
|
+
// The named identities, as one JSON map the tool picks from by name.
|
|
1211
|
+
...(browseSettings.identities ? { FOLDRUN_BROWSER_IDENTITIES: JSON.stringify(browseSettings.identities) } : {}),
|
|
1212
|
+
// Only the non-default travels: a window, on a virtual screen if need be.
|
|
1213
|
+
...(browseSettings.headless === false ? { FOLDRUN_BROWSER_HEADLESS: "0" } : {}),
|
|
1214
|
+
...(browseSettings.version ? { FOLDRUN_BROWSER_VERSION: browseSettings.version } : {}),
|
|
1215
|
+
...(browseSettings.live ? { FOLDRUN_BROWSER_LIVE: "1" } : {}),
|
|
1216
|
+
// What the agent may do, from its file: a lock the call cannot widen.
|
|
1217
|
+
...(browseSettings.allowed_domains ? { FOLDRUN_BROWSER_ALLOWED_DOMAINS: browseSettings.allowed_domains.join(",") } : {}),
|
|
1218
|
+
...(browseSettings.deny ? { FOLDRUN_BROWSER_DENY: browseSettings.deny.join(",") } : {}),
|
|
1219
|
+
...(browseSettings.boundaries ? { FOLDRUN_BROWSER_BOUNDARIES: "1" } : {}),
|
|
1220
|
+
...(browseSettings.init ? { FOLDRUN_BROWSER_INIT: browseSettings.init.join(",") } : {}),
|
|
1221
|
+
...(browseSettings.extensions ? { FOLDRUN_BROWSER_EXTENSIONS: browseSettings.extensions.join(",") } : {}),
|
|
1222
|
+
...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
|
|
1223
|
+
...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
|
|
1224
|
+
...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
|
|
1225
|
+
...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
|
|
1226
|
+
...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
|
|
875
1227
|
};
|
|
876
1228
|
// Scripts declared as tools — callable by name, no bash required.
|
|
877
1229
|
const scriptTools = buildScriptTools(agentDir, scriptSpecs,
|
|
@@ -901,7 +1253,7 @@ identity = {}) {
|
|
|
901
1253
|
}
|
|
902
1254
|
parts.push("Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
|
|
903
1255
|
"what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
|
|
904
|
-
"for the workspace root (
|
|
1256
|
+
"for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
|
|
905
1257
|
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool.");
|
|
906
1258
|
// The run's one-line summary is the first meaningful line of the last step
|
|
907
1259
|
// that produced a result — read, never declared (runSummary in store.ts).
|
|
@@ -923,15 +1275,49 @@ identity = {}) {
|
|
|
923
1275
|
const disabled = [];
|
|
924
1276
|
const unknownTools = [];
|
|
925
1277
|
const shadowed = [];
|
|
1278
|
+
// `web: {search: …, fetch: …}` name who answers. Unset means ours — the
|
|
1279
|
+
// account's own engine, and our own fetch, both inside the run's sandbox
|
|
1280
|
+
// and both on the run record. A model provider's name adds the SDK's
|
|
1281
|
+
// server-side tool beside ours, which the run's endpoint executes: point
|
|
1282
|
+
// the run at z.ai and z.ai's index answers the search. The web tool hands
|
|
1283
|
+
// the action over to it (FOLDRUN_WEB_BUILTIN).
|
|
1284
|
+
const providerWebTools = [];
|
|
1285
|
+
if (browseChoice.error)
|
|
1286
|
+
providerWarnings.push(browseChoice.error);
|
|
1287
|
+
providerWarnings.push(...sessionProblems, ...web.problems, ...actionChoices.flatMap((c) => (c.error ? [c.error] : [])));
|
|
1288
|
+
for (const [choice, builtin] of [
|
|
1289
|
+
[searchChoice, "WebSearch"],
|
|
1290
|
+
[fetchChoice, "WebFetch"],
|
|
1291
|
+
]) {
|
|
1292
|
+
if (choice.error) {
|
|
1293
|
+
providerWarnings.push(choice.error);
|
|
1294
|
+
}
|
|
1295
|
+
else if (choice.provider && choice.shape !== "direct") {
|
|
1296
|
+
// A provider's server-side tool replaces ours. A direct search API
|
|
1297
|
+
// does not: our tool stays, and reads FOLDRUN_WEB_SEARCH_VIA to know
|
|
1298
|
+
// whose index to ask — through the egress proxy, with the customer's
|
|
1299
|
+
// own key, on the run record.
|
|
1300
|
+
providerWebTools.push(builtin);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
926
1303
|
let wantSearch = false;
|
|
927
1304
|
let wantHistory = false;
|
|
928
1305
|
let wantDesks = false;
|
|
1306
|
+
let wantAsk = false;
|
|
929
1307
|
for (const ref of toolRefs(front)) {
|
|
930
1308
|
const toolName = ref.name;
|
|
931
1309
|
if (ref.mode === "ask") {
|
|
932
1310
|
disabled.push(toolName);
|
|
933
1311
|
continue;
|
|
934
1312
|
}
|
|
1313
|
+
// `web` is our own tool, granted with the rest of them above. A model
|
|
1314
|
+
// provider answering search or fetch on its own side (`web: {search:
|
|
1315
|
+
// zai}`) adds that provider's tool beside it; the web tool's search then
|
|
1316
|
+
// names it (FOLDRUN_WEB_BUILTIN) rather than asking our engine.
|
|
1317
|
+
if (toolName === "web" && !ref.linked) {
|
|
1318
|
+
allowed.push(...providerWebTools);
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
935
1321
|
// Resolution order: built-in group, exact SDK name, then your own tools.
|
|
936
1322
|
// Built-ins win a bare name, because their names are a small fixed set —
|
|
937
1323
|
// but a tool hidden by one is worth saying out loud rather than silently
|
|
@@ -945,13 +1331,15 @@ identity = {}) {
|
|
|
945
1331
|
if (available[toolName])
|
|
946
1332
|
shadowed.push(toolName);
|
|
947
1333
|
}
|
|
948
|
-
else if (toolName === "search" || toolName === "history" || toolName === "desks") {
|
|
1334
|
+
else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
|
|
949
1335
|
// The platform's own groups, served in-process — built below, once
|
|
950
1336
|
// the roots and the records are known.
|
|
951
1337
|
if (toolName === "search")
|
|
952
1338
|
wantSearch = true;
|
|
953
1339
|
else if (toolName === "history")
|
|
954
1340
|
wantHistory = true;
|
|
1341
|
+
else if (toolName === "ask")
|
|
1342
|
+
wantAsk = true;
|
|
955
1343
|
else
|
|
956
1344
|
wantDesks = true;
|
|
957
1345
|
if (available[toolName])
|
|
@@ -981,8 +1369,8 @@ identity = {}) {
|
|
|
981
1369
|
{ label: "memory/", dir: path.join(agentDir, "memory") },
|
|
982
1370
|
{ label: "../../knowledge/", dir: path.join(wsRoot, "knowledge") },
|
|
983
1371
|
{ label: "../../memory/", dir: path.join(wsRoot, "memory") },
|
|
984
|
-
{ label: "
|
|
985
|
-
{ label:
|
|
1372
|
+
{ label: "workspace/state/", dir: path.join(wsRoot, "state") },
|
|
1373
|
+
{ label: `workspace/${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
|
|
986
1374
|
{ label: "outputs/", dir: path.join(agentDir, "outputs") },
|
|
987
1375
|
{ label: "[library] knowledge/", dir: libraryDir(tenant, "knowledge") },
|
|
988
1376
|
{ label: "[library] memory/", dir: libraryDir(tenant, "memory") },
|
|
@@ -1004,6 +1392,14 @@ identity = {}) {
|
|
|
1004
1392
|
const historyTools = wantHistory ? buildHistoryTools(historyDigest) : { server: null, toolNames: [], promptLines: [] };
|
|
1005
1393
|
const deskTools = wantDesks ? buildDeskTools(deskDigest) : { server: null, toolNames: [], promptLines: [] };
|
|
1006
1394
|
allowed.push(...searchTools.toolNames, ...historyTools.toolNames, ...deskTools.toolNames);
|
|
1395
|
+
// tools: [ask] — ask_person. Its server is built where the step runs (it
|
|
1396
|
+
// needs the step's emit, and in a pod the proxy's address); here only the
|
|
1397
|
+
// grant, the wait and the prompt's line.
|
|
1398
|
+
const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
|
|
1399
|
+
if (ask) {
|
|
1400
|
+
allowed.push(ASK_TOOL);
|
|
1401
|
+
parts.push(`# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
|
|
1402
|
+
}
|
|
1007
1403
|
if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
|
|
1008
1404
|
parts.push(`# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
|
|
1009
1405
|
}
|
|
@@ -1023,12 +1419,16 @@ identity = {}) {
|
|
|
1023
1419
|
return {
|
|
1024
1420
|
front,
|
|
1025
1421
|
clockEnv,
|
|
1422
|
+
/** The zone, where it came from, and anything unreadable on the way —
|
|
1423
|
+
* reported by the step, not here, so it lands in the run's trace. */
|
|
1424
|
+
clock,
|
|
1026
1425
|
systemPrompt: parts.join("\n\n"),
|
|
1027
1426
|
allowed: finalAllowed,
|
|
1028
1427
|
disabled,
|
|
1029
1428
|
unknownTools,
|
|
1030
1429
|
shadowed,
|
|
1031
1430
|
legacyUse,
|
|
1431
|
+
retiredTools: retiredToolNames(front),
|
|
1032
1432
|
mcpServers,
|
|
1033
1433
|
mcpNames,
|
|
1034
1434
|
apiTools,
|
|
@@ -1038,19 +1438,21 @@ identity = {}) {
|
|
|
1038
1438
|
// than using the servers built here.
|
|
1039
1439
|
apiSpecs: apis,
|
|
1040
1440
|
scriptSpecs,
|
|
1441
|
+
outward: outward || scriptSpecs.some((sc) => sc.outward),
|
|
1041
1442
|
searchRoots,
|
|
1042
1443
|
historyDigest,
|
|
1043
1444
|
deskDigest,
|
|
1044
1445
|
searchTools,
|
|
1045
1446
|
historyTools,
|
|
1046
1447
|
deskTools,
|
|
1448
|
+
ask,
|
|
1047
1449
|
// `spec` is the MERGED declaration — the agent's own block or its
|
|
1048
1450
|
// workspace's, plus every granted tool's. The isolated path builds its
|
|
1049
1451
|
// runtime from this; passing the agent's frontmatter alone silently drops
|
|
1050
1452
|
// what the tools declared, and a tool whose program needs a package it
|
|
1051
1453
|
// asked for by name fails inside the container with "cannot find" while
|
|
1052
1454
|
// the host path, which uses this same merged spec, works fine.
|
|
1053
|
-
runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec },
|
|
1455
|
+
runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
|
|
1054
1456
|
secretEnv: { ...runtime.env, ...secretEnv },
|
|
1055
1457
|
formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
|
|
1056
1458
|
providerEnv,
|
|
@@ -1058,8 +1460,19 @@ identity = {}) {
|
|
|
1058
1460
|
providerSecrets,
|
|
1059
1461
|
providerWarnings,
|
|
1060
1462
|
fallbackEnv,
|
|
1061
|
-
|
|
1062
|
-
|
|
1463
|
+
region,
|
|
1464
|
+
// Who answers web search and fetch, resolved once up top. The step
|
|
1465
|
+
// needs them again to grant a direct API's key to its host.
|
|
1466
|
+
searchChoice,
|
|
1467
|
+
fetchChoice,
|
|
1468
|
+
actionChoices,
|
|
1469
|
+
web,
|
|
1470
|
+
language,
|
|
1471
|
+
// Stamped with the step's own zone: when one of these refuses, the
|
|
1472
|
+
// reset time it reports is told in the calendar the step works to,
|
|
1473
|
+
// not in the pod's UTC.
|
|
1474
|
+
translator: translator && { ...translator, timezone },
|
|
1475
|
+
fallbackTranslator: fallbackTranslator && { ...fallbackTranslator, timezone },
|
|
1063
1476
|
apiWarnings,
|
|
1064
1477
|
secretScopes,
|
|
1065
1478
|
missingTools,
|
|
@@ -1088,9 +1501,9 @@ identity = {}) {
|
|
|
1088
1501
|
* Never fails a step: a link is bookkeeping, and a run that did its work must
|
|
1089
1502
|
* not be marked failed because a URL could not be minted.
|
|
1090
1503
|
*/
|
|
1091
|
-
function publishPublicDir(tenant, workspace, push) {
|
|
1504
|
+
async function publishPublicDir(tenant, workspace, push) {
|
|
1092
1505
|
try {
|
|
1093
|
-
const { added } = platform.syncPublicShares(tenant, workspace);
|
|
1506
|
+
const { added } = await platform.syncPublicShares(tenant, workspace);
|
|
1094
1507
|
if (added.length)
|
|
1095
1508
|
push("info", `shared: ${added.join(", ")}`);
|
|
1096
1509
|
}
|
|
@@ -1098,10 +1511,18 @@ function publishPublicDir(tenant, workspace, push) {
|
|
|
1098
1511
|
push("error", `shares: ${err instanceof Error ? err.message : String(err)}`);
|
|
1099
1512
|
}
|
|
1100
1513
|
}
|
|
1101
|
-
function repriced(catalog, model, usage, sdkCost, push
|
|
1514
|
+
export function repriced(catalog, model, usage, sdkCost, push,
|
|
1515
|
+
/** The step's turns each priced at its own model — set when a sub-agent
|
|
1516
|
+
* ran on a different model (ExecOutcome.turnsCostUsd). The lump `usage`
|
|
1517
|
+
* priced at the step's model would bill those turns at the wrong rate. */
|
|
1518
|
+
turnsCostUsd) {
|
|
1102
1519
|
const known = usage ? catalogCost(catalog, model, usage) : null;
|
|
1103
1520
|
if (known === null)
|
|
1104
1521
|
return sdkCost;
|
|
1522
|
+
if (typeof turnsCostUsd === "number") {
|
|
1523
|
+
push("info", `cost from each model turn at its own model's rate: $${turnsCostUsd.toFixed(6)} (sub-agents ran on other models)`);
|
|
1524
|
+
return turnsCostUsd;
|
|
1525
|
+
}
|
|
1105
1526
|
if (sdkCost !== null && Math.abs(known - sdkCost) > 0.000001) {
|
|
1106
1527
|
push("info", `cost repriced from the gateway's catalogue: $${known.toFixed(6)} (sdk said $${sdkCost.toFixed(6)})`);
|
|
1107
1528
|
}
|
|
@@ -1117,7 +1538,9 @@ function repriced(catalog, model, usage, sdkCost, push) {
|
|
|
1117
1538
|
* step used to carry only its last attempt's cost, so a `retry: 2` step
|
|
1118
1539
|
* could spend three shares while reporting one.
|
|
1119
1540
|
*/
|
|
1120
|
-
export function recordAttempt(step,
|
|
1541
|
+
export function recordAttempt(step,
|
|
1542
|
+
/** The step's attempt (`attempts`) these rows belong to. */
|
|
1543
|
+
attempt, status, startedAt, eventsBefore,
|
|
1121
1544
|
/** The attempt re-attached to a sandbox a previous driver left: same
|
|
1122
1545
|
* attempt, so its row is replaced rather than repeated. */
|
|
1123
1546
|
reattach = false) {
|
|
@@ -1126,23 +1549,74 @@ reattach = false) {
|
|
|
1126
1549
|
.slice(eventsBefore)
|
|
1127
1550
|
.filter((e) => e.type === "error")
|
|
1128
1551
|
.at(-1)?.text;
|
|
1552
|
+
// Re-attached: the same attempt, so its rows — the last one, and the lost
|
|
1553
|
+
// slim go before it — are replaced, not repeated.
|
|
1554
|
+
const kept = [...tries];
|
|
1555
|
+
const attemptOf = (t) => t.attempt ?? t.n;
|
|
1556
|
+
if (reattach && kept.length && attemptOf(kept.at(-1)) === attempt) {
|
|
1557
|
+
kept.pop();
|
|
1558
|
+
if (kept.length && attemptOf(kept.at(-1)) === attempt && kept.at(-1).status === "lost")
|
|
1559
|
+
kept.pop();
|
|
1560
|
+
}
|
|
1561
|
+
// Tries are numbered in the order they ran: a full re-run after a lost
|
|
1562
|
+
// slim go is the next try, not the same number again (live, 2026-10-01,
|
|
1563
|
+
// both rows read n: 1). `attempt` says which attempt a row belongs to
|
|
1564
|
+
// when its number no longer does.
|
|
1565
|
+
let next = (kept.at(-1)?.n ?? 0) + 1;
|
|
1566
|
+
const numbered = () => {
|
|
1567
|
+
const n = next++;
|
|
1568
|
+
return n === attempt ? { n } : { n, attempt };
|
|
1569
|
+
};
|
|
1570
|
+
// A slim go the browser pod was lost under, before this attempt re-ran
|
|
1571
|
+
// on the full image: its own row, first (runStep set it aside).
|
|
1572
|
+
const lost = step.podLostTry ? [{ ...numbered(), ...step.podLostTry, startedAt }] : [];
|
|
1129
1573
|
const row = {
|
|
1130
|
-
|
|
1574
|
+
...numbered(),
|
|
1131
1575
|
status,
|
|
1132
1576
|
costUsd: step.costUsd,
|
|
1133
1577
|
tokens: step.tokens ?? null,
|
|
1134
1578
|
computeSecs: step.computeSecs ?? null,
|
|
1135
|
-
startedAt,
|
|
1579
|
+
startedAt: step.podLostTry?.finishedAt ?? startedAt,
|
|
1136
1580
|
finishedAt: step.finishedAt ?? new Date().toISOString(),
|
|
1137
1581
|
...(status === "failed" && lastError ? { error: lastError.slice(0, 500) } : {}),
|
|
1582
|
+
...(step.image?.variant ? { image: step.image.variant } : {}),
|
|
1583
|
+
...(step.browserPod ? { browserPod: browserPodLine(step.browserPod) } : {}),
|
|
1138
1584
|
};
|
|
1139
|
-
|
|
1140
|
-
step.tries = [...kept, row];
|
|
1585
|
+
delete step.podLostTry;
|
|
1586
|
+
step.tries = [...kept, ...lost, row];
|
|
1141
1587
|
const totals = sumAttempts(step.tries);
|
|
1142
1588
|
step.costUsd = totals.costUsd;
|
|
1143
1589
|
step.tokens = totals.tokens;
|
|
1144
1590
|
step.computeSecs = totals.computeSecs;
|
|
1145
1591
|
}
|
|
1592
|
+
/** A question an agent asked mid-step goes where approvals go (the
|
|
1593
|
+
* workspace's notify:, `awaiting-approval` events), with the way to answer. */
|
|
1594
|
+
async function notifyQuestion(tenant, workspace, runId, agent, question, options) {
|
|
1595
|
+
const origin = (process.env.FOLDRUN_PUBLIC_URL ?? "").replace(/\/+$/, "");
|
|
1596
|
+
const where = origin ? `${origin}/dashboard/${encodeURIComponent(workspace)}/runs/${encodeURIComponent(runId)}${tenant !== "default" ? `?tenant=${encodeURIComponent(tenant)}` : ""}` : `run ${runId}`;
|
|
1597
|
+
await sendPlainNotification(tenant, workspace, {
|
|
1598
|
+
event: "awaiting-approval",
|
|
1599
|
+
headline: `${agent} is asking you something`,
|
|
1600
|
+
detail: `${question}${options?.length ? `\n\nOptions: ${options.join(" · ")}` : ""}\n\nAnswer it on ${where}, or \`foldrun answer ${runId} "…"\`.`,
|
|
1601
|
+
runId,
|
|
1602
|
+
}).catch(() => false);
|
|
1603
|
+
}
|
|
1604
|
+
/** The local stand-in for a person: the terminal, when there is one. */
|
|
1605
|
+
async function terminalAsk(question, options) {
|
|
1606
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1607
|
+
return null;
|
|
1608
|
+
const { createInterface } = await import("node:readline/promises");
|
|
1609
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1610
|
+
try {
|
|
1611
|
+
const list = options?.length ? `\n${options.map((o, i) => ` ${i + 1}. ${o}`).join("\n")}\n` : "";
|
|
1612
|
+
const raw = (await rl.question(`\n the agent asks: ${question}${list}\n your answer: `)).trim();
|
|
1613
|
+
const n = Number(raw);
|
|
1614
|
+
return options?.length && Number.isInteger(n) && n >= 1 && n <= options.length ? options[n - 1] : raw || null;
|
|
1615
|
+
}
|
|
1616
|
+
finally {
|
|
1617
|
+
rl.close();
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1146
1620
|
async function runStep(agentDir, tenant, step, context, save, modelOverride, tags = [], effortOverride, runId,
|
|
1147
1621
|
/** What the previous group returned as DATA (an `output: json` step's
|
|
1148
1622
|
* value), beside the prose in `context`. Undefined when nothing did. */
|
|
@@ -1159,7 +1633,14 @@ testRun = false,
|
|
|
1159
1633
|
/** Has a person asked for the run to stop? Read on a clock by the
|
|
1160
1634
|
* in-process model loop, so a stop lands mid-step and not at the next
|
|
1161
1635
|
* group. The isolated path needs none: stopRun destroys the sandbox. */
|
|
1162
|
-
stopRequested
|
|
1636
|
+
stopRequested,
|
|
1637
|
+
/** The flow's `timezone:`, for the clock cascade. Undefined for a step
|
|
1638
|
+
* run outside any flow, which still gets agent → workspace → account. */
|
|
1639
|
+
flowTimezone,
|
|
1640
|
+
/** The step's index in run.steps, carried on its egress lease so a
|
|
1641
|
+
* person's message to this step reaches this step's own inbox and not a
|
|
1642
|
+
* sibling's running in the same group. Undefined outside a flow. */
|
|
1643
|
+
stepIndex) {
|
|
1163
1644
|
// Secret values are injected into scripts as environment variables and
|
|
1164
1645
|
// substituted into API headers, so a model that reads one back — from a
|
|
1165
1646
|
// script that prints it, an API error that echoes it, or its own `env` —
|
|
@@ -1177,6 +1658,11 @@ stopRequested) {
|
|
|
1177
1658
|
// cover a script's own stdout.
|
|
1178
1659
|
let redactions = [];
|
|
1179
1660
|
let fileDir = null;
|
|
1661
|
+
let disposeRuntime;
|
|
1662
|
+
// Can this step's tools act outside the workspace? Set once its context is
|
|
1663
|
+
// read; the verdict at the end of this function reads it.
|
|
1664
|
+
let canActOutward = false;
|
|
1665
|
+
const eventsAtStart = step.events.length;
|
|
1180
1666
|
const redact = (text) => {
|
|
1181
1667
|
let out = text;
|
|
1182
1668
|
for (const [value, name] of redactions)
|
|
@@ -1200,7 +1686,16 @@ stopRequested) {
|
|
|
1200
1686
|
}
|
|
1201
1687
|
};
|
|
1202
1688
|
const push = (type, text, extra) => {
|
|
1203
|
-
|
|
1689
|
+
const t = new Date().toISOString();
|
|
1690
|
+
step.events.push({ t, type, text: redact(text), ...extra });
|
|
1691
|
+
// A person in the loop: the question, its answer, a message delivered —
|
|
1692
|
+
// onto the step, so the run page, the API and history show them.
|
|
1693
|
+
if (extra?.operator) {
|
|
1694
|
+
applyOperatorEvent(step, extra.operator, t);
|
|
1695
|
+
if (extra.operator.kind === "asked" && runId && !testRun) {
|
|
1696
|
+
void notifyQuestion(tenant, path.basename(path.resolve(agentDir, "..", "..")), runId, step.agent, extra.operator.question, extra.operator.options);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1204
1699
|
save();
|
|
1205
1700
|
};
|
|
1206
1701
|
// The clock on the step itself. `??=` so a retry extends the first
|
|
@@ -1218,7 +1713,9 @@ stopRequested) {
|
|
|
1218
1713
|
? fs.readFileSync(stubFile, "utf8").split(/\n---\n/)
|
|
1219
1714
|
: [`stub result from ${step.agent}`];
|
|
1220
1715
|
const call = step.events.filter((e) => e.text.startsWith("stub call")).length;
|
|
1221
|
-
|
|
1716
|
+
// The ceiling it was given rides on the line, so the budget arithmetic
|
|
1717
|
+
// around fan-out is testable without a model to stop.
|
|
1718
|
+
push("info", `stub call ${call + 1}${stepBudgetUsd !== null ? ` (ceiling $${stepBudgetUsd.toFixed(2)})` : ""}`);
|
|
1222
1719
|
step.status = "completed";
|
|
1223
1720
|
// A stub answer may open with `cost: 0.25` — a pretend price, so the
|
|
1224
1721
|
// parts of orchestration that watch money (budget:) are testable too.
|
|
@@ -1261,7 +1758,9 @@ stopRequested) {
|
|
|
1261
1758
|
// step: the generic HTTP tool still works without the typed ones.
|
|
1262
1759
|
for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
|
|
1263
1760
|
push("error", `openapi: ${w}`);
|
|
1264
|
-
const { front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, translator, fallbackTranslator, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
|
|
1761
|
+
const { front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
1762
|
+
disposeRuntime = runtime.dispose;
|
|
1763
|
+
canActOutward = outward;
|
|
1265
1764
|
// A retry that moved the step up a class (an evicted or OOM-killed
|
|
1266
1765
|
// attempt) wins over the agent's own `size:` for the attempts after.
|
|
1267
1766
|
const size = step.sizeUp ?? agentSize;
|
|
@@ -1284,18 +1783,31 @@ stopRequested) {
|
|
|
1284
1783
|
.map(([name, value]) => [value, name])
|
|
1285
1784
|
// Longest first, so a value containing another is replaced whole.
|
|
1286
1785
|
.sort((a, b) => b[0].length - a[0].length);
|
|
1786
|
+
// One line for the clock: which zone this step works to, and which level
|
|
1787
|
+
// set it. "Why is this dated yesterday" should be answerable from the run.
|
|
1788
|
+
push("info", `clock: ${clock.timezone} (${clock.source}) — today is ${clockEnv.FOLDRUN_DATE}`);
|
|
1789
|
+
// And one more only when something written could not be read as a zone.
|
|
1790
|
+
for (const problem of clock.problems)
|
|
1791
|
+
push("error", problem);
|
|
1287
1792
|
if (providerLabel)
|
|
1288
1793
|
push("info", `provider: ${providerLabel}`);
|
|
1289
1794
|
for (const w of providerWarnings)
|
|
1290
1795
|
push("error", w);
|
|
1796
|
+
// A level that wrote a language nobody can read was skipped, not obeyed.
|
|
1797
|
+
for (const l of language.lines)
|
|
1798
|
+
push("info", l);
|
|
1799
|
+
for (const l of region.lines)
|
|
1800
|
+
push("info", l);
|
|
1291
1801
|
for (const w of apiWarnings)
|
|
1292
1802
|
push("error", `openapi: ${w}`);
|
|
1293
1803
|
if (formatWarning)
|
|
1294
1804
|
push("error", formatWarning);
|
|
1295
1805
|
if (legacyUse.length)
|
|
1296
1806
|
push("error", legacyUseError(legacyUse));
|
|
1807
|
+
for (const t of retiredTools)
|
|
1808
|
+
push("error", retiredToolError(t));
|
|
1297
1809
|
for (const t of unknownTools) {
|
|
1298
|
-
push("error", `tools: "${t}" is not a tool group (
|
|
1810
|
+
push("error", `tools: "${t}" is not a tool group (read, write, code, search, history, desks), an SDK tool name, or a tool in this workspace's library — nothing was granted for it`);
|
|
1299
1811
|
}
|
|
1300
1812
|
for (const name of mcpNames)
|
|
1301
1813
|
push("info", `mcp server connected: ${name}`);
|
|
@@ -1404,9 +1916,11 @@ stopRequested) {
|
|
|
1404
1916
|
fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
|
|
1405
1917
|
// Expose the workspace's shared scripts/ as ./shared so every agent reaches
|
|
1406
1918
|
// them by the same path, without each one carrying a copy.
|
|
1407
|
-
// No symlinks into the agent directory — they would recurse
|
|
1408
|
-
// workspace's own file listing. Shared paths are resolved by the
|
|
1409
|
-
// tool layer and described to the agent below
|
|
1919
|
+
// No permanent symlinks into the agent directory — they would recurse
|
|
1920
|
+
// into the workspace's own file listing. Shared paths are resolved by the
|
|
1921
|
+
// script tool layer and described to the agent below; the one link there
|
|
1922
|
+
// is, `workspace` → the root for the shell, lives only for the length of
|
|
1923
|
+
// a step (executeStep, confine.ts#linkWorkspace).
|
|
1410
1924
|
// The confinement boundary: agents in a workspace are one team, but the
|
|
1411
1925
|
// workspace is where isolation is enforced.
|
|
1412
1926
|
const workspaceRoot = path.resolve(agentDir, "..", "..");
|
|
@@ -1493,14 +2007,40 @@ stopRequested) {
|
|
|
1493
2007
|
for (const m of missingConsults) {
|
|
1494
2008
|
push("error", `agents: "${m}" is not an agent in this workspace — no consult tool granted`);
|
|
1495
2009
|
}
|
|
1496
|
-
const consultNames =
|
|
2010
|
+
const consultNames = [
|
|
2011
|
+
...mcpNames,
|
|
2012
|
+
...(consults.length ? ["foldrun_agents"] : []),
|
|
2013
|
+
...(ask ? ["foldrun_ask"] : []),
|
|
2014
|
+
];
|
|
2015
|
+
// Colleagues this agent may DELEGATE to: each with its own context and
|
|
2016
|
+
// its own tools — resolved exactly as its own step's would be, then cut
|
|
2017
|
+
// to what THIS step holds (subagents.ts). Built host-side, like consults.
|
|
2018
|
+
const subagents = buildSubagentSpecs({
|
|
2019
|
+
names: front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
|
|
2020
|
+
parentAllowed: [...allowed, ...consultNames.map((n) => `mcp__${n}`)], push,
|
|
2021
|
+
});
|
|
2022
|
+
if (subagents.length) {
|
|
2023
|
+
// The delegation tool itself, granted only when there is someone to
|
|
2024
|
+
// delegate to.
|
|
2025
|
+
allowed.push(DELEGATE_TOOL);
|
|
2026
|
+
for (const s of subagents)
|
|
2027
|
+
push("info", `subagent: ${s.name} — ${s.tools.length ? s.tools.join(", ") : "no tools"}`);
|
|
2028
|
+
}
|
|
1497
2029
|
// The last provider refusal this step saw, for the fallback decision —
|
|
1498
2030
|
// watched on both paths, because a refusal reads the same from a pod
|
|
1499
2031
|
// and from this process.
|
|
1500
2032
|
let lastRefusal = "";
|
|
2033
|
+
// And WHY it refused, in words, when anything on the way saw the
|
|
2034
|
+
// headers that say so: the translator here, the egress proxy over
|
|
2035
|
+
// there. Kept apart from `lastRefusal` — that one decides whether to
|
|
2036
|
+
// fall back, this one is what a person reads afterwards.
|
|
2037
|
+
let refusalWhy = "";
|
|
1501
2038
|
const pushWatching = (type, text, extra) => {
|
|
1502
2039
|
if (type === "error" && isProviderRefusal(text))
|
|
1503
2040
|
lastRefusal = text;
|
|
2041
|
+
const why = refusalFromLine(text);
|
|
2042
|
+
if (why)
|
|
2043
|
+
refusalWhy = why;
|
|
1504
2044
|
push(type, text, extra);
|
|
1505
2045
|
};
|
|
1506
2046
|
// The second supply: the block's own fallback when it declared one (a
|
|
@@ -1508,6 +2048,28 @@ stopRequested) {
|
|
|
1508
2048
|
// fallback), else the platform's, and only for a step riding the
|
|
1509
2049
|
// platform credential.
|
|
1510
2050
|
const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
|
|
2051
|
+
// Where this step stands on a second supply, for the refusal sentence.
|
|
2052
|
+
// It starts as the truth before anything has run and becomes
|
|
2053
|
+
// "exhausted" the moment the fallback has had its turn — the owner is
|
|
2054
|
+
// about to remove the OpenRouter fallback, so "none" becomes the normal
|
|
2055
|
+
// reading and has to look like a decision on the page.
|
|
2056
|
+
let supplyState = secondSupply ? "trying" : "none";
|
|
2057
|
+
// The line a person reads when this step ends failed: the reason, if
|
|
2058
|
+
// anything on the way learned one, else the status put into words, and
|
|
2059
|
+
// in every case where the step could go next. Pushed last so it is the
|
|
2060
|
+
// last error event — which is what runFailure, the desks' headlines and
|
|
2061
|
+
// `foldrun report` read.
|
|
2062
|
+
const sayWhy = (status) => {
|
|
2063
|
+
if (status !== "failed" || !lastRefusal)
|
|
2064
|
+
return;
|
|
2065
|
+
const said = Number(lastRefusal.match(/API Error: (\d{3})/)?.[1] ?? NaN);
|
|
2066
|
+
const why = refusalWhy ||
|
|
2067
|
+
explainRefusal({
|
|
2068
|
+
status: Number.isFinite(said) && isRefusalStatus(said) ? said : 402,
|
|
2069
|
+
timezone: clock.timezone,
|
|
2070
|
+
});
|
|
2071
|
+
push("error", `${why}${supplyNote(supplyState)}`);
|
|
2072
|
+
};
|
|
1511
2073
|
// `schema:` — inline on the step, or a file under the workspace read
|
|
1512
2074
|
// now, host-side, so both executors are handed the same document. A
|
|
1513
2075
|
// file that cannot be read is a failed step before any model runs:
|
|
@@ -1518,6 +2080,19 @@ stopRequested) {
|
|
|
1518
2080
|
}
|
|
1519
2081
|
if (step.maxTurns)
|
|
1520
2082
|
push("info", `max_turns: ${step.maxTurns}`);
|
|
2083
|
+
// `limits:` — account, workspace, agent, then this step's option;
|
|
2084
|
+
// nearest wins per key. Enforced in the model loop's hook, on either
|
|
2085
|
+
// path (step-exec.ts), so it is resolved here as plain values.
|
|
2086
|
+
const stepLimits = cascadeLimits([
|
|
2087
|
+
readAgentsMd(accountDir(tenant))?.data?.limits,
|
|
2088
|
+
readAgentsMd(workspaceRootOf(agentDir))?.data?.limits,
|
|
2089
|
+
front.limits,
|
|
2090
|
+
step.limits,
|
|
2091
|
+
]);
|
|
2092
|
+
const limitOwners = toolOwners(apiTools.owners, scriptSpecs);
|
|
2093
|
+
const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
|
|
2094
|
+
if (limitOpts.limits)
|
|
2095
|
+
push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
|
|
1521
2096
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
1522
2097
|
if (isolatedRun()) {
|
|
1523
2098
|
// The isolated path: the whole loop — model, built-in tools, scripts —
|
|
@@ -1540,7 +2115,19 @@ stopRequested) {
|
|
|
1540
2115
|
// before anything is copied in. Null where no proxy is configured
|
|
1541
2116
|
// (the CLI, a compose install without FOLDRUN_EGRESS_URL), and then
|
|
1542
2117
|
// everything below materialises exactly as it always did.
|
|
1543
|
-
const grant = { secrets: {} };
|
|
2118
|
+
const grant = { secrets: {}, timezone: clock.timezone };
|
|
2119
|
+
// A direct search API's key: granted to that API's host and no other,
|
|
2120
|
+
// exactly as an http tool's key is granted to its base URL's host. The
|
|
2121
|
+
// script sends the placeholder; the worker fills it on the way out.
|
|
2122
|
+
for (const choice of [searchChoice, fetchChoice]) {
|
|
2123
|
+
if (choice.shape === "direct" && choice.secret && choice.host && choice.secret in liveSecrets) {
|
|
2124
|
+
addGrant(grant, choice.secret, liveSecrets[choice.secret], choice.host);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
for (const c of actionChoices) {
|
|
2128
|
+
if (c.provider && c.secret in liveSecrets)
|
|
2129
|
+
addGrant(grant, c.secret, liveSecrets[c.secret], c.host);
|
|
2130
|
+
}
|
|
1544
2131
|
for (const api of apiSpecs) {
|
|
1545
2132
|
const host = hostOf(api.base);
|
|
1546
2133
|
if (!host)
|
|
@@ -1556,13 +2143,16 @@ stopRequested) {
|
|
|
1556
2143
|
}
|
|
1557
2144
|
}
|
|
1558
2145
|
}
|
|
1559
|
-
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun });
|
|
2146
|
+
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
|
|
1560
2147
|
// Which secrets must still be real inside the sandbox: a script tool
|
|
1561
2148
|
// reads them from its environment, and unless it says `secrets:
|
|
1562
2149
|
// proxied` it needs the value, not the name. bash is a script by
|
|
1563
2150
|
// another route. The record says which way it went, and why.
|
|
1564
2151
|
const materialisers = [
|
|
1565
2152
|
...scriptSpecs.filter((sc) => sc.secrets !== "proxied").map((sc) => sc.name),
|
|
2153
|
+
// web proxies its keys — unless it may browse: a remote browser's key
|
|
2154
|
+
// goes into a websocket, and cookie and login secrets into the page.
|
|
2155
|
+
...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
|
|
1566
2156
|
...(allowed.includes("Bash") ? ["bash"] : []),
|
|
1567
2157
|
];
|
|
1568
2158
|
const materialise = !lease || materialisers.length > 0;
|
|
@@ -1634,7 +2224,7 @@ stopRequested) {
|
|
|
1634
2224
|
libraryRoot: libraryDir(tenant),
|
|
1635
2225
|
galleryRoot: platform.galleryDir() ?? undefined,
|
|
1636
2226
|
// Keys this step's dependency cache. Per-account by design — see
|
|
1637
|
-
// run-container.ts#
|
|
2227
|
+
// run-container.ts#runtimeCacheMount for why it is not shared.
|
|
1638
2228
|
tenant,
|
|
1639
2229
|
input: {
|
|
1640
2230
|
agentRel: path.relative(workspaceRoot, agentDir).replaceAll("\\", "/"),
|
|
@@ -1651,6 +2241,7 @@ stopRequested) {
|
|
|
1651
2241
|
// field. A tool's own `runtime:` is part of what this step needs.
|
|
1652
2242
|
runtime: runtime.spec,
|
|
1653
2243
|
consults,
|
|
2244
|
+
...(subagents.length ? { subagents } : {}),
|
|
1654
2245
|
timeoutSec: step.timeout,
|
|
1655
2246
|
budgetUsd: stepBudgetUsd,
|
|
1656
2247
|
budgetNote: stepBudgetNote,
|
|
@@ -1659,7 +2250,12 @@ stopRequested) {
|
|
|
1659
2250
|
output: step.output,
|
|
1660
2251
|
schema: resolvedSchema ?? undefined,
|
|
1661
2252
|
maxTurns: step.maxTurns,
|
|
2253
|
+
...limitOpts,
|
|
1662
2254
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
2255
|
+
// Outward steps keep a browser of their own: an executor that
|
|
2256
|
+
// could browse from the slim image through the account's pod
|
|
2257
|
+
// leaves them on the full one (browser-pod.ts).
|
|
2258
|
+
...(outward ? { outward: true } : {}),
|
|
1663
2259
|
// The container sees the workspace at /workspace and the library
|
|
1664
2260
|
// at /library; the roots are named host-side and moved here.
|
|
1665
2261
|
search: searchRoots.map((r) => ({
|
|
@@ -1670,6 +2266,7 @@ stopRequested) {
|
|
|
1670
2266
|
})),
|
|
1671
2267
|
history: historyDigest,
|
|
1672
2268
|
desks: deskDigest,
|
|
2269
|
+
...(ask ? { ask } : {}),
|
|
1673
2270
|
},
|
|
1674
2271
|
env: Object.fromEntries(Object.entries({
|
|
1675
2272
|
// The platform's own model credential, when the agent names no
|
|
@@ -1719,8 +2316,39 @@ stopRequested) {
|
|
|
1719
2316
|
// attempt holds its own sandbox, so the meter owes the sum.
|
|
1720
2317
|
const firstArgs = isolatedArgs(platformModelEnv());
|
|
1721
2318
|
await lease?.commit();
|
|
1722
|
-
let
|
|
2319
|
+
let credentialUsed = platformModelCredential();
|
|
2320
|
+
// The arguments of the attempt whose outcome is current — what a
|
|
2321
|
+
// re-run on the full image repeats, on the same model supply.
|
|
2322
|
+
let lastArgs = firstArgs;
|
|
2323
|
+
const runTracked = (a) => {
|
|
2324
|
+
lastArgs = a;
|
|
2325
|
+
return runIsolated(a);
|
|
2326
|
+
};
|
|
2327
|
+
let outcome = await runTracked(firstArgs);
|
|
1723
2328
|
step.sandbox = null; // whatever happens next starts its own
|
|
2329
|
+
// A 401 from a token that rotated mid-step is not a broken key: the
|
|
2330
|
+
// replacement is already on its way to the mounted file. Wait for it
|
|
2331
|
+
// once, then fall into the same retry the busy case uses.
|
|
2332
|
+
if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
|
|
2333
|
+
push("info", `the model credential was refused mid-step — waiting for the refreshed one`);
|
|
2334
|
+
const rotated = await awaitRotatedCredential(credentialUsed);
|
|
2335
|
+
if (rotated) {
|
|
2336
|
+
push("info", "the credential rotated under this step; retrying on the new one");
|
|
2337
|
+
credentialUsed = rotated;
|
|
2338
|
+
lastRefusal = "";
|
|
2339
|
+
const previous = outcome.timing;
|
|
2340
|
+
// Build first: isolatedArgs is what re-grants the model key, and
|
|
2341
|
+
// commit() is what publishes the grant to Redis, where the proxy
|
|
2342
|
+
// reads it. Committing first sent the revoked token again — every
|
|
2343
|
+
// rotation retry on 2026-09-22/23 401'd this way.
|
|
2344
|
+
const retry = isolatedArgs(platformModelEnv());
|
|
2345
|
+
await lease?.commit();
|
|
2346
|
+
outcome = withEarlierTiming(await runTracked(retry), previous);
|
|
2347
|
+
}
|
|
2348
|
+
else {
|
|
2349
|
+
push("info", "the credential did not change — this is the key itself, not a rotation");
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
1724
2352
|
for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
|
|
1725
2353
|
const wait = backoffMs(n, lastRefusal);
|
|
1726
2354
|
push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
|
|
@@ -1731,7 +2359,7 @@ stopRequested) {
|
|
|
1731
2359
|
const previous = outcome.timing;
|
|
1732
2360
|
const again = isolatedArgs(platformModelEnv());
|
|
1733
2361
|
await lease?.commit();
|
|
1734
|
-
outcome = await
|
|
2362
|
+
outcome = await runTracked(again);
|
|
1735
2363
|
outcome = withEarlierTiming(outcome, previous);
|
|
1736
2364
|
}
|
|
1737
2365
|
// The second supply, tried exactly once, and only when the primary
|
|
@@ -1754,25 +2382,70 @@ stopRequested) {
|
|
|
1754
2382
|
.filter((entry) => typeof entry[1] === "string")),
|
|
1755
2383
|
};
|
|
1756
2384
|
await lease?.commit();
|
|
1757
|
-
|
|
2385
|
+
supplyState = "exhausted";
|
|
2386
|
+
outcome = await runTracked(fallbackArgs);
|
|
1758
2387
|
// Both attempts held sandboxes; the meter owes the sum. The first
|
|
1759
2388
|
// try's pod ran, was billed for by the platform, and must not
|
|
1760
2389
|
// vanish from the record because a second try replaced its outcome.
|
|
1761
2390
|
outcome = withEarlierTiming(outcome, first);
|
|
1762
2391
|
}
|
|
2392
|
+
// Slim browsing (browser-pod.ts): the step ran on the slim image and
|
|
2393
|
+
// the account's browser pod went away under it, past the web tool's
|
|
2394
|
+
// reconnects. Reads only — run it again from the start on the full
|
|
2395
|
+
// image, where a browser in the step is always there. Anything
|
|
2396
|
+
// written — fail it, naming what, and leave retry:/on-fail:/a person
|
|
2397
|
+
// to decide; a second go could send or charge twice.
|
|
2398
|
+
step.image = outcome.image ?? null;
|
|
2399
|
+
step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
|
|
2400
|
+
if (outcome.status === "failed" && outcome.browserPod?.lost) {
|
|
2401
|
+
const decision = podLossDecision(outcome.browserPod);
|
|
2402
|
+
if (decision.rerun) {
|
|
2403
|
+
push("info", decision.note);
|
|
2404
|
+
const first = outcome;
|
|
2405
|
+
// The slim go is a try of its own on the record — image slim,
|
|
2406
|
+
// status lost, its own cost, tokens and sandbox seconds — so the
|
|
2407
|
+
// run page and `foldrun report` show two tries, slim (lost) then
|
|
2408
|
+
// full, and the step's totals are the sum of both rows
|
|
2409
|
+
// (recordAttempt). Live, 2026-10-01, the step showed only the
|
|
2410
|
+
// full re-run.
|
|
2411
|
+
step.podLostTry = {
|
|
2412
|
+
status: "lost",
|
|
2413
|
+
costUsd: repriced(catalog, wireModel, first.usage ?? null, first.costUsd, push, first.turnsCostUsd),
|
|
2414
|
+
tokens: first.usage ? { input: first.usage.inputTokens, output: first.usage.outputTokens } : null,
|
|
2415
|
+
computeSecs: first.timing ? Math.round(first.timing.totalMs) / 1000 : null,
|
|
2416
|
+
finishedAt: new Date().toISOString(),
|
|
2417
|
+
error: decision.note.slice(0, 500),
|
|
2418
|
+
image: "slim",
|
|
2419
|
+
browserPod: browserPodLine(first.browserPod),
|
|
2420
|
+
};
|
|
2421
|
+
const again = { ...lastArgs, input: { ...lastArgs.input, image: "full" }, resume: null };
|
|
2422
|
+
await lease?.commit();
|
|
2423
|
+
outcome = await runTracked(again);
|
|
2424
|
+
step.image = outcome.image ?? { variant: "full", why: "re-run after the browser pod was lost" };
|
|
2425
|
+
step.browserPod = { ...first.browserPod, fallback: RERAN_ON_FULL };
|
|
2426
|
+
}
|
|
2427
|
+
else {
|
|
2428
|
+
push("error", decision.message);
|
|
2429
|
+
step.browserPod = { ...outcome.browserPod, failure: decision.message };
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
1763
2432
|
if (lease) {
|
|
1764
2433
|
for (const line of await lease.drainLog()) {
|
|
2434
|
+
// pushWatching, not push: a line the proxy wrote may carry the
|
|
2435
|
+
// refusal mark, and that sentence is the whole reason the proxy
|
|
2436
|
+
// reads response headers at all.
|
|
1765
2437
|
if (typeof line === "string")
|
|
1766
|
-
|
|
2438
|
+
pushWatching("info", line);
|
|
1767
2439
|
else
|
|
1768
|
-
|
|
2440
|
+
pushWatching("info", line.text, { effect: line.effect });
|
|
1769
2441
|
}
|
|
1770
2442
|
await lease.release();
|
|
1771
2443
|
}
|
|
1772
2444
|
// Nothing a test run left in storage/public/ reached storage/, so
|
|
1773
2445
|
// there is nothing to publish — and a share link is an outward act.
|
|
1774
2446
|
if (!testRun)
|
|
1775
|
-
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
2447
|
+
await publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
2448
|
+
sayWhy(outcome.status);
|
|
1776
2449
|
step.status = outcome.status;
|
|
1777
2450
|
step.result = redactText(outcome.result);
|
|
1778
2451
|
step.conclusion = redactText(outcome.conclusion);
|
|
@@ -1783,7 +2456,7 @@ stopRequested) {
|
|
|
1783
2456
|
push("error", `sandbox ended: ${outcome.reason}`);
|
|
1784
2457
|
if (outcome.data !== undefined)
|
|
1785
2458
|
step.data = redactData(outcome.data);
|
|
1786
|
-
step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
|
|
2459
|
+
step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push, outcome.turnsCostUsd);
|
|
1787
2460
|
step.tokens = outcome.usage
|
|
1788
2461
|
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
1789
2462
|
: null;
|
|
@@ -1827,6 +2500,9 @@ stopRequested) {
|
|
|
1827
2500
|
}
|
|
1828
2501
|
const stepSecrets = held.env;
|
|
1829
2502
|
const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...providerEnv }, (type, text) => push(type, text));
|
|
2503
|
+
// On this machine nobody can write in and there is no proxy: the
|
|
2504
|
+
// question goes to the terminal when there is one, else unanswered.
|
|
2505
|
+
const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
|
|
1830
2506
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
1831
2507
|
// finally — once, however many attempts follow.
|
|
1832
2508
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
@@ -1835,6 +2511,7 @@ stopRequested) {
|
|
|
1835
2511
|
agentDir,
|
|
1836
2512
|
workspaceRoot,
|
|
1837
2513
|
libraryRoot: libraryDir(tenant),
|
|
2514
|
+
...(subagents.length ? { subagents } : {}),
|
|
1838
2515
|
prompt,
|
|
1839
2516
|
model,
|
|
1840
2517
|
effort,
|
|
@@ -1848,6 +2525,7 @@ stopRequested) {
|
|
|
1848
2525
|
...(searchTools.server ? { foldrun_search: searchTools.server } : {}),
|
|
1849
2526
|
...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
|
|
1850
2527
|
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
2528
|
+
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
1851
2529
|
...mcpServers,
|
|
1852
2530
|
},
|
|
1853
2531
|
// Declared secrets reach the agent's scripts as env vars; the model
|
|
@@ -1864,6 +2542,7 @@ stopRequested) {
|
|
|
1864
2542
|
output: step.output,
|
|
1865
2543
|
schema: resolvedSchema ?? undefined,
|
|
1866
2544
|
maxTurns: step.maxTurns,
|
|
2545
|
+
...limitOpts,
|
|
1867
2546
|
stopRequested,
|
|
1868
2547
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
1869
2548
|
// the proof a step left names THIS run, not one that came before —
|
|
@@ -1894,7 +2573,7 @@ stopRequested) {
|
|
|
1894
2573
|
finally {
|
|
1895
2574
|
if (t) {
|
|
1896
2575
|
for (const line of t.drainLog())
|
|
1897
|
-
|
|
2576
|
+
pushWatching("info", line.startsWith("translator") ? line : `translator: ${line}`);
|
|
1898
2577
|
await t.close();
|
|
1899
2578
|
}
|
|
1900
2579
|
if (before && runId) {
|
|
@@ -1903,7 +2582,23 @@ stopRequested) {
|
|
|
1903
2582
|
}
|
|
1904
2583
|
}
|
|
1905
2584
|
};
|
|
2585
|
+
let credentialUsed = platformModelCredential();
|
|
1906
2586
|
let outcome = await attemptThrough(providerEnv, translator);
|
|
2587
|
+
// Same rule as the isolated path: a credential that rotated under the
|
|
2588
|
+
// step is waited out and retried before anything else is considered.
|
|
2589
|
+
if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
|
|
2590
|
+
push("info", `the model credential was refused mid-step — waiting for the refreshed one`);
|
|
2591
|
+
const rotated = await awaitRotatedCredential(credentialUsed);
|
|
2592
|
+
if (rotated) {
|
|
2593
|
+
push("info", "the credential rotated under this step; retrying on the new one");
|
|
2594
|
+
credentialUsed = rotated;
|
|
2595
|
+
lastRefusal = "";
|
|
2596
|
+
outcome = await attemptThrough(providerEnv, translator);
|
|
2597
|
+
}
|
|
2598
|
+
else {
|
|
2599
|
+
push("info", "the credential did not change — this is the key itself, not a rotation");
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
1907
2602
|
// Same rule as the isolated path: a busy provider is waited out on
|
|
1908
2603
|
// the supply it refused from, before any fallback is considered.
|
|
1909
2604
|
for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
|
|
@@ -1917,10 +2612,12 @@ stopRequested) {
|
|
|
1917
2612
|
// declared or platform second supply.
|
|
1918
2613
|
if (outcome.status === "failed" && lastRefusal && secondSupply) {
|
|
1919
2614
|
push("info", `primary model supply refused (${lastRefusal.slice(0, 80)}) — retrying this step on the fallback provider`);
|
|
2615
|
+
supplyState = "exhausted";
|
|
1920
2616
|
outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
|
|
1921
2617
|
}
|
|
1922
2618
|
if (!testRun)
|
|
1923
|
-
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
2619
|
+
await publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
2620
|
+
sayWhy(outcome.status);
|
|
1924
2621
|
step.status = outcome.status;
|
|
1925
2622
|
step.result = redactText(outcome.result);
|
|
1926
2623
|
step.conclusion = redactText(outcome.conclusion);
|
|
@@ -1928,7 +2625,7 @@ stopRequested) {
|
|
|
1928
2625
|
step.data = redactData(outcome.data);
|
|
1929
2626
|
// A consult's spend belongs to the step that asked.
|
|
1930
2627
|
const consultCost = consultTools.drainCost();
|
|
1931
|
-
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
|
|
2628
|
+
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push, outcome.turnsCostUsd);
|
|
1932
2629
|
step.costUsd = stepCost === null && consultCost === 0 ? null : (stepCost ?? 0) + consultCost;
|
|
1933
2630
|
step.tokens = outcome.usage
|
|
1934
2631
|
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
@@ -1950,9 +2647,34 @@ stopRequested) {
|
|
|
1950
2647
|
catch {
|
|
1951
2648
|
// best-effort — a leftover scratch file is swept with the run's outputs
|
|
1952
2649
|
}
|
|
2650
|
+
try {
|
|
2651
|
+
// A private runtime build (see prepareRuntime) is this step's alone.
|
|
2652
|
+
disposeRuntime?.();
|
|
2653
|
+
}
|
|
2654
|
+
catch {
|
|
2655
|
+
// best-effort — it is in the tmpdir
|
|
2656
|
+
}
|
|
1953
2657
|
}
|
|
1954
2658
|
step.finishedAt = new Date().toISOString();
|
|
1955
2659
|
save();
|
|
2660
|
+
return { actedThenFailedCheck: canActOutward && actedThenFailedCheck(step.events.slice(eventsAtStart)) };
|
|
2661
|
+
}
|
|
2662
|
+
/**
|
|
2663
|
+
* Did this attempt call its tools and then fail only on the check after —
|
|
2664
|
+
* `verify:`, `output: json`, a schema? For a step whose tools act outward
|
|
2665
|
+
* that is the one failure a retry must not answer: the email went, the post
|
|
2666
|
+
* is up, and the check failing says nothing about whether they happened. A
|
|
2667
|
+
* second attempt sends them again. Read off the attempt's own events, which
|
|
2668
|
+
* both executors write the same way (step-exec.ts): every event a failed
|
|
2669
|
+
* check writes carries `check: true`. It used to read only the LAST error's
|
|
2670
|
+
* wording, and a failed shell verify or judge writes its detail after its
|
|
2671
|
+
* headline — so the step was retried and the email went twice. The wording
|
|
2672
|
+
* is still honoured, for a driver image older than the tag.
|
|
2673
|
+
*/
|
|
2674
|
+
export function actedThenFailedCheck(events) {
|
|
2675
|
+
if (!events.some((e) => e.type === "tool"))
|
|
2676
|
+
return false;
|
|
2677
|
+
return events.some((e) => e.type === "error" && (e.check === true || /^(verify `|output: json — |schema: the value does not fit)/.test(e.text)));
|
|
1956
2678
|
}
|
|
1957
2679
|
// Block until a human approves or rejects the pending steps. Polls the run
|
|
1958
2680
|
// record — the same file the dashboard writes through the approval API — so
|
|
@@ -2053,6 +2775,53 @@ function platformFallbackEnv() {
|
|
|
2053
2775
|
}
|
|
2054
2776
|
return out;
|
|
2055
2777
|
}
|
|
2778
|
+
/**
|
|
2779
|
+
* A 401 the token refresh caused, rather than a key that is simply wrong.
|
|
2780
|
+
*
|
|
2781
|
+
* The refresher on the box turns the long-lived grant into an 8h access
|
|
2782
|
+
* token every four hours, and Anthropic revokes the old one the instant the
|
|
2783
|
+
* new one is minted. The platform reads the credential per step from the
|
|
2784
|
+
* mounted file, which fixed the boot-time copy — but not this: a step
|
|
2785
|
+
* already talking to the API when the rotation lands gets a hard 401
|
|
2786
|
+
* mid-turn, and the replacement does not appear in the mounted file until
|
|
2787
|
+
* the kubelet syncs it, about a minute later.
|
|
2788
|
+
*
|
|
2789
|
+
* That window is narrow and it is not rare. Every 401 on this account in
|
|
2790
|
+
* three weeks of runs fell inside it: 02:07:22 and 02:07:24 against a 02:07
|
|
2791
|
+
* refresh, 22:08:12, 22:08:17, 22:08:30 and 22:09:03 against 22:08. The
|
|
2792
|
+
* desks that lost a run nightly were simply the ones scheduled on the hour
|
|
2793
|
+
* the refresher shares.
|
|
2794
|
+
*
|
|
2795
|
+
* So: on a 401, wait for the file to hold a DIFFERENT credential, then let
|
|
2796
|
+
* the caller retry with it. A token that never changes inside the window is
|
|
2797
|
+
* a key that is actually invalid — no waiting fixes that, and the caller
|
|
2798
|
+
* falls through to the second supply exactly as before. Moving the timer
|
|
2799
|
+
* cannot fix this; a run that takes forty minutes spans any minute chosen.
|
|
2800
|
+
*/
|
|
2801
|
+
const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
|
|
2802
|
+
const CREDENTIAL_POLL_MS = 2_000;
|
|
2803
|
+
function isAuthRefusal(text) {
|
|
2804
|
+
return /API Error: 401|access token has (been revoked|expired)/i.test(text);
|
|
2805
|
+
}
|
|
2806
|
+
/**
|
|
2807
|
+
* Wait, briefly, for the mounted credential to become one we have not
|
|
2808
|
+
* already been refused on. Returns the new value, or null when the window
|
|
2809
|
+
* passes without a change — which is the answer that means "this key is
|
|
2810
|
+
* wrong", not "this key is stale".
|
|
2811
|
+
*/
|
|
2812
|
+
export async function awaitRotatedCredential(before, now = Date.now, read = () => platformModelCredential(), waitMs = CREDENTIAL_ROTATION_WAIT_MS, pollMs = CREDENTIAL_POLL_MS) {
|
|
2813
|
+
if (!before)
|
|
2814
|
+
return null;
|
|
2815
|
+
const until = now() + waitMs;
|
|
2816
|
+
for (;;) {
|
|
2817
|
+
const current = read();
|
|
2818
|
+
if (current && current !== before)
|
|
2819
|
+
return current;
|
|
2820
|
+
if (now() >= until)
|
|
2821
|
+
return null;
|
|
2822
|
+
await sleep(pollMs);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2056
2825
|
/** A model-call failure the fallback can answer: the primary refusing over
|
|
2057
2826
|
* credit, quota or auth — never a failure of the work itself, which would
|
|
2058
2827
|
* fail identically anywhere. */
|
|
@@ -2063,7 +2832,17 @@ function isProviderRefusal(text) {
|
|
|
2063
2832
|
// fallback it could only time out on again. A refusal the fallback can
|
|
2064
2833
|
// answer is about the ACCOUNT; anything about the work or the sandbox is
|
|
2065
2834
|
// not, however similar the vocabulary.
|
|
2066
|
-
|
|
2835
|
+
// A subscription's own ceiling is the same kind of refusal, worded
|
|
2836
|
+
// differently: Claude Code on a Max plan answers "You've hit your weekly
|
|
2837
|
+
// limit · resets 5am" with no status code and none of the money words.
|
|
2838
|
+
// On 2026-09-21 every opus-tier step on the account failed on exactly that
|
|
2839
|
+
// sentence while sonnet steps beside them ran fine — the plan has a
|
|
2840
|
+
// separate weekly cap for Opus — and nothing here recognised it, so a
|
|
2841
|
+
// second supply could not have answered even if one had been configured.
|
|
2842
|
+
// It is not transient (isTransientOverload is right to ignore it: the
|
|
2843
|
+
// window is a week), it is the account's supply being exhausted, which is
|
|
2844
|
+
// precisely what the fallback provider is for.
|
|
2845
|
+
return /API Error: (401|402|403|429|5\d\d)|credits?\b|quota|billing|insufficient|overloaded|(weekly|daily|monthly|usage) limit|rate.?limit(ed)?\b.*resets?\b|limit\b.*\bresets?\b/i.test(text);
|
|
2067
2846
|
}
|
|
2068
2847
|
/** How many times a busy provider is given another moment, and the longest
|
|
2069
2848
|
* any one wait may be. Three is the industry's habit and the point where
|
|
@@ -2117,7 +2896,48 @@ export function backoffMs(attempt, text = "", random = Math.random) {
|
|
|
2117
2896
|
const base = Math.min(2 ** attempt * 1000, MAX_BACKOFF_MS);
|
|
2118
2897
|
return Math.round(base * (0.5 + random() * 0.5));
|
|
2119
2898
|
}
|
|
2120
|
-
|
|
2899
|
+
/**
|
|
2900
|
+
* The platform's model credential, read at the moment a step needs it.
|
|
2901
|
+
*
|
|
2902
|
+
* It used to come only from this process's environment, which meant the
|
|
2903
|
+
* worker held whatever token it was started with. Refreshing an OAuth token
|
|
2904
|
+
* REVOKES the old one, so a refresh without a restart left every run
|
|
2905
|
+
* answering 401 — on 2026-09-18 that took every desk down for two hours, and
|
|
2906
|
+
* nothing said so until a person read a failed run. The refresher's own
|
|
2907
|
+
* comment had named the fix years of incidents earlier: stop shipping the
|
|
2908
|
+
* token as an env var.
|
|
2909
|
+
*
|
|
2910
|
+
* So: when `FOLDRUN_MODEL_KEY_FILE` names a file (the Kubernetes secret,
|
|
2911
|
+
* mounted rather than injected), it is read fresh every time. The kubelet
|
|
2912
|
+
* updates a mounted secret in place within about a minute of it changing, so
|
|
2913
|
+
* a refreshed token reaches the next step on its own, with no restart, no
|
|
2914
|
+
* timing window, and nothing to skip while work is in flight.
|
|
2915
|
+
*
|
|
2916
|
+
* A missing or empty file falls back to the environment, which is what a
|
|
2917
|
+
* local install, the CLI and every compose setup use.
|
|
2918
|
+
*/
|
|
2919
|
+
export function platformModelCredential(env = process.env) {
|
|
2920
|
+
const file = env.FOLDRUN_MODEL_KEY_FILE;
|
|
2921
|
+
if (file) {
|
|
2922
|
+
try {
|
|
2923
|
+
const value = fs.readFileSync(file, "utf8").trim();
|
|
2924
|
+
if (value)
|
|
2925
|
+
return value;
|
|
2926
|
+
}
|
|
2927
|
+
catch {
|
|
2928
|
+
// Unreadable is not the same as absent: say so once, then fall back,
|
|
2929
|
+
// because a running platform that cannot read its credential file is a
|
|
2930
|
+
// thing an operator needs to know about before the next step fails.
|
|
2931
|
+
if (!credentialFileWarned) {
|
|
2932
|
+
credentialFileWarned = true;
|
|
2933
|
+
console.warn(`[model] FOLDRUN_MODEL_KEY_FILE=${file} could not be read; falling back to the environment`);
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
return env.CLAUDE_CODE_OAUTH_TOKEN || undefined;
|
|
2938
|
+
}
|
|
2939
|
+
let credentialFileWarned = false;
|
|
2940
|
+
export function platformModelEnv() {
|
|
2121
2941
|
const { ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN,
|
|
2122
2942
|
// What the tiers mean when the platform's own provider is not Anthropic.
|
|
2123
2943
|
// These belong to the credential: a base URL pointing at OpenRouter with
|
|
@@ -2129,7 +2949,9 @@ function platformModelEnv() {
|
|
|
2129
2949
|
ANTHROPIC_API_KEY,
|
|
2130
2950
|
ANTHROPIC_AUTH_TOKEN,
|
|
2131
2951
|
ANTHROPIC_BASE_URL,
|
|
2132
|
-
|
|
2952
|
+
// The file wins when there is one: it is the live secret, and this
|
|
2953
|
+
// process's copy is whatever it was handed at boot.
|
|
2954
|
+
CLAUDE_CODE_OAUTH_TOKEN: platformModelCredential() ?? CLAUDE_CODE_OAUTH_TOKEN,
|
|
2133
2955
|
ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
|
2134
2956
|
ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
2135
2957
|
ANTHROPIC_DEFAULT_OPUS_MODEL,
|
|
@@ -2177,21 +2999,34 @@ function jsonItems(data, step, take) {
|
|
|
2177
2999
|
function csvItems(workspaceDir, step, take) {
|
|
2178
3000
|
const raw = step.eachPath ?? "";
|
|
2179
3001
|
if (!raw) {
|
|
2180
|
-
step.skipReason = "each: rows needs a path — `each: rows of
|
|
3002
|
+
step.skipReason = "each: rows needs a path — `each: rows of workspace/storage/x.csv`";
|
|
2181
3003
|
return [];
|
|
2182
3004
|
}
|
|
2183
|
-
const
|
|
2184
|
-
if (
|
|
2185
|
-
step.skipReason = `each: rows — ${
|
|
3005
|
+
const read = csvDataRows(workspaceDir, step.agent, raw);
|
|
3006
|
+
if ("problem" in read) {
|
|
3007
|
+
step.skipReason = `each: rows — ${read.problem}`;
|
|
2186
3008
|
return [];
|
|
2187
3009
|
}
|
|
3010
|
+
return read.data.slice(0, take).map((r) => `${read.header}\n${r}`);
|
|
3011
|
+
}
|
|
3012
|
+
/**
|
|
3013
|
+
* The data rows of a CSV, path agent-relative like every path in a flow and
|
|
3014
|
+
* confined to the workspace. Shared by `each: rows of`, which fans out over
|
|
3015
|
+
* them, and `when: rows of`, which asks only whether there are any.
|
|
3016
|
+
*/
|
|
3017
|
+
function csvDataRows(workspaceDir, agent, raw) {
|
|
3018
|
+
// `workspace/storage/x.csv` from the workspace root, anything else from
|
|
3019
|
+
// the agent's folder (`../../storage/x.csv` is the same file).
|
|
3020
|
+
const resolved = resolveAgentPath(workspaceDir, path.join(workspaceDir, "agents", agent), raw);
|
|
3021
|
+
if (!resolved.startsWith(path.resolve(workspaceDir) + path.sep)) {
|
|
3022
|
+
return { problem: `${raw} is outside this workspace` };
|
|
3023
|
+
}
|
|
2188
3024
|
let text;
|
|
2189
3025
|
try {
|
|
2190
3026
|
text = fs.readFileSync(resolved, "utf8");
|
|
2191
3027
|
}
|
|
2192
3028
|
catch {
|
|
2193
|
-
|
|
2194
|
-
return [];
|
|
3029
|
+
return { problem: `${raw} does not exist (yet?)` };
|
|
2195
3030
|
}
|
|
2196
3031
|
// Minimal CSV: quoted fields with embedded commas/newlines survive.
|
|
2197
3032
|
const rows = [];
|
|
@@ -2225,23 +3060,21 @@ function csvItems(workspaceDir, step, take) {
|
|
|
2225
3060
|
if (row + field)
|
|
2226
3061
|
rows.push(row + field);
|
|
2227
3062
|
const [header, ...data] = rows.filter((r) => r.trim() !== "");
|
|
2228
|
-
if (!header || data.length === 0)
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
}
|
|
2232
|
-
return data.slice(0, take).map((r) => `${header}\n${r}`);
|
|
3063
|
+
if (!header || data.length === 0)
|
|
3064
|
+
return { problem: `${raw} has no data rows` };
|
|
3065
|
+
return { header, data };
|
|
2233
3066
|
}
|
|
2234
3067
|
/**
|
|
2235
3068
|
* `schema: <path>` — the file, read and parsed, or null. Agent-relative like
|
|
2236
|
-
* every other path in a flow (
|
|
3069
|
+
* every other path in a flow (`workspace/schemas/lead.json`), confined to the
|
|
2237
3070
|
* workspace: a schema is the author's document, never something a step
|
|
2238
3071
|
* can point outside.
|
|
2239
3072
|
*/
|
|
2240
3073
|
export function readSchemaFile(workspaceRoot, rel) {
|
|
2241
3074
|
const root = path.resolve(workspaceRoot);
|
|
2242
|
-
// Written agent-relative (`../../schemas/x.json`)
|
|
2243
|
-
// (`schemas/x.json`);
|
|
2244
|
-
const candidates = [path.resolve(root, rel.replace(/^(\.\.\/){2}/, "")), path.resolve(root, "agents", "x", rel)];
|
|
3075
|
+
// Written `workspace/schemas/x.json`, agent-relative (`../../schemas/x.json`)
|
|
3076
|
+
// or workspace-relative (`schemas/x.json`); all land on the same file.
|
|
3077
|
+
const candidates = [path.resolve(root, rel.replace(/^((\.\.\/){2}|workspace\/)/, "")), path.resolve(root, "agents", "x", rel)];
|
|
2245
3078
|
for (const abs of candidates) {
|
|
2246
3079
|
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
2247
3080
|
continue;
|
|
@@ -2293,6 +3126,7 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
|
|
|
2293
3126
|
schemaPath: s.schemaPath,
|
|
2294
3127
|
parallel: s.parallel,
|
|
2295
3128
|
maxTurns: s.maxTurns,
|
|
3129
|
+
limits: s.limits,
|
|
2296
3130
|
attempts: 0,
|
|
2297
3131
|
status: "pending",
|
|
2298
3132
|
events: [],
|
|
@@ -2427,6 +3261,19 @@ export function driveRun(tenant, workspace, run, modelOverride, tags = [], opts
|
|
|
2427
3261
|
}
|
|
2428
3262
|
function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
2429
3263
|
const effortOverride = opts.effortOverride ?? null;
|
|
3264
|
+
// The flow's own calendar. Read from the flow file rather than passed in,
|
|
3265
|
+
// so every door into a run — the queue, an eval, the CLI, a rerun, a
|
|
3266
|
+
// reconcile that picks an abandoned run back up — gets the same clock
|
|
3267
|
+
// without each caller having to remember it. A run that belongs to no
|
|
3268
|
+
// flow (an ad-hoc agent run) finds nothing here and cascades from the
|
|
3269
|
+
// agent to the workspace, which is what it should do.
|
|
3270
|
+
let flowTimezone = null;
|
|
3271
|
+
try {
|
|
3272
|
+
flowTimezone = readFlow(tenant, workspace, run.flow)?.timezone ?? null;
|
|
3273
|
+
}
|
|
3274
|
+
catch {
|
|
3275
|
+
// an unreadable flow file is reported where it is parsed for real
|
|
3276
|
+
}
|
|
2430
3277
|
const pDir = workspaceDir(tenant, workspace);
|
|
2431
3278
|
const runRoot = pDir;
|
|
2432
3279
|
/**
|
|
@@ -2860,6 +3707,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2860
3707
|
for (const step of freshGroup) {
|
|
2861
3708
|
if (step.status !== "pending" || !step.when)
|
|
2862
3709
|
continue;
|
|
3710
|
+
// `when: rows of <csv>` asks the data, not the prose: run only if
|
|
3711
|
+
// the file has a data row. It is the condition a fan-out's
|
|
3712
|
+
// followers need — an `each:` over an empty file skips itself but
|
|
3713
|
+
// leaves nothing for a marker to be read from, so the steps after
|
|
3714
|
+
// it used to run on nothing.
|
|
3715
|
+
const rowsOf = whenRowsPath(step.when);
|
|
3716
|
+
if (rowsOf !== null) {
|
|
3717
|
+
const read = csvDataRows(pDir, step.agent, rowsOf);
|
|
3718
|
+
if ("problem" in read) {
|
|
3719
|
+
step.status = "skipped";
|
|
3720
|
+
step.skipReason = `condition not met: when rows — ${read.problem}`;
|
|
3721
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: `skipped — ${read.problem}` });
|
|
3722
|
+
}
|
|
3723
|
+
continue;
|
|
3724
|
+
}
|
|
2863
3725
|
// A marker at the start of a line, not a word anywhere in the
|
|
2864
3726
|
// prose — see markerPresent. "There are no BLOCKED items" must
|
|
2865
3727
|
// not open a gate keyed on BLOCKED.
|
|
@@ -3028,13 +3890,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3028
3890
|
// An agent's own `budget:` — the most it may spend in one run — is
|
|
3029
3891
|
// read at launch, so a cap raised mid-run applies to the next step.
|
|
3030
3892
|
const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
|
|
3893
|
+
// The ceiling each attempt now running was given. A step records its
|
|
3894
|
+
// cost only when it ends, so while it runs its ceiling stands in for
|
|
3895
|
+
// what it may yet spend — otherwise every fan-out copy of one agent
|
|
3896
|
+
// saw none of its siblings' spend and got the agent's whole budget.
|
|
3897
|
+
const reserved = new Map();
|
|
3031
3898
|
// The ceiling for one attempt of one step, read NOW: what is left of
|
|
3032
3899
|
// the run's budget shared across the group, or the agent's own cap
|
|
3033
|
-
// less what its steps have cost
|
|
3034
|
-
//
|
|
3035
|
-
//
|
|
3036
|
-
//
|
|
3037
|
-
|
|
3900
|
+
// less what its steps have cost or hold in flight, shared with its
|
|
3901
|
+
// steps in this group still waiting to start. Computed before every
|
|
3902
|
+
// attempt, not once before the loop — a retry's share is what is left
|
|
3903
|
+
// after the failed attempt spent, and the step's own figure already
|
|
3904
|
+
// carries that spend (it is the sum of its tries).
|
|
3905
|
+
const ceilingFor = (step) => stepCeilingFor(stepCeiling(run.budgetUsd, platform.runSpend(run), launching.length), agentBudgets.get(step.agent), run.steps
|
|
3906
|
+
.filter((s) => s.agent === step.agent)
|
|
3907
|
+
.reduce((sum, s) => sum + (s.costUsd ?? 0) + (s !== step && s.status === "running" ? (reserved.get(s) ?? 0) : 0), 0), step.agent, 1 + launching.filter((s) => s !== step && s.agent === step.agent && s.status === "pending" && !reserved.has(s)).length);
|
|
3038
3908
|
// parallel: — the instances of one fan-out template share a pool.
|
|
3039
3909
|
// Keyed by what the instances share (agent + instruction), since
|
|
3040
3910
|
// the template itself is behind them in the record as "expanded".
|
|
@@ -3094,25 +3964,57 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3094
3964
|
const eventsBefore = step.events.length;
|
|
3095
3965
|
step.attempts = attempt;
|
|
3096
3966
|
step.status = "running";
|
|
3967
|
+
if (ceiling.ceilingUsd !== null)
|
|
3968
|
+
reserved.set(step, ceiling.ceilingUsd);
|
|
3097
3969
|
save();
|
|
3098
|
-
await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceiling.ceilingUsd, ceiling.note, run.test === true,
|
|
3970
|
+
const verdict = await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceiling.ceilingUsd, ceiling.note, run.test === true,
|
|
3099
3971
|
// save() merges a stop written by another process into
|
|
3100
3972
|
// this record; the model loop polls this on a clock.
|
|
3101
3973
|
() => {
|
|
3102
3974
|
save();
|
|
3103
3975
|
return run.stopRequested === true;
|
|
3104
|
-
});
|
|
3976
|
+
}, flowTimezone, run.steps.indexOf(step)).finally(() => reserved.delete(step));
|
|
3105
3977
|
// runStep mutates step.status; read it through a widened local
|
|
3106
3978
|
// so TS doesn't keep the "running" narrowing from above.
|
|
3107
3979
|
const outcome = step.status;
|
|
3108
3980
|
recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
|
|
3109
3981
|
save();
|
|
3982
|
+
// A stop destroys the step's sandbox, which reads here as a
|
|
3983
|
+
// failed attempt. stopRun writes "stopped by a person" onto
|
|
3984
|
+
// this step in the record, and the save() above writes this
|
|
3985
|
+
// process's copy over it — so the trail ended on the
|
|
3986
|
+
// sandbox's own last words, "sandbox ended: Error (exit 143)",
|
|
3987
|
+
// and a person's stop read as the platform killing a healthy
|
|
3988
|
+
// step (abandon-desk, 2026-09-29: stopped from the CLI after
|
|
3989
|
+
// ten minutes, then chased as an unexplained kill). Say so,
|
|
3990
|
+
// whether or not a retry was left.
|
|
3991
|
+
if (outcome === "failed" && run.stopRequested) {
|
|
3992
|
+
step.events.push({
|
|
3993
|
+
t: new Date().toISOString(),
|
|
3994
|
+
type: "info",
|
|
3995
|
+
text: "stopped by a person mid-step — its sandbox was destroyed, which is what ended this attempt; not retried",
|
|
3996
|
+
});
|
|
3997
|
+
save();
|
|
3998
|
+
break;
|
|
3999
|
+
}
|
|
3110
4000
|
if (outcome !== "failed" || attempt === attempts)
|
|
3111
4001
|
break;
|
|
3112
|
-
//
|
|
3113
|
-
//
|
|
3114
|
-
//
|
|
3115
|
-
|
|
4002
|
+
// An outward step whose tools ran and whose check then failed
|
|
4003
|
+
// is not retried: the check cannot say the send did not
|
|
4004
|
+
// happen, and a retry would do it twice.
|
|
4005
|
+
if (verdict?.actedThenFailedCheck) {
|
|
4006
|
+
step.events.push({
|
|
4007
|
+
t: new Date().toISOString(),
|
|
4008
|
+
type: "info",
|
|
4009
|
+
text: `not retried — this step's tools can act outside the workspace and ran before the check failed; another attempt could repeat what they did (${attempts - attempt} left unused)`,
|
|
4010
|
+
});
|
|
4011
|
+
save();
|
|
4012
|
+
break;
|
|
4013
|
+
}
|
|
4014
|
+
// A failed attempt used to be retried in a fresh sandbox even
|
|
4015
|
+
// after a stop, so a stopped `retry: 2` step ran twice more.
|
|
4016
|
+
// The check above ends the loop on a stop; this one covers a
|
|
4017
|
+
// stop that lands while the outward check was being written.
|
|
3116
4018
|
save();
|
|
3117
4019
|
if (run.stopRequested)
|
|
3118
4020
|
break;
|
|
@@ -3206,7 +4108,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3206
4108
|
rescue.attempts = 1;
|
|
3207
4109
|
rescue.status = "running";
|
|
3208
4110
|
save();
|
|
3209
|
-
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true);
|
|
4111
|
+
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, undefined, flowTimezone, run.steps.indexOf(rescue));
|
|
3210
4112
|
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
3211
4113
|
const rescueOutcome = rescue.status;
|
|
3212
4114
|
if (rescueOutcome === "completed") {
|
|
@@ -3368,6 +4270,9 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3368
4270
|
// learn, and the runs list and the notification stop having to say
|
|
3369
4271
|
// only "completed".
|
|
3370
4272
|
run.summary = runSummary(run);
|
|
4273
|
+
// Read before a test run's prefix goes on the summary, which would
|
|
4274
|
+
// otherwise hide the word it leads with.
|
|
4275
|
+
run.verdict = verdictOf(run.summary);
|
|
3371
4276
|
// A test run's headline says so wherever the headline goes — the
|
|
3372
4277
|
// runs list, the history tool, a later run's recall.
|
|
3373
4278
|
if (run.test && run.summary)
|