@foldrun/core 0.2.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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
|
@@ -0,0 +1,3215 @@
|
|
|
1
|
+
// Flow orchestration. Steps sharing a group number run in parallel; the next
|
|
2
|
+
// group receives every prior result, labeled per agent. Optional steps ("2?")
|
|
3
|
+
// fail without failing the flow. A direct agent run is a one-step ad-hoc
|
|
4
|
+
// flow, so there is a single execution path.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import matter from "gray-matter";
|
|
8
|
+
import { executeStep, extractJson, stepCeiling } from "./step-exec.js";
|
|
9
|
+
import { eventUrl } from "./webhook.js";
|
|
10
|
+
import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
|
|
11
|
+
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
|
|
12
|
+
import { platform } from "./platform.js";
|
|
13
|
+
/** Does this process run steps in a sandbox — the container core ships, or
|
|
14
|
+
* one the platform registered (a pod)? */
|
|
15
|
+
const isolatedRun = () => {
|
|
16
|
+
const mode = process.env.FOLDRUN_RUN_ISOLATION;
|
|
17
|
+
return mode === "container" || (!!mode && mode in platform.isolation);
|
|
18
|
+
};
|
|
19
|
+
import { gatherConsults, buildConsultTools } from "./agent-tools.js";
|
|
20
|
+
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError } from "./tool-names.js";
|
|
21
|
+
import { refNames } from "./refs.js";
|
|
22
|
+
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, parseFlow, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, STORAGE_DIR, adoptLegacyVersionKey, runCost, } from "./store.js";
|
|
23
|
+
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
|
|
24
|
+
import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
|
|
25
|
+
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
26
|
+
import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
|
|
27
|
+
import { attachOperations, prefetchOpenApi } from "./openapi.js";
|
|
28
|
+
import { buildSearchTools, buildHistoryTools, buildDeskTools, digestRuns } from "./context-tools.js";
|
|
29
|
+
import { startTranslator, translatorSpecFor } from "./translator.js";
|
|
30
|
+
import { providerPreset } from "./providers.js";
|
|
31
|
+
import { buildScriptTools, parseScripts } from "./script-tools.js";
|
|
32
|
+
import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.js";
|
|
33
|
+
import { mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
|
|
34
|
+
import { materializeFiles, harvestFiles } from "./storage.js";
|
|
35
|
+
import { chooseExecutor, ensureImage } from "./container.js";
|
|
36
|
+
import { stampBundle } from "./okf.js";
|
|
37
|
+
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
38
|
+
// in env and headers resolve server-side, so a credential reaches the server
|
|
39
|
+
// process without ever entering the model's context.
|
|
40
|
+
function mcpConfig(spec, tenant, workspace) {
|
|
41
|
+
const fill = (values) => {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const [k, v] of Object.entries(values)) {
|
|
44
|
+
out[k] = v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, secretName) => {
|
|
45
|
+
const hit = getSecret(tenant, secretName, workspace);
|
|
46
|
+
return hit ? hit.value : whole;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
};
|
|
51
|
+
// alwaysLoad: the SDK defers MCP tools behind tool search by default, which
|
|
52
|
+
// hides them from an agent whose toolset we've already narrowed. A server
|
|
53
|
+
// the author explicitly granted should be present from turn one.
|
|
54
|
+
return spec.url
|
|
55
|
+
? { type: "http", url: spec.url, headers: fill(spec.headers), alwaysLoad: true }
|
|
56
|
+
: {
|
|
57
|
+
type: "stdio",
|
|
58
|
+
command: spec.command,
|
|
59
|
+
args: spec.args,
|
|
60
|
+
env: fill(spec.env),
|
|
61
|
+
alwaysLoad: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** How much of the earlier groups' output a step is handed, in characters.
|
|
65
|
+
* Newest groups are kept when the total is over it. */
|
|
66
|
+
export const CONTEXT_CHARS = 30_000;
|
|
67
|
+
/** tools: [desks] — how many other-workspace runs cross into a step, in all. */
|
|
68
|
+
export const DESK_RUNS_CAP = 100;
|
|
69
|
+
/**
|
|
70
|
+
* What a group is handed from the groups before it: all of them, oldest
|
|
71
|
+
* first, so a group-3 reporter sees what group 1 measured and not only what
|
|
72
|
+
* group 2 diagnosed. When the total is over the cap, the oldest groups are
|
|
73
|
+
* dropped whole before anything is cut mid-text — the newest is the one the
|
|
74
|
+
* step is most likely to be about.
|
|
75
|
+
*/
|
|
76
|
+
export function joinEarlierGroups(groupResults, gi, cap = CONTEXT_CHARS) {
|
|
77
|
+
const parts = [];
|
|
78
|
+
for (let i = 0; i < gi; i++)
|
|
79
|
+
if (groupResults[i])
|
|
80
|
+
parts.push(groupResults[i]);
|
|
81
|
+
if (parts.length === 0)
|
|
82
|
+
return null;
|
|
83
|
+
while (parts.length > 1 && parts.reduce((n, t) => n + t.length + 2, 0) > cap)
|
|
84
|
+
parts.shift();
|
|
85
|
+
return parts.join("\n\n");
|
|
86
|
+
}
|
|
87
|
+
const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" && v.trim() ? [v.trim()] : [];
|
|
88
|
+
// gray-matter throws on invalid YAML. Skills authored for another client
|
|
89
|
+
// sometimes ship YAML that that client's parser happened to accept — most
|
|
90
|
+
// often an unquoted value containing a colon (`description: Use when: ...`).
|
|
91
|
+
// The Agent Skills client guide recommends quoting such values and retrying
|
|
92
|
+
// rather than dropping the skill, so a cross-client skill still loads here.
|
|
93
|
+
function safeMatter(raw) {
|
|
94
|
+
// gray-matter caches by input string AND caches a *failed* parse: the first
|
|
95
|
+
// call on invalid YAML throws, but every later call on the same bytes returns
|
|
96
|
+
// a hollow object with the fields silently missing. Clearing the cache makes
|
|
97
|
+
// the throw deterministic, so the fallback below fires every time rather than
|
|
98
|
+
// once — otherwise the same skill loads on one run and vanishes on the next.
|
|
99
|
+
matter.clearCache();
|
|
100
|
+
try {
|
|
101
|
+
return matter(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
105
|
+
if (fm) {
|
|
106
|
+
const fixed = fm[1].replace(/^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm, (_m, k, v) => `${k}: ${JSON.stringify(v)}`);
|
|
107
|
+
try {
|
|
108
|
+
// A function replacer, not a string: a fixed block containing `$&` or
|
|
109
|
+
// `$$` (a value with those in it) would otherwise be treated as a
|
|
110
|
+
// replacement pattern and splice the whole frontmatter into itself.
|
|
111
|
+
return matter(raw.replace(fm[1], () => fixed));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* fall through */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { data: {} };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
|
|
121
|
+
// <root>/<name>.md convenience form. `subdir` is the skills root under the
|
|
122
|
+
// scanned directory — "skills" (foldrun-native) or ".agents/skills" (the
|
|
123
|
+
// cross-client convention other skills-compatible tools read and write).
|
|
124
|
+
export function discoverSkills(agentDir, subdir = "skills") {
|
|
125
|
+
const skillsDir = path.join(agentDir, subdir);
|
|
126
|
+
if (!fs.existsSync(skillsDir))
|
|
127
|
+
return [];
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const entry of fs.readdirSync(skillsDir).sort()) {
|
|
130
|
+
const full = path.join(skillsDir, entry);
|
|
131
|
+
const stat = fs.statSync(full);
|
|
132
|
+
if (stat.isDirectory()) {
|
|
133
|
+
const skillMd = path.join(full, "SKILL.md");
|
|
134
|
+
if (!fs.existsSync(skillMd))
|
|
135
|
+
continue;
|
|
136
|
+
const { data } = safeMatter(fs.readFileSync(skillMd, "utf8"));
|
|
137
|
+
const description = typeof data.description === "string" ? data.description.trim() : "";
|
|
138
|
+
// The spec makes description essential — it is the only thing the model
|
|
139
|
+
// sees before activating the skill. Without one there is nothing to
|
|
140
|
+
// disclose, so the skill is skipped rather than offered blind. `check`
|
|
141
|
+
// reports it; here it is simply not loaded.
|
|
142
|
+
if (!description)
|
|
143
|
+
continue;
|
|
144
|
+
out.push({
|
|
145
|
+
name: typeof data.name === "string" && data.name ? data.name : entry,
|
|
146
|
+
description,
|
|
147
|
+
when: toTags(data.when),
|
|
148
|
+
path: `${subdir}/${entry}/SKILL.md`,
|
|
149
|
+
dir: `${subdir}/${entry}`,
|
|
150
|
+
hasScripts: fs.existsSync(path.join(full, "scripts")),
|
|
151
|
+
hasReferences: fs.existsSync(path.join(full, "references")),
|
|
152
|
+
hasAssets: fs.existsSync(path.join(full, "assets")),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
else if (entry.endsWith(".md")) {
|
|
156
|
+
const { data } = safeMatter(fs.readFileSync(full, "utf8"));
|
|
157
|
+
const description = typeof data.description === "string" ? data.description.trim() : "";
|
|
158
|
+
if (!description)
|
|
159
|
+
continue;
|
|
160
|
+
out.push({
|
|
161
|
+
name: typeof data.name === "string" && data.name ? data.name : entry.replace(/\.md$/, ""),
|
|
162
|
+
description,
|
|
163
|
+
when: toTags(data.when),
|
|
164
|
+
path: `${subdir}/${entry}`,
|
|
165
|
+
dir: null,
|
|
166
|
+
hasScripts: false,
|
|
167
|
+
hasReferences: false,
|
|
168
|
+
hasAssets: false,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* AGENTS.md, at both scopes it can live at.
|
|
176
|
+
*
|
|
177
|
+
* `AGENTS.md` is the Linux Foundation name; `project.md` is still read so
|
|
178
|
+
* nothing written before the rename breaks.
|
|
179
|
+
*
|
|
180
|
+
* Returns null rather than throwing on unparseable YAML: a broken shared file
|
|
181
|
+
* must not take down every agent under it.
|
|
182
|
+
*/
|
|
183
|
+
export function readAgentsMd(dir) {
|
|
184
|
+
// Convert a pre-rename `mdagent_version:` before parsing. One name in the
|
|
185
|
+
// code, one on disk — the reader no longer knows two spellings, so a file
|
|
186
|
+
// written before the rename is migrated here rather than tolerated forever.
|
|
187
|
+
adoptLegacyVersionKey(dir);
|
|
188
|
+
for (const name of ["AGENTS.md", "project.md"]) {
|
|
189
|
+
const file = path.join(dir, name);
|
|
190
|
+
if (!fs.existsSync(file))
|
|
191
|
+
continue;
|
|
192
|
+
try {
|
|
193
|
+
const parsed = matter(fs.readFileSync(file, "utf8"));
|
|
194
|
+
return { data: parsed.data, body: parsed.content.trim() };
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const workspaceRootOf = (agentDir) => path.join(agentDir, "..", "..");
|
|
203
|
+
/**
|
|
204
|
+
* Configuration from AGENTS.md, nearest-wins: the workspace's overrides the
|
|
205
|
+
* account's, key by key.
|
|
206
|
+
*
|
|
207
|
+
* Config overrides and prose accumulates — see sharedInstructions. A workspace
|
|
208
|
+
* naming its own `provider:` means *instead of* the account's; a workspace
|
|
209
|
+
* writing instructions means *as well as*, because an account rule nobody can
|
|
210
|
+
* silently drop is the point of having one.
|
|
211
|
+
*/
|
|
212
|
+
function workspaceFrontmatter(agentDir, tenant) {
|
|
213
|
+
return {
|
|
214
|
+
...(readAgentsMd(accountDir(tenant))?.data ?? {}),
|
|
215
|
+
...(readAgentsMd(workspaceRootOf(agentDir))?.data ?? {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The calendar an agent works to. `timezone:` in AGENTS.md, nearest-wins like
|
|
220
|
+
* every other key there; a flow's cron already means its own timezone, this
|
|
221
|
+
* makes "today" mean it too. Unset is UTC — what the pods run — which for a
|
|
222
|
+
* Sydney desk made an article dated today "one day in the future" until 10am.
|
|
223
|
+
* An unknown name falls back to UTC rather than failing every step.
|
|
224
|
+
*/
|
|
225
|
+
export function resolveTimezone(front) {
|
|
226
|
+
const tz = typeof front.timezone === "string" ? front.timezone.trim() : "";
|
|
227
|
+
const chosen = tz || process.env.FOLDRUN_TIMEZONE?.trim() || "UTC";
|
|
228
|
+
try {
|
|
229
|
+
new Intl.DateTimeFormat("en-CA", { timeZone: chosen });
|
|
230
|
+
return chosen;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return "UTC";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** YYYY-MM-DD in a timezone — the date the workspace believes it is. */
|
|
237
|
+
export function localDate(timeZone, now = new Date()) {
|
|
238
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
239
|
+
timeZone,
|
|
240
|
+
year: "numeric",
|
|
241
|
+
month: "2-digit",
|
|
242
|
+
day: "2-digit",
|
|
243
|
+
}).format(now);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The prose from AGENTS.md, outermost first.
|
|
247
|
+
*
|
|
248
|
+
* This was written by every scaffold, shown in the dashboard, described in the
|
|
249
|
+
* template as "context every agent here shares" — and never read. Only the
|
|
250
|
+
* frontmatter was, so the instructions people actually wrote reached no model.
|
|
251
|
+
*/
|
|
252
|
+
export function sharedInstructions(agentDir, tenant) {
|
|
253
|
+
const sections = [
|
|
254
|
+
["Everyone in this account", readAgentsMd(accountDir(tenant))?.body],
|
|
255
|
+
["Everyone in this workspace", readAgentsMd(workspaceRootOf(agentDir))?.body],
|
|
256
|
+
].filter(([, body]) => body);
|
|
257
|
+
if (!sections.length)
|
|
258
|
+
return null;
|
|
259
|
+
return (`# Shared context\n\nThis applies to you in addition to your own instructions ` +
|
|
260
|
+
`above. Where it is more specific than they are, it is not optional.\n\n` +
|
|
261
|
+
sections.map(([label, body]) => `## ${label}\n\n${body}`).join("\n\n"));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Which of an agent's in-scope skills load for this run.
|
|
265
|
+
*
|
|
266
|
+
* Two independent gates: `skills:` in the agent's frontmatter is an allowlist
|
|
267
|
+
* (absent means everything in scope), and a skill's own `when:` names the run
|
|
268
|
+
* tags it is for (absent means every run).
|
|
269
|
+
*
|
|
270
|
+
* Note what an empty `tags` means — a skill with `when:` matches nothing, so
|
|
271
|
+
* an untagged run gets none of them. That is the intent, and it is also why
|
|
272
|
+
* failing to thread the run's tags this far made every `when:` skill
|
|
273
|
+
* permanently invisible instead of conditionally loaded.
|
|
274
|
+
*/
|
|
275
|
+
export function applicableSkills(skills, only, tags) {
|
|
276
|
+
return skills
|
|
277
|
+
.filter((s) => only.length === 0 || only.includes(s.name))
|
|
278
|
+
.filter((s) => s.when.length === 0 || s.when.some((t) => tags.includes(t)));
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* [[name]] as a reference to a document or file — the Obsidian instinct,
|
|
282
|
+
* honoured where it is safe. A prompt is text the model acts on, and a
|
|
283
|
+
* model cannot open "[[sources-of-conveyancers]]"; it can open
|
|
284
|
+
* `../../knowledge/sources-of-conveyancers.md`. So links resolve to real
|
|
285
|
+
* agent-relative paths BEFORE the prompt is built, matching by filename or
|
|
286
|
+
* OKF title, hyphen/underscore/case-blind. Anything unmatched — agent
|
|
287
|
+
* names, flow links, prose in brackets — passes through untouched: this is
|
|
288
|
+
* sugar over paths, never a gate in front of them.
|
|
289
|
+
*/
|
|
290
|
+
/**
|
|
291
|
+
* Whether an evaluator's reply actually carries its `until:` marker.
|
|
292
|
+
*
|
|
293
|
+
* Not a substring test. A reviewer that writes "APPROVED once the tribunal
|
|
294
|
+
* sentence is corrected" contains the word, and a substring match reads that
|
|
295
|
+
* conditional as a pass — the loop ends, the correction is never made, and the
|
|
296
|
+
* step after it inherits the problem the loop existed to catch. That happened:
|
|
297
|
+
* a factual error rode a conditional approval past the gate.
|
|
298
|
+
*
|
|
299
|
+
* So the marker has to stand alone on its own line, which is a thing a model
|
|
300
|
+
* does deliberately and cannot do by accident mid-sentence. Trailing
|
|
301
|
+
* punctuation is allowed ("APPROVED." is still a decision), and the line may
|
|
302
|
+
* sit anywhere in the reply, so an approval followed by notes still passes —
|
|
303
|
+
* the point is that the word is a switch, not a clause.
|
|
304
|
+
*/
|
|
305
|
+
export function saysUntilMarker(result, until) {
|
|
306
|
+
const marker = until.trim().toLowerCase();
|
|
307
|
+
if (!marker)
|
|
308
|
+
return true;
|
|
309
|
+
return (result ?? "")
|
|
310
|
+
.split(/\r?\n/)
|
|
311
|
+
.some((line) =>
|
|
312
|
+
// Strip markdown emphasis and punctuation from BOTH ends: "**APPROVED.**"
|
|
313
|
+
// is a decision written by someone using bold, not a sentence.
|
|
314
|
+
line
|
|
315
|
+
.trim()
|
|
316
|
+
.replace(/^[#>\-*_`\s]+/, "")
|
|
317
|
+
.replace(/[.!:,*_`\s]+$/, "")
|
|
318
|
+
.toLowerCase() === marker);
|
|
319
|
+
}
|
|
320
|
+
export function resolveDocLinks(text, workspaceRoot) {
|
|
321
|
+
if (!text.includes("[["))
|
|
322
|
+
return text;
|
|
323
|
+
const map = new Map();
|
|
324
|
+
const norm = (v) => v.toLowerCase().replace(/\.md$/, "").replace(/\/+$/, "").replace(/[^a-z0-9/]+/g, "-");
|
|
325
|
+
// Folders resolve too: a reference to "the file store" is as real as a
|
|
326
|
+
// reference to one file in it, and an agent told to read whatever its
|
|
327
|
+
// instruction names needs to say WHERE without naming a file. Added
|
|
328
|
+
// first, so a document that happens to share a folder's name still wins.
|
|
329
|
+
for (const dir of [STORAGE_DIR, "state", "knowledge", "memory", "skills", "outputs"]) {
|
|
330
|
+
if (fs.existsSync(path.join(workspaceRoot, dir)))
|
|
331
|
+
map.set(norm(dir), `../../${dir}/`);
|
|
332
|
+
}
|
|
333
|
+
for (const kind of ["knowledge", "memory"]) {
|
|
334
|
+
const dir = path.join(workspaceRoot, kind);
|
|
335
|
+
if (!fs.existsSync(dir))
|
|
336
|
+
continue;
|
|
337
|
+
for (const entry of fs.readdirSync(dir, { recursive: true }).map(String)) {
|
|
338
|
+
if (!entry.endsWith(".md"))
|
|
339
|
+
continue;
|
|
340
|
+
const rel = `../../${kind}/${entry.split(path.sep).join("/")}`;
|
|
341
|
+
map.set(norm(entry), rel);
|
|
342
|
+
map.set(norm(`${kind}/${entry}`), rel);
|
|
343
|
+
try {
|
|
344
|
+
const data = matter(fs.readFileSync(path.join(dir, entry), "utf8")).data;
|
|
345
|
+
// `title:` is OKF v0.2's word; `name:` is the older spelling still
|
|
346
|
+
// read, never written. A link should match what the file says it
|
|
347
|
+
// is, in either era's vocabulary.
|
|
348
|
+
for (const key of ["title", "name"]) {
|
|
349
|
+
const v = data?.[key];
|
|
350
|
+
if (typeof v === "string" && v)
|
|
351
|
+
map.set(norm(v), rel);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// unparseable frontmatter still resolves by filename
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// state/ — the cursors, snapshots and histories a scheduled desk reads from
|
|
360
|
+
// itself every run. Individual files here were not linkable for a long time
|
|
361
|
+
// while the folder was, so the one directory a recurring flow reads MOST was
|
|
362
|
+
// the one that could only be reached by typing `../../state/x.md` by hand.
|
|
363
|
+
// Indexed by path like storage/ rather than by frontmatter like knowledge/:
|
|
364
|
+
// these are data files, often .json or .csv, and many carry no frontmatter.
|
|
365
|
+
const stateDir = path.join(workspaceRoot, "state");
|
|
366
|
+
if (fs.existsSync(stateDir)) {
|
|
367
|
+
for (const entry of fs.readdirSync(stateDir, { recursive: true }).map(String)) {
|
|
368
|
+
try {
|
|
369
|
+
if (!fs.statSync(path.join(stateDir, entry)).isFile())
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const fwd = entry.split(path.sep).join("/");
|
|
376
|
+
const rel = `../../state/${fwd}`;
|
|
377
|
+
map.set(norm(`state/${fwd}`), rel);
|
|
378
|
+
map.set(norm(fwd), rel);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
// The hosted store's index lives at <tenant>/storage/<workspace>/ — beside
|
|
383
|
+
// the workspaces, not inside one. workspaceRoot is <tenant>/…/<ws>, so
|
|
384
|
+
// up two and across. Single-workspace installs have no index and fall
|
|
385
|
+
// through to the plain-directory walk below.
|
|
386
|
+
const index = JSON.parse(fs.readFileSync(path.join(workspaceRoot, "..", "..", STORAGE_DIR, path.basename(workspaceRoot), "index.json"), "utf8"));
|
|
387
|
+
for (const f of index.files ?? [])
|
|
388
|
+
map.set(norm(`${STORAGE_DIR}/${f.path}`), `../../${STORAGE_DIR}/${f.path}`);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// no file store index is normal
|
|
392
|
+
}
|
|
393
|
+
// Plain files on disk under storage/ too — local installs write there directly.
|
|
394
|
+
const filesDir = path.join(workspaceRoot, STORAGE_DIR);
|
|
395
|
+
if (fs.existsSync(filesDir)) {
|
|
396
|
+
for (const entry of fs.readdirSync(filesDir, { recursive: true }).map(String)) {
|
|
397
|
+
try {
|
|
398
|
+
if (!fs.statSync(path.join(filesDir, entry)).isFile())
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const fwd = entry.split(path.sep).join("/");
|
|
405
|
+
if (fwd.startsWith(".store/"))
|
|
406
|
+
continue;
|
|
407
|
+
map.set(norm(`${STORAGE_DIR}/${fwd}`), `../../${STORAGE_DIR}/${fwd}`);
|
|
408
|
+
map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return text.replace(/\[\[([^\]\n]+)\]\]/g, (whole, name) => {
|
|
412
|
+
const hit = map.get(norm(name.trim()));
|
|
413
|
+
return hit ? `\`${hit}\`` : whole;
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
function agentContext(agentDir, tenant, tags = [],
|
|
417
|
+
/** Who is running, so scripts can stamp what they write with its provenance. */
|
|
418
|
+
identity = {}) {
|
|
419
|
+
// agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
|
|
420
|
+
// workspace a secret should resolve against is two levels up.
|
|
421
|
+
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
422
|
+
const { data: front, content: body } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
|
|
423
|
+
const parts = [resolveDocLinks(body.trim(), path.resolve(agentDir, "..", ".."))];
|
|
424
|
+
// Where the agent is standing. Every path below this line is relative to the
|
|
425
|
+
// agent's own directory, and a run showed why that has to be said rather
|
|
426
|
+
// than implied: told to read `../../knowledge/index.md`, the model asked for
|
|
427
|
+
// `/knowledge/index.md` twice before working it out. Two denials and a turn,
|
|
428
|
+
// for want of one sentence.
|
|
429
|
+
parts.push(`# Where you are\n\n` +
|
|
430
|
+
`Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
|
|
431
|
+
`\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
|
|
432
|
+
`\`outputs/\` is yours, and \`../../\` is the workspace root, so the workspace's ` +
|
|
433
|
+
`own knowledge is at \`../../knowledge/\`. Absolute paths are outside the ` +
|
|
434
|
+
`workspace and will be refused.\n\n` +
|
|
435
|
+
// A flow's whole point is that a later step works on what an earlier one
|
|
436
|
+
// produced, and each agent writes to its own outputs/. A run showed what
|
|
437
|
+
// omitting this costs: the checker looked in its own empty outputs/ and
|
|
438
|
+
// reported the draft missing — reviewing nothing, and saying so
|
|
439
|
+
// confidently — while the publisher only found it by guessing at a glob.
|
|
440
|
+
`Each agent writes to its own \`outputs/\`, so a file an earlier step produced is ` +
|
|
441
|
+
`at \`../<that-agent>/outputs/\`, not in yours. If a previous step says it wrote ` +
|
|
442
|
+
`something, look there before concluding it does not exist.\n\n` +
|
|
443
|
+
// The distinction agents kept getting wrong, said where they can read
|
|
444
|
+
// it: outputs/ is working text between steps and never leaves the
|
|
445
|
+
// source tree; storage/ is the store the dashboard's Storage page lists.
|
|
446
|
+
// A 100-row CSV written to outputs/ is delivered nowhere — a person
|
|
447
|
+
// looking for it on the Storage page finds it empty and concludes the run
|
|
448
|
+
// produced nothing.
|
|
449
|
+
// The path here MUST be the one harvestFiles reads. It said
|
|
450
|
+
// `../../files/` after the rename, and nothing recovered from that:
|
|
451
|
+
// materializeFiles always creates `storage/` before the step, so
|
|
452
|
+
// adoptLegacyFilesDir's "move files/ to storage/" is a no-op by the time
|
|
453
|
+
// the run ends — a deliverable written where this text pointed was
|
|
454
|
+
// harvested from nowhere and silently never appeared.
|
|
455
|
+
`\`../../storage/\` is the workspace file store: anything you leave there is kept ` +
|
|
456
|
+
`after the run and shown on the Storage page for people to download. Write ` +
|
|
457
|
+
`deliverables people asked for — CSVs, reports, PDFs, images — to \`../../storage/\`; ` +
|
|
458
|
+
`use \`outputs/\` for working text the next step reads.`);
|
|
459
|
+
// Shared context before anything derived — an account or workspace rule is
|
|
460
|
+
// background the rest of the prompt is read against, not an afterthought.
|
|
461
|
+
const sharedContext = sharedInstructions(agentDir, tenant);
|
|
462
|
+
if (sharedContext)
|
|
463
|
+
parts.push(sharedContext);
|
|
464
|
+
// Skills follow the open Agent Skills format (agentskills.io): a folder
|
|
465
|
+
// with SKILL.md, optionally bundling its own scripts/ and references/. The
|
|
466
|
+
// flat `skills/<name>.md` form is also accepted for small skills.
|
|
467
|
+
//
|
|
468
|
+
// Progressive disclosure, as the standard prescribes: only each skill's
|
|
469
|
+
// name and description go into context up front. The agent reads the full
|
|
470
|
+
// SKILL.md when a task actually calls for it — so an agent can carry many
|
|
471
|
+
// skills for a few tokens each.
|
|
472
|
+
// Own skills, then the workspace's, then the workspace library — nearest
|
|
473
|
+
// definition of a name wins, so a team default can be overridden locally.
|
|
474
|
+
const skills = [];
|
|
475
|
+
const seenSkills = new Set();
|
|
476
|
+
const addSkills = (dir, prefix, subdir = "skills") => {
|
|
477
|
+
for (const skill of discoverSkills(dir, subdir)) {
|
|
478
|
+
if (seenSkills.has(skill.name))
|
|
479
|
+
continue;
|
|
480
|
+
seenSkills.add(skill.name);
|
|
481
|
+
skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
const workspaceRoot = path.join(agentDir, "..", "..");
|
|
485
|
+
addSkills(agentDir, "");
|
|
486
|
+
addSkills(workspaceRoot, "../../"); // the workspace's skills/
|
|
487
|
+
// The cross-client convention: skills any skills-compatible tool installs
|
|
488
|
+
// under .agents/skills/ are visible here too, and vice versa. Scanned after
|
|
489
|
+
// the native location so a foldrun-native skill wins a name clash, but both
|
|
490
|
+
// are discovered. project scope only — foldrun workspaces are self-contained.
|
|
491
|
+
addSkills(workspaceRoot, "../../", ".agents/skills");
|
|
492
|
+
addSkills(path.join(libraryDir(tenant)), "../../../../library/"); // the account library
|
|
493
|
+
// A skill with `when:` only loads when the run carries a matching tag —
|
|
494
|
+
// so an agent with 119 skills costs 119 lines of context only when every
|
|
495
|
+
// one is genuinely relevant.
|
|
496
|
+
// `skills:` is Claude Code's field for naming which skills an agent gets.
|
|
497
|
+
// Absent, an agent inherits every skill in scope — knowledge cascades. Named,
|
|
498
|
+
// it's an allowlist, which is how you keep a focused agent focused.
|
|
499
|
+
const only = refNames(front.skills);
|
|
500
|
+
const applicable = applicableSkills(skills, only, tags);
|
|
501
|
+
const withheld = skills.length - applicable.length;
|
|
502
|
+
if (applicable.length) {
|
|
503
|
+
const lines = applicable.map((s) => {
|
|
504
|
+
// The rest of the Agent Skills layout, named so the agent knows to look
|
|
505
|
+
// rather than guessing that a skill folder holds only SKILL.md.
|
|
506
|
+
const bundles = [
|
|
507
|
+
s.hasScripts && `scripts in \`${s.dir}/scripts/\``,
|
|
508
|
+
s.hasReferences && `reference docs in \`${s.dir}/references/\``,
|
|
509
|
+
s.hasAssets && `assets in \`${s.dir}/assets/\``,
|
|
510
|
+
].filter(Boolean);
|
|
511
|
+
return (`- **${s.name}** — ${s.description || "(no description)"} ` +
|
|
512
|
+
`Full instructions: \`${s.path}\`` +
|
|
513
|
+
(bundles.length ? ` (also bundles ${bundles.join(", ")})` : ""));
|
|
514
|
+
});
|
|
515
|
+
parts.push(`# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
|
|
516
|
+
`read its file for the full instructions before proceeding:\n\n${lines.join("\n")}` +
|
|
517
|
+
(withheld ? `\n\n(${withheld} further skills exist but do not apply to this run.)` : ""));
|
|
518
|
+
}
|
|
519
|
+
// Memory: the index is loaded every run, and the agent is told how to add
|
|
520
|
+
// to it. Writing is only possible if it has the `files` tool.
|
|
521
|
+
const canWrite = refNames(front.tools).includes("files");
|
|
522
|
+
const memoryDir = path.join(agentDir, "memory");
|
|
523
|
+
const memoryIndex = buildMemoryIndex(memoryDir);
|
|
524
|
+
if (memoryIndex || canWrite) {
|
|
525
|
+
const howTo = canWrite
|
|
526
|
+
? `\n\nTo remember something durable, write one fact per file to memory/<slug>.md with ` +
|
|
527
|
+
`frontmatter: type (e.g. Fact, Decision, Preference), name and description — these ` +
|
|
528
|
+
`bundles follow the Open Knowledge Format. The index above maintains itself — you do not ` +
|
|
529
|
+
`need to edit it. Read a memory file before relying on it, update an existing file ` +
|
|
530
|
+
`rather than duplicating it, and don't record what this workspace's files already say.`
|
|
531
|
+
: "";
|
|
532
|
+
const projectMem = workspaceMemoryIndex(tenant, workspace);
|
|
533
|
+
const shared = libraryMemoryIndex(tenant);
|
|
534
|
+
parts.push(`# Memory — what you have learned\n\n${memoryIndex ? `Your own (files under memory/):\n\n${memoryIndex}` : "You have no memories of your own yet."}` +
|
|
535
|
+
(projectMem ? `\n\nShared across this workspace:\n\n${projectMem}` : "") +
|
|
536
|
+
(shared ? `\n\nShared across every workspace:\n\n${shared}` : "") +
|
|
537
|
+
howTo);
|
|
538
|
+
}
|
|
539
|
+
// Knowledge — reference material the agent was given. Same three scopes and
|
|
540
|
+
// the same nearest-wins reading order as memory, but never written back:
|
|
541
|
+
// an agent that edits the price list it was handed has corrupted its own
|
|
542
|
+
// source of truth.
|
|
543
|
+
const ownKnowledge = knowledgeIndex(path.join(agentDir, "knowledge"), "knowledge/");
|
|
544
|
+
const projectKnowledge = workspaceKnowledgeIndex(tenant, workspace);
|
|
545
|
+
const sharedKnowledge = knowledgeIndex(libraryDir(tenant, "knowledge"), "../../../../library/knowledge/");
|
|
546
|
+
if (ownKnowledge || projectKnowledge || sharedKnowledge) {
|
|
547
|
+
const sections = [
|
|
548
|
+
ownKnowledge && `Your own (files under knowledge/):\n\n${ownKnowledge}`,
|
|
549
|
+
projectKnowledge && `Shared across this workspace:\n\n${projectKnowledge}`,
|
|
550
|
+
sharedKnowledge && `Shared across every workspace:\n\n${sharedKnowledge}`,
|
|
551
|
+
].filter(Boolean);
|
|
552
|
+
parts.push(`# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
|
|
553
|
+
`on it rather than guessing at its contents. This is read-only: it is maintained by ` +
|
|
554
|
+
`people, not by you — if you learn something new, write it to memory/ instead.\n\n` +
|
|
555
|
+
sections.join("\n\n"));
|
|
556
|
+
}
|
|
557
|
+
// Scripts an agent can run with bash: its own scripts/, plus the
|
|
558
|
+
// workspace-level shared scripts/ exposed at shared/ in its working dir.
|
|
559
|
+
const listDir = (dir, prefix) => {
|
|
560
|
+
if (!fs.existsSync(dir))
|
|
561
|
+
return [];
|
|
562
|
+
const out = [];
|
|
563
|
+
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
|
564
|
+
const rel = String(entry);
|
|
565
|
+
if (fs.statSync(path.join(dir, rel)).isFile())
|
|
566
|
+
out.push(`- ${prefix}${rel}`);
|
|
567
|
+
}
|
|
568
|
+
return out.sort();
|
|
569
|
+
};
|
|
570
|
+
const own = listDir(path.join(agentDir, "scripts"), "scripts/");
|
|
571
|
+
const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "../../scripts/");
|
|
572
|
+
// The library's path depends on where this run executes: isolated runs see
|
|
573
|
+
// it at /library, host runs at its real location. Printing the host path
|
|
574
|
+
// into a container's prompt handed the model files it could never open.
|
|
575
|
+
const libraryScriptsRoot = isolatedRun() ? "/library/scripts" : libraryDir(tenant, "scripts");
|
|
576
|
+
const global = listDir(libraryDir(tenant, "scripts"), `${libraryScriptsRoot}/`);
|
|
577
|
+
if (own.length || shared.length || global.length) {
|
|
578
|
+
const sections = [
|
|
579
|
+
own.length ? `Your own scripts:\n${own.join("\n")}` : null,
|
|
580
|
+
shared.length ? `Shared across this workspace:\n${shared.join("\n")}` : null,
|
|
581
|
+
global.length ? `From the account library (read-only):\n${global.join("\n")}` : null,
|
|
582
|
+
].filter(Boolean);
|
|
583
|
+
parts.push(`# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
|
|
584
|
+
`Read a script before running it if you are unsure what it does. Credentials your ` +
|
|
585
|
+
`scripts need are already in the environment as the secrets listed below.`);
|
|
586
|
+
}
|
|
587
|
+
// State — data a run carries to the next one. It was a directory in the
|
|
588
|
+
// workspace list, preserved across deploys, and nothing else: never named to
|
|
589
|
+
// an agent, never loaded, never written. A promise with no implementation.
|
|
590
|
+
//
|
|
591
|
+
// Listed rather than inlined, for the same reason knowledge and memory are:
|
|
592
|
+
// an agent that needs a cursor opens it, and one that doesn't shouldn't pay
|
|
593
|
+
// for it. The difference is that state is small enough that inlining would
|
|
594
|
+
// usually be free — that is a threshold worth adding once there is a real
|
|
595
|
+
// file to size it against, not before.
|
|
596
|
+
const state = listDir(path.join(agentDir, "..", "..", "state"), "../../state/");
|
|
597
|
+
if (state.length) {
|
|
598
|
+
parts.push(`# State — what you carry between runs\n\n` +
|
|
599
|
+
`What this workspace keeps across runs: a count, a cursor, where you got to. ` +
|
|
600
|
+
`Read and write these directly, and update them before you finish so the next ` +
|
|
601
|
+
`run starts where you left off.\n\n${state.join("\n")}\n\n` +
|
|
602
|
+
`Keep a file in the format it is already in, and write markdown for anything new. ` +
|
|
603
|
+
`A count reads as well in a sentence as in JSON, and a person edits these in the ` +
|
|
604
|
+
`same editor as everything else — data formats are allowed here, not preferred.\n\n` +
|
|
605
|
+
`This is not memory. A fact you learned goes in memory/, where it is indexed and ` +
|
|
606
|
+
`its provenance recorded — state is the bookmark, not the lesson.`);
|
|
607
|
+
}
|
|
608
|
+
// APIs — the agent's own definitions, plus workspace tools it granted by
|
|
609
|
+
// name. Library tools are defined once and reused.
|
|
610
|
+
const apis = parseApis(front.apis);
|
|
611
|
+
// Scripts declared inline on the agent, plus any script-type tool it
|
|
612
|
+
// granted — one list grants both kinds.
|
|
613
|
+
const scriptSpecs = parseScripts(front.scripts);
|
|
614
|
+
// Workspace tools shadow account tools of the same name (nearest wins).
|
|
615
|
+
const available = { ...libraryTools(tenant), ...workspaceTools(tenant, workspace) };
|
|
616
|
+
// Resolved in one place (tool-names.ts) so nothing downstream can disagree
|
|
617
|
+
// about which names are the author's.
|
|
618
|
+
const requested = ownToolNames(front);
|
|
619
|
+
const legacyUse = legacyUseNames(front);
|
|
620
|
+
const missingTools = [];
|
|
621
|
+
// MCP servers an agent connects to. `mcpServers:` is Claude Code's spelling
|
|
622
|
+
// for the inline form; a `type: mcp` tool file is the shared form. Both end
|
|
623
|
+
// up here, and both reach the model as ordinary MCP tools.
|
|
624
|
+
const mcpServers = {};
|
|
625
|
+
const mcpNames = [];
|
|
626
|
+
// What the granted tools' programs need — merged into this step's runtime
|
|
627
|
+
// below. The tool is the unit of code, so its dependencies live in tool.md
|
|
628
|
+
// and travel with it; the agent should not have to repeat them.
|
|
629
|
+
const toolRuntimes = [];
|
|
630
|
+
const grantOwnTool = (name) => {
|
|
631
|
+
const def = available[name];
|
|
632
|
+
if (!def) {
|
|
633
|
+
missingTools.push(name);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (def.kind === "http")
|
|
637
|
+
apis.push(def.spec);
|
|
638
|
+
else if (def.kind === "script") {
|
|
639
|
+
scriptSpecs.push(...parseScripts([def.spec]));
|
|
640
|
+
const rt = parseRuntime(def.spec.runtime);
|
|
641
|
+
if (rt)
|
|
642
|
+
toolRuntimes.push(rt);
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
mcpServers[def.name] = mcpConfig(def.spec, tenant, workspace);
|
|
646
|
+
mcpNames.push(def.name);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
for (const name of requested)
|
|
650
|
+
grantOwnTool(name);
|
|
651
|
+
// Inline servers, Claude Code's field name and shape.
|
|
652
|
+
if (front.mcpServers && typeof front.mcpServers === "object") {
|
|
653
|
+
for (const [name, raw] of Object.entries(front.mcpServers)) {
|
|
654
|
+
const def = parseToolDef({ ...raw, type: "mcp" }, name);
|
|
655
|
+
if (def?.kind === "mcp") {
|
|
656
|
+
mcpServers[name] = mcpConfig(def.spec, tenant, workspace);
|
|
657
|
+
mcpNames.push(name);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// An `openapi:` document turns one generic HTTP tool into typed ones, one
|
|
662
|
+
// per operation. Resolved here, host-side, so the operations cross into a
|
|
663
|
+
// container as values like the rest of the spec.
|
|
664
|
+
const attached = attachOperations(apis, tenant, path.resolve(agentDir, "..", ".."));
|
|
665
|
+
const apiWarnings = attached.warnings;
|
|
666
|
+
apis.splice(0, apis.length, ...attached.specs);
|
|
667
|
+
const apiTools = buildApiTools(tenant, apis, workspace);
|
|
668
|
+
if (apiTools.promptLines.length) {
|
|
669
|
+
parts.push(`# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
|
|
670
|
+
}
|
|
671
|
+
// Secrets an agent declared become env vars for its scripts and bash.
|
|
672
|
+
//
|
|
673
|
+
// Plus the credentials its API tools reference. An agent that opted into
|
|
674
|
+
// `tools: [email]` has said nothing about RESEND_API_KEY and should not have
|
|
675
|
+
// to: the whole point of a tool definition is that the capability travels
|
|
676
|
+
// with its credential and the agent never learns the name. The in-process
|
|
677
|
+
// path always worked this way — buildApiTools resolves what the specs
|
|
678
|
+
// reference — so without this the isolated path substituted nothing and
|
|
679
|
+
// sent `Bearer ${RESEND_API_KEY}` literally, which the provider answers
|
|
680
|
+
// with a 4xx the agent then reports as "the credential is missing".
|
|
681
|
+
//
|
|
682
|
+
// Declared-and-absent is still an error; referenced-and-absent is the API
|
|
683
|
+
// tool's own missingSecrets, reported where the tool is described.
|
|
684
|
+
const declared = Array.isArray(front.secrets) ? front.secrets.map(String) : [];
|
|
685
|
+
const referenced = [...new Set(apis.flatMap(secretsUsedByApi))];
|
|
686
|
+
const { env: secretEnv, from: secretScopes, } = resolveSecrets(tenant, [...new Set([...declared, ...referenced])], workspace);
|
|
687
|
+
const { missing: missingDeclared } = resolveSecrets(tenant, declared, workspace);
|
|
688
|
+
// The step's pod size. Three classes, not a dial: a size is a price and a
|
|
689
|
+
// promise, and three of each is a menu while ten is a support queue. Default
|
|
690
|
+
// is large — the install's configured limits — so existing agents change
|
|
691
|
+
// nothing. `size: small` is the opt-in for agents that wait on APIs more
|
|
692
|
+
// than they work; `size: heavy` for a rendered browser or real data in
|
|
693
|
+
// memory. Memory is a hard ceiling per class; CPU bursts uncapped and bills
|
|
694
|
+
// at the class rate, so heavy both may use more and costs more. The actuals
|
|
695
|
+
// line in any trace is the evidence for right-sizing.
|
|
696
|
+
const size = front.size === "small" ? "small" : front.size === "heavy" ? "heavy" : "large";
|
|
697
|
+
// Provider: which endpoint the model calls run against. Anthropic by
|
|
698
|
+
// default; an Anthropic-compatible gateway (z.ai, LiteLLM, OpenRouter) by
|
|
699
|
+
// declaring one. The format is portable — this is where the runtime stops
|
|
700
|
+
// being tied to one vendor's endpoint.
|
|
701
|
+
//
|
|
702
|
+
// provider:
|
|
703
|
+
// base_url: https://api.z.ai/api/anthropic
|
|
704
|
+
// token: ${ZAI_TOKEN}
|
|
705
|
+
//
|
|
706
|
+
// The URL sits in git; the token is a ${SECRET} resolved server-side. An
|
|
707
|
+
// agent's own block wins over the workspace's, like everything else here.
|
|
708
|
+
const providerBlock = front.provider ?? workspaceFrontmatter(agentDir, tenant).provider;
|
|
709
|
+
let providerEnv = {};
|
|
710
|
+
let providerLabel = null;
|
|
711
|
+
// Secret values pulled into the provider block, name → value. Kept apart
|
|
712
|
+
// from providerEnv because a header blob is a poor redaction key: what
|
|
713
|
+
// must never reach the journal is the credential inside it, not the blob.
|
|
714
|
+
const providerSecrets = {};
|
|
715
|
+
const providerWarnings = [];
|
|
716
|
+
// The declared second supply, resolved the same way. Null when the block
|
|
717
|
+
// names none; the platform's own fallback still applies to a step that
|
|
718
|
+
// rides the platform credential.
|
|
719
|
+
let fallbackEnv = null;
|
|
720
|
+
// A Chat-Completions endpoint is reached through the runtime's own
|
|
721
|
+
// translator; these are its instructions, as values, or null when the
|
|
722
|
+
// block speaks Anthropic. One for the primary, one for the fallback.
|
|
723
|
+
let translator = null;
|
|
724
|
+
let fallbackTranslator = null;
|
|
725
|
+
const spec = parseProvider(providerBlock);
|
|
726
|
+
if (spec) {
|
|
727
|
+
providerWarnings.push(...spec.warnings);
|
|
728
|
+
const substitute = (text) => text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, secretName) => {
|
|
729
|
+
const hit = getSecret(tenant, secretName, workspace);
|
|
730
|
+
if (!hit)
|
|
731
|
+
return whole;
|
|
732
|
+
providerSecrets[secretName] = hit.value;
|
|
733
|
+
return hit.value;
|
|
734
|
+
});
|
|
735
|
+
const resolved = substitute(spec.token);
|
|
736
|
+
if (spec.baseUrl) {
|
|
737
|
+
const headers = Object.fromEntries(Object.entries(spec.headers).map(([k, v]) => [k, substitute(v)]));
|
|
738
|
+
providerEnv = providerEnvFor({
|
|
739
|
+
baseUrl: spec.baseUrl,
|
|
740
|
+
token: resolved,
|
|
741
|
+
auth: spec.auth,
|
|
742
|
+
models: spec.models,
|
|
743
|
+
headers,
|
|
744
|
+
});
|
|
745
|
+
const preset = providerPreset(spec.name);
|
|
746
|
+
translator = translatorSpecFor({
|
|
747
|
+
format: spec.format,
|
|
748
|
+
baseUrl: spec.baseUrl,
|
|
749
|
+
token: resolved,
|
|
750
|
+
headers,
|
|
751
|
+
params: spec.params,
|
|
752
|
+
name: spec.name,
|
|
753
|
+
maxTokensParam: preset?.maxTokensParam,
|
|
754
|
+
reasoningEffort: preset?.reasoningEffort,
|
|
755
|
+
});
|
|
756
|
+
// What the run is actually pointed at, in one line: the endpoint, the
|
|
757
|
+
// tiers this gateway renames, and whether the credential landed.
|
|
758
|
+
const remapped = Object.entries(spec.models).map(([tier, id]) => `${tier}→${id}`);
|
|
759
|
+
providerLabel = (spec.name ? `${spec.name} ` : "") + spec.baseUrl + (translator ? " via translator" : "");
|
|
760
|
+
if (remapped.length)
|
|
761
|
+
providerLabel += ` (${remapped.join(", ")})`;
|
|
762
|
+
// An unresolved ${SECRET} would silently fall back to Anthropic's
|
|
763
|
+
// endpoint with the host's key — say so instead.
|
|
764
|
+
if (spec.token && resolved === spec.token && spec.token.includes("${")) {
|
|
765
|
+
providerLabel += " — token secret not set";
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (spec.fallback?.baseUrl) {
|
|
769
|
+
const fb = spec.fallback;
|
|
770
|
+
const headers = Object.fromEntries(Object.entries(fb.headers).map(([k, v]) => [k, substitute(v)]));
|
|
771
|
+
const fbToken = substitute(fb.token);
|
|
772
|
+
fallbackEnv = providerEnvFor({ baseUrl: fb.baseUrl, token: fbToken, auth: fb.auth, models: fb.models, headers });
|
|
773
|
+
const fbPreset = providerPreset(fb.name);
|
|
774
|
+
fallbackTranslator = translatorSpecFor({
|
|
775
|
+
format: fb.format,
|
|
776
|
+
baseUrl: fb.baseUrl,
|
|
777
|
+
token: fbToken,
|
|
778
|
+
headers,
|
|
779
|
+
params: fb.params,
|
|
780
|
+
name: fb.name,
|
|
781
|
+
maxTokensParam: fbPreset?.maxTokensParam,
|
|
782
|
+
reasoningEffort: fbPreset?.reasoningEffort,
|
|
783
|
+
});
|
|
784
|
+
providerLabel = `${providerLabel ?? "platform"} (fallback: ${fb.name ?? fb.baseUrl})`;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
// Runtime: an agent's own `runtime:` block, else its workspace's. Built once
|
|
788
|
+
// per declaration and cached, so scripts get their dependencies without
|
|
789
|
+
// polluting the host.
|
|
790
|
+
let runtimeSpec = parseRuntime(front.runtime);
|
|
791
|
+
if (!runtimeSpec) {
|
|
792
|
+
// AGENTS.md is the Linux Foundation standard name (60k+ repos, same
|
|
793
|
+
// nearest-wins cascade we already use); project.md stays accepted so
|
|
794
|
+
// nothing that already exists breaks.
|
|
795
|
+
runtimeSpec = parseRuntime(workspaceFrontmatter(agentDir, tenant).runtime);
|
|
796
|
+
}
|
|
797
|
+
// Plus whatever the tools this agent granted declared for themselves.
|
|
798
|
+
runtimeSpec = mergeRuntimes(runtimeSpec, ...toolRuntimes);
|
|
799
|
+
// Execution: a container per run when Docker is available (real isolation),
|
|
800
|
+
// otherwise the host venv path so local development still works.
|
|
801
|
+
const executor = chooseExecutor();
|
|
802
|
+
const runtimeLog = [];
|
|
803
|
+
let runtimeError = null;
|
|
804
|
+
let exec = null;
|
|
805
|
+
let runtime = { interpreters: {}, env: {}, log: [], error: null };
|
|
806
|
+
if (executor === "docker") {
|
|
807
|
+
const image = ensureImage(runtimeSpec);
|
|
808
|
+
runtimeLog.push(...image.log);
|
|
809
|
+
runtimeError = image.error;
|
|
810
|
+
if (!image.error) {
|
|
811
|
+
exec = {
|
|
812
|
+
executor: "docker",
|
|
813
|
+
image: image.tag,
|
|
814
|
+
mounts: {
|
|
815
|
+
[path.resolve(agentDir, "..", "..", "scripts")]: "/workspace-scripts",
|
|
816
|
+
[libraryDir(tenant, "scripts")]: "/library-scripts",
|
|
817
|
+
},
|
|
818
|
+
// Only agents that granted something reaching out — an `apis:` entry
|
|
819
|
+
// or one of their own tools — get egress.
|
|
820
|
+
network: parseApis(front.apis).length > 0 || ownToolNames(front).length > 0,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
runtime = prepareRuntime(tenant, runtimeSpec);
|
|
826
|
+
runtimeLog.push(...runtime.log);
|
|
827
|
+
runtimeError = runtime.error;
|
|
828
|
+
}
|
|
829
|
+
// What day it is, in the workspace's own timezone. Not a secret, so it
|
|
830
|
+
// travels beside secretEnv rather than inside it — a value in there is
|
|
831
|
+
// redacted from every log line that quotes it.
|
|
832
|
+
const timezone = resolveTimezone(workspaceFrontmatter(agentDir, tenant));
|
|
833
|
+
const clockEnv = {
|
|
834
|
+
TZ: timezone,
|
|
835
|
+
FOLDRUN_DATE: localDate(timezone),
|
|
836
|
+
// Where the account's own search engine answers, when the install runs
|
|
837
|
+
// one — the `websearch` library tool reads it. Not a secret; rides with
|
|
838
|
+
// the clock so it reaches scripts, the sandbox and verify alike.
|
|
839
|
+
...(process.env.FOLDRUN_SEARCH_URL ? { FOLDRUN_SEARCH_URL: process.env.FOLDRUN_SEARCH_URL } : {}),
|
|
840
|
+
};
|
|
841
|
+
// Scripts declared as tools — callable by name, no bash required.
|
|
842
|
+
const scriptTools = buildScriptTools(agentDir, scriptSpecs,
|
|
843
|
+
// A script can say where its work came from. Without this, anything a
|
|
844
|
+
// tool writes into another system lands anonymous: you can see 100 new
|
|
845
|
+
// contacts and have no way back to the run that made them. The run id,
|
|
846
|
+
// the agent, and the date the platform believes it is.
|
|
847
|
+
{
|
|
848
|
+
...runtime.env,
|
|
849
|
+
...secretEnv,
|
|
850
|
+
...(identity.runId ? { FOLDRUN_RUN_ID: identity.runId } : {}),
|
|
851
|
+
...(identity.agent ? { FOLDRUN_AGENT: identity.agent } : {}),
|
|
852
|
+
// And the workspace: a commit or a record made by a shared library
|
|
853
|
+
// tool should say which desk made it, and the pod's own paths
|
|
854
|
+
// (/workspace/agents/<agent>) do not carry the name.
|
|
855
|
+
FOLDRUN_WORKSPACE: workspace,
|
|
856
|
+
...clockEnv,
|
|
857
|
+
}, libraryDir(tenant, "scripts"), runtime.interpreters, exec);
|
|
858
|
+
parts.push(`# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
|
|
859
|
+
`write or check — it is the workspace's calendar, not the container's clock.`);
|
|
860
|
+
if (scriptTools.promptLines.length) {
|
|
861
|
+
parts.push(`# Tools from scripts\n\nCall these directly — they run workspace scripts for you:\n\n${scriptTools.promptLines.join("\n")}`);
|
|
862
|
+
}
|
|
863
|
+
if (declared.length) {
|
|
864
|
+
parts.push(`# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
|
|
865
|
+
`Never print, echo, or write them to a file.`);
|
|
866
|
+
}
|
|
867
|
+
parts.push("Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
|
|
868
|
+
"what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
|
|
869
|
+
"for the workspace root (../../storage/ and workspace/storage/ are the same place). There is no /tmp for you: a " +
|
|
870
|
+
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool.");
|
|
871
|
+
// The run's one-line summary is the first meaningful line of the last step
|
|
872
|
+
// that produced a result — read, never declared (runSummary in store.ts).
|
|
873
|
+
// That convention was load-bearing and unstated: it decides the runs-list
|
|
874
|
+
// row, the notification subject, and what `recall_runs()` shows a later
|
|
875
|
+
// run, and an agent that opened with "I'll start by cloning the repo" made
|
|
876
|
+
// all three useless. Asking here rather than in each agent.md means every
|
|
877
|
+
// agent obeys it, including the ones written before the summary existed.
|
|
878
|
+
parts.push("Open your reply with one sentence saying what you concluded or produced — the outcome, " +
|
|
879
|
+
"not your plan. Everything else follows it. That sentence is what a person sees in the " +
|
|
880
|
+
"run list and in the notification, without opening the run.");
|
|
881
|
+
// One list. `tools:` grants anything: a built-in group, an exact SDK tool
|
|
882
|
+
// name, or a tool defined in this workspace or account.
|
|
883
|
+
//
|
|
884
|
+
// The split used to be ours, not the author's: they don't care whether a
|
|
885
|
+
// capability comes from the SDK or a file, only what the agent may do. Two
|
|
886
|
+
// lists meant two places to look for the blast radius.
|
|
887
|
+
const allowed = [];
|
|
888
|
+
const disabled = [];
|
|
889
|
+
const unknownTools = [];
|
|
890
|
+
const shadowed = [];
|
|
891
|
+
let wantSearch = false;
|
|
892
|
+
let wantHistory = false;
|
|
893
|
+
let wantDesks = false;
|
|
894
|
+
for (const ref of toolRefs(front)) {
|
|
895
|
+
const toolName = ref.name;
|
|
896
|
+
if (ref.mode === "ask") {
|
|
897
|
+
disabled.push(toolName);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
// Resolution order: built-in group, exact SDK name, then your own tools.
|
|
901
|
+
// Built-ins win a bare name, because their names are a small fixed set —
|
|
902
|
+
// but a tool hidden by one is worth saying out loud rather than silently
|
|
903
|
+
// ignoring. A `[[link]]` skips the built-ins entirely: the brackets mean
|
|
904
|
+
// "my file", which is how a tool named `search` is granted at all.
|
|
905
|
+
if (ref.linked) {
|
|
906
|
+
// Granted above through ownToolNames, or reported missing there.
|
|
907
|
+
}
|
|
908
|
+
else if (TOOL_MAP[toolName]) {
|
|
909
|
+
allowed.push(...TOOL_MAP[toolName]);
|
|
910
|
+
if (available[toolName])
|
|
911
|
+
shadowed.push(toolName);
|
|
912
|
+
}
|
|
913
|
+
else if (toolName === "search" || toolName === "history" || toolName === "desks") {
|
|
914
|
+
// The platform's own groups, served in-process — built below, once
|
|
915
|
+
// the roots and the records are known.
|
|
916
|
+
if (toolName === "search")
|
|
917
|
+
wantSearch = true;
|
|
918
|
+
else if (toolName === "history")
|
|
919
|
+
wantHistory = true;
|
|
920
|
+
else
|
|
921
|
+
wantDesks = true;
|
|
922
|
+
if (available[toolName])
|
|
923
|
+
shadowed.push(toolName);
|
|
924
|
+
}
|
|
925
|
+
else if (BUILTIN_TOOLS.has(toolName)) {
|
|
926
|
+
allowed.push(toolName);
|
|
927
|
+
if (available[toolName])
|
|
928
|
+
shadowed.push(toolName);
|
|
929
|
+
}
|
|
930
|
+
else if (available[toolName]) {
|
|
931
|
+
// Already granted above — nothing to do here.
|
|
932
|
+
}
|
|
933
|
+
else if (!missingTools.includes(toolName)) {
|
|
934
|
+
unknownTools.push(toolName);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
allowed.push(...apiTools.toolNames, ...scriptTools.toolNames);
|
|
938
|
+
// tools: [search] — every place a fact of this agent's could be, in the
|
|
939
|
+
// same nearest-wins order the prompt lists them. tools: [history] — the
|
|
940
|
+
// workspace's finished runs, minus the one being driven. Both are values
|
|
941
|
+
// by the time they leave here, so the container can rebuild the servers.
|
|
942
|
+
const wsRoot = path.resolve(agentDir, "..", "..");
|
|
943
|
+
const searchRoots = wantSearch
|
|
944
|
+
? [
|
|
945
|
+
{ label: "knowledge/", dir: path.join(agentDir, "knowledge") },
|
|
946
|
+
{ label: "memory/", dir: path.join(agentDir, "memory") },
|
|
947
|
+
{ label: "../../knowledge/", dir: path.join(wsRoot, "knowledge") },
|
|
948
|
+
{ label: "../../memory/", dir: path.join(wsRoot, "memory") },
|
|
949
|
+
{ label: "../../state/", dir: path.join(wsRoot, "state") },
|
|
950
|
+
{ label: `../../${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
|
|
951
|
+
{ label: "outputs/", dir: path.join(agentDir, "outputs") },
|
|
952
|
+
{ label: "[library] knowledge/", dir: libraryDir(tenant, "knowledge") },
|
|
953
|
+
{ label: "[library] memory/", dir: libraryDir(tenant, "memory") },
|
|
954
|
+
]
|
|
955
|
+
: [];
|
|
956
|
+
const historyDigest = wantHistory ? digestRuns(listRuns(tenant, workspace), 30, identity.runId) : [];
|
|
957
|
+
// tools: [desks] — the account's OTHER workspaces, ten runs each. A run
|
|
958
|
+
// pod cannot reach the platform's API and the library is read-only, so
|
|
959
|
+
// this is the only way one desk sees what another concluded — and it
|
|
960
|
+
// stays a value the host chose to send, not a door the sandbox opened.
|
|
961
|
+
const deskDigest = wantDesks
|
|
962
|
+
? listWorkspaces(tenant)
|
|
963
|
+
.filter((w) => w.name !== workspace)
|
|
964
|
+
.flatMap((w) => digestRuns(listRuns(tenant, w.name), 10).map((d) => ({ ...d, workspace: w.name })))
|
|
965
|
+
.sort((a, b) => (b.startedAt > a.startedAt ? 1 : -1))
|
|
966
|
+
.slice(0, DESK_RUNS_CAP)
|
|
967
|
+
: [];
|
|
968
|
+
const searchTools = buildSearchTools(searchRoots);
|
|
969
|
+
const historyTools = wantHistory ? buildHistoryTools(historyDigest) : { server: null, toolNames: [], promptLines: [] };
|
|
970
|
+
const deskTools = wantDesks ? buildDeskTools(deskDigest) : { server: null, toolNames: [], promptLines: [] };
|
|
971
|
+
allowed.push(...searchTools.toolNames, ...historyTools.toolNames, ...deskTools.toolNames);
|
|
972
|
+
if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
|
|
973
|
+
parts.push(`# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
|
|
974
|
+
}
|
|
975
|
+
// An MCP server declares its tools when it connects, so they can't be listed
|
|
976
|
+
// ahead of time. Server-level specs cover them: granting the server grants
|
|
977
|
+
// what it exposes, which is the unit a person actually reasons about anyway.
|
|
978
|
+
for (const name of mcpNames)
|
|
979
|
+
allowed.push(`mcp__${name}`, `mcp__${name}__*`);
|
|
980
|
+
// Claude Code's spellings, so an agent written for it mostly runs here.
|
|
981
|
+
// `permissionMode: plan` is its read-only mode; ours is the `read` group.
|
|
982
|
+
if (front.permissionMode === "plan") {
|
|
983
|
+
allowed.length = 0;
|
|
984
|
+
allowed.push(...TOOL_MAP.read, ...apiTools.toolNames, ...scriptTools.toolNames);
|
|
985
|
+
}
|
|
986
|
+
const denied = refNames(front.disallowedTools);
|
|
987
|
+
const finalAllowed = allowed.filter((t) => !denied.includes(t));
|
|
988
|
+
return {
|
|
989
|
+
front,
|
|
990
|
+
clockEnv,
|
|
991
|
+
systemPrompt: parts.join("\n\n"),
|
|
992
|
+
allowed: finalAllowed,
|
|
993
|
+
disabled,
|
|
994
|
+
unknownTools,
|
|
995
|
+
shadowed,
|
|
996
|
+
legacyUse,
|
|
997
|
+
mcpServers,
|
|
998
|
+
mcpNames,
|
|
999
|
+
apiTools,
|
|
1000
|
+
scriptTools,
|
|
1001
|
+
// The merged spec lists — inline plus tools:-granted — for the isolated
|
|
1002
|
+
// path, which serialises specs across the container boundary rather
|
|
1003
|
+
// than using the servers built here.
|
|
1004
|
+
apiSpecs: apis,
|
|
1005
|
+
scriptSpecs,
|
|
1006
|
+
searchRoots,
|
|
1007
|
+
historyDigest,
|
|
1008
|
+
deskDigest,
|
|
1009
|
+
searchTools,
|
|
1010
|
+
historyTools,
|
|
1011
|
+
deskTools,
|
|
1012
|
+
// `spec` is the MERGED declaration — the agent's own block or its
|
|
1013
|
+
// workspace's, plus every granted tool's. The isolated path builds its
|
|
1014
|
+
// runtime from this; passing the agent's frontmatter alone silently drops
|
|
1015
|
+
// what the tools declared, and a tool whose program needs a package it
|
|
1016
|
+
// asked for by name fails inside the container with "cannot find" while
|
|
1017
|
+
// the host path, which uses this same merged spec, works fine.
|
|
1018
|
+
runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec },
|
|
1019
|
+
secretEnv: { ...runtime.env, ...secretEnv },
|
|
1020
|
+
formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
|
|
1021
|
+
providerEnv,
|
|
1022
|
+
providerLabel,
|
|
1023
|
+
providerSecrets,
|
|
1024
|
+
providerWarnings,
|
|
1025
|
+
fallbackEnv,
|
|
1026
|
+
translator,
|
|
1027
|
+
fallbackTranslator,
|
|
1028
|
+
apiWarnings,
|
|
1029
|
+
secretScopes,
|
|
1030
|
+
missingTools,
|
|
1031
|
+
size,
|
|
1032
|
+
// Definitions that exist but could not be read. Carried alongside the
|
|
1033
|
+
// missing ones so the step can tell "you never made this" apart from
|
|
1034
|
+
// "it's right there with a typo in it".
|
|
1035
|
+
brokenTools: brokenToolReport(tenant, workspace),
|
|
1036
|
+
missingSecrets: [...new Set([...missingDeclared, ...apiTools.missingSecrets])],
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* The SDK prices a step off Anthropic's table; through a gateway to a
|
|
1041
|
+
* routed model that number is fiction. When the catalogue can price the
|
|
1042
|
+
* tokens itself, its number replaces the SDK's, and the trace says so —
|
|
1043
|
+
* a silently different bill is the one kind nobody forgives.
|
|
1044
|
+
*/
|
|
1045
|
+
/**
|
|
1046
|
+
* Publish whatever this step left in `storage/public/` and record the URLs.
|
|
1047
|
+
*
|
|
1048
|
+
* Runs after every step, on both execution paths, because the file has to be
|
|
1049
|
+
* on the host (copy-back has happened) and the manifest has to be in place
|
|
1050
|
+
* before the next step's sandbox is filled from that same workspace. A step
|
|
1051
|
+
* generates an image; the step after it reads the URL from a plain file.
|
|
1052
|
+
*
|
|
1053
|
+
* Never fails a step: a link is bookkeeping, and a run that did its work must
|
|
1054
|
+
* not be marked failed because a URL could not be minted.
|
|
1055
|
+
*/
|
|
1056
|
+
function publishPublicDir(tenant, workspace, push) {
|
|
1057
|
+
try {
|
|
1058
|
+
const { added } = platform.syncPublicShares(tenant, workspace);
|
|
1059
|
+
if (added.length)
|
|
1060
|
+
push("info", `shared: ${added.join(", ")}`);
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
push("error", `shares: ${err instanceof Error ? err.message : String(err)}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
function repriced(catalog, model, usage, sdkCost, push) {
|
|
1067
|
+
const known = usage ? catalogCost(catalog, model, usage) : null;
|
|
1068
|
+
if (known === null)
|
|
1069
|
+
return sdkCost;
|
|
1070
|
+
if (sdkCost !== null && Math.abs(known - sdkCost) > 0.000001) {
|
|
1071
|
+
push("info", `cost repriced from the gateway's catalogue: $${known.toFixed(6)} (sdk said $${sdkCost.toFixed(6)})`);
|
|
1072
|
+
}
|
|
1073
|
+
return known;
|
|
1074
|
+
}
|
|
1075
|
+
async function runStep(agentDir, tenant, step, context, save, modelOverride, tags = [], effortOverride, runId,
|
|
1076
|
+
/** What the previous group returned as DATA (an `output: json` step's
|
|
1077
|
+
* value), beside the prose in `context`. Undefined when nothing did. */
|
|
1078
|
+
priorData,
|
|
1079
|
+
/** The most this step may spend before it is stopped mid-turn: the run's
|
|
1080
|
+
* remaining `budget:`, shared across the steps launched with it. Null
|
|
1081
|
+
* means the flow set no budget. */
|
|
1082
|
+
stepBudgetUsd = null) {
|
|
1083
|
+
// Secret values are injected into scripts as environment variables and
|
|
1084
|
+
// substituted into API headers, so a model that reads one back — from a
|
|
1085
|
+
// script that prints it, an API error that echoes it, or its own `env` —
|
|
1086
|
+
// can put a live credential into its reply. Everything a step says is
|
|
1087
|
+
// written to runs/<id>.json and rendered in the dashboard, which would make
|
|
1088
|
+
// that credential permanent and visible to anyone who can read a run.
|
|
1089
|
+
//
|
|
1090
|
+
// So values are scrubbed on the way into the journal, at the one place every
|
|
1091
|
+
// event passes through. The name survives, because "the token was here" is
|
|
1092
|
+
// the part worth keeping.
|
|
1093
|
+
//
|
|
1094
|
+
// This is the last line of defence, not the only one: the model is never
|
|
1095
|
+
// given the values in the first place, and checkBash already refuses the
|
|
1096
|
+
// obvious `echo $SOMETHING_TOKEN`. It exists because neither of those can
|
|
1097
|
+
// cover a script's own stdout.
|
|
1098
|
+
let redactions = [];
|
|
1099
|
+
let fileDir = null;
|
|
1100
|
+
const redact = (text) => {
|
|
1101
|
+
let out = text;
|
|
1102
|
+
for (const [value, name] of redactions)
|
|
1103
|
+
out = out.split(value).join(`[redacted:${name}]`);
|
|
1104
|
+
return out;
|
|
1105
|
+
};
|
|
1106
|
+
const push = (type, text, extra) => {
|
|
1107
|
+
step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
|
|
1108
|
+
save();
|
|
1109
|
+
};
|
|
1110
|
+
// The clock on the step itself. `??=` so a retry extends the first
|
|
1111
|
+
// attempt's span rather than starting a new one; finishedAt is stamped on
|
|
1112
|
+
// every exit below and the last attempt's stands.
|
|
1113
|
+
step.startedAt ??= new Date().toISOString();
|
|
1114
|
+
// The test seam: FOLDRUN_STUB_STEP=1 completes the step from a canned
|
|
1115
|
+
// script instead of a model, so the orchestration around steps — loops,
|
|
1116
|
+
// fan-out, gates — is testable at zero cost. An agent's `stub.md` holds
|
|
1117
|
+
// the answers, `\n---\n`-separated, consumed one per call; the last one
|
|
1118
|
+
// repeats. Never set outside a test.
|
|
1119
|
+
if (process.env.FOLDRUN_STUB_STEP === "1") {
|
|
1120
|
+
const stubFile = path.join(agentDir, "stub.md");
|
|
1121
|
+
const answers = fs.existsSync(stubFile)
|
|
1122
|
+
? fs.readFileSync(stubFile, "utf8").split(/\n---\n/)
|
|
1123
|
+
: [`stub result from ${step.agent}`];
|
|
1124
|
+
const call = step.events.filter((e) => e.text.startsWith("stub call")).length;
|
|
1125
|
+
push("info", `stub call ${call + 1}`);
|
|
1126
|
+
step.status = "completed";
|
|
1127
|
+
// A stub answer may open with `cost: 0.25` — a pretend price, so the
|
|
1128
|
+
// parts of orchestration that watch money (budget:) are testable too.
|
|
1129
|
+
const answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
|
|
1130
|
+
const priced = answer.match(/^cost:\s*([\d.]+)\n?/);
|
|
1131
|
+
const body = (priced ? answer.slice(priced[0].length) : answer).trim();
|
|
1132
|
+
// A `===` line inside an answer separates earlier turns from the final
|
|
1133
|
+
// one, so the handoffs that read a conclusion rather than a transcript
|
|
1134
|
+
// are testable: result is everything joined, conclusion the last part.
|
|
1135
|
+
const turns = body.split(/\n===\n/);
|
|
1136
|
+
step.result = turns.join("\n").trim() + (step.item ? ` [item: ${step.item}]` : "");
|
|
1137
|
+
step.conclusion = turns.length > 1 ? turns.at(-1).trim() : null;
|
|
1138
|
+
step.costUsd = priced ? Number(priced[1]) : 0;
|
|
1139
|
+
// The stub honours the output contract too, so fan-out over data and
|
|
1140
|
+
// the failure of a step that returned no JSON are testable at no cost.
|
|
1141
|
+
if (step.output === "json") {
|
|
1142
|
+
const extracted = extractJson(step.result);
|
|
1143
|
+
if (extracted.ok)
|
|
1144
|
+
step.data = extracted.value;
|
|
1145
|
+
else {
|
|
1146
|
+
push("error", `output: json — ${extracted.reason}`);
|
|
1147
|
+
step.status = "failed";
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
step.finishedAt = new Date().toISOString();
|
|
1151
|
+
save();
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
try {
|
|
1155
|
+
// An `openapi:` URL is fetched (and cached a day) before the context is
|
|
1156
|
+
// assembled, because assembling it is synchronous and the document has
|
|
1157
|
+
// to be on disk by then. Failures are lines in the trace, not a failed
|
|
1158
|
+
// step: the generic HTTP tool still works without the typed ones.
|
|
1159
|
+
for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
|
|
1160
|
+
push("error", `openapi: ${w}`);
|
|
1161
|
+
const { front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, translator, fallbackTranslator, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
|
|
1162
|
+
// oauth2 secrets become live access tokens here — the one async moment
|
|
1163
|
+
// in secret resolution, at the last host-side point before anything is
|
|
1164
|
+
// injected or substituted. The refresh recipes never leave this process:
|
|
1165
|
+
// what crosses into containers, headers and env files is the token. A
|
|
1166
|
+
// failed refresh fails the step naming the secret and the provider's
|
|
1167
|
+
// reason, which beats a 401 three layers later.
|
|
1168
|
+
const liveSecrets = await materializeSecrets(secretEnv);
|
|
1169
|
+
// @file values (SSH keys, certs) still carry their content at this point.
|
|
1170
|
+
// The in-process branch materialises them to 0600 paths on this host
|
|
1171
|
+
// below; the container branch passes the content across and the driver
|
|
1172
|
+
// materialises inside, because a host path is meaningless in there.
|
|
1173
|
+
// Populate before the first push: everything after this point may quote a
|
|
1174
|
+
// credential. Short values are skipped — a two-character secret would
|
|
1175
|
+
// redact half the English language, and anything that short is not one.
|
|
1176
|
+
redactions = Object.entries({ ...liveSecrets, ...providerSecrets, ...providerEnv })
|
|
1177
|
+
.filter(([, value]) => typeof value === "string" && value.length >= 8)
|
|
1178
|
+
.map(([name, value]) => [value, name])
|
|
1179
|
+
// Longest first, so a value containing another is replaced whole.
|
|
1180
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
1181
|
+
if (providerLabel)
|
|
1182
|
+
push("info", `provider: ${providerLabel}`);
|
|
1183
|
+
for (const w of providerWarnings)
|
|
1184
|
+
push("error", w);
|
|
1185
|
+
for (const w of apiWarnings)
|
|
1186
|
+
push("error", `openapi: ${w}`);
|
|
1187
|
+
if (formatWarning)
|
|
1188
|
+
push("error", formatWarning);
|
|
1189
|
+
if (legacyUse.length)
|
|
1190
|
+
push("error", legacyUseError(legacyUse));
|
|
1191
|
+
for (const t of unknownTools) {
|
|
1192
|
+
push("error", `tools: "${t}" is not a tool group (web, fetch, read, files, bash, search, history), an SDK tool name, or a tool in this workspace's library — nothing was granted for it`);
|
|
1193
|
+
}
|
|
1194
|
+
for (const name of mcpNames)
|
|
1195
|
+
push("info", `mcp server connected: ${name}`);
|
|
1196
|
+
for (const name of shadowed) {
|
|
1197
|
+
push("error", `tools: "${name}" is a built-in, so your tool of the same name was not granted — write it as [[${name}]] to mean yours`);
|
|
1198
|
+
}
|
|
1199
|
+
for (const t of disabled)
|
|
1200
|
+
push("info", `tool ${t}: ask-mode is disabled for platform runs`);
|
|
1201
|
+
for (const s of missingSecrets) {
|
|
1202
|
+
push("error", `secret ${s} is not set in this workspace — add it in Settings → Secrets`);
|
|
1203
|
+
}
|
|
1204
|
+
// Say which store each secret came from: a workspace quietly falling back
|
|
1205
|
+
// to an account credential is the kind of thing you want to see, not infer.
|
|
1206
|
+
for (const [name, scope] of Object.entries(secretScopes)) {
|
|
1207
|
+
push("info", `secret ${name} ← ${scope} scope`);
|
|
1208
|
+
}
|
|
1209
|
+
for (const t of missingTools) {
|
|
1210
|
+
// "Not found" is the wrong sentence when the file is right there with a
|
|
1211
|
+
// typo in it, and it sends the author to the wrong place. Say which.
|
|
1212
|
+
const broken = brokenTools.filter((b) => b.startsWith(`${t}:`));
|
|
1213
|
+
push("error", broken.length
|
|
1214
|
+
? `tool "${t}" exists but could not be read — ${broken[0].slice(t.length + 2)}`
|
|
1215
|
+
: `tool "${t}" is not in the workspace library — add it under Library → Tools`);
|
|
1216
|
+
}
|
|
1217
|
+
for (const line of runtime.log)
|
|
1218
|
+
push("info", line);
|
|
1219
|
+
push("info", `script executor: ${runtime.executor}`);
|
|
1220
|
+
if (runtime.error)
|
|
1221
|
+
push("error", `runtime: ${runtime.error}`);
|
|
1222
|
+
// Nearest wins, for both halves of model selection: the step names one,
|
|
1223
|
+
// else the flow does, else the agent's own frontmatter. Which level won
|
|
1224
|
+
// goes in the trace — "why did this run on haiku" should be answerable
|
|
1225
|
+
// from the run, not by opening three files.
|
|
1226
|
+
const modelSource = step.model ? "step" : modelOverride ? "flow" : "agent";
|
|
1227
|
+
const model = resolveModel(step.model ?? modelOverride ?? front.model);
|
|
1228
|
+
const rawEffort = step.effort ?? effortOverride ?? front.effort;
|
|
1229
|
+
const effortSource = step.effort ? "step" : effortOverride ? "flow" : "agent";
|
|
1230
|
+
let effort = resolveEffort(rawEffort);
|
|
1231
|
+
if (isEffortWord(rawEffort)) {
|
|
1232
|
+
push("error", `effort: "${String(rawEffort).trim()}" is not a level — use ${EFFORT_LEVELS.join(", ")}. ` +
|
|
1233
|
+
`Running at the model's default instead.`);
|
|
1234
|
+
}
|
|
1235
|
+
push("info", `step started (agent: ${step.agent}, model: ${model} — ${modelSource}` +
|
|
1236
|
+
`${effort ? `, effort: ${effort} — ${effortSource}` : ""}` +
|
|
1237
|
+
// Say it every time, so a limit is never a surprise and its absence
|
|
1238
|
+
// is a choice on the record, not an accident.
|
|
1239
|
+
`${step.timeout ? `, timeout: ${step.timeout}s` : ", no timeout"})`);
|
|
1240
|
+
// The run-start gate, when a gateway is in play. "Any model they want"
|
|
1241
|
+
// is only an offer if a wrong pick fails here, in one line naming the
|
|
1242
|
+
// fix — not minutes in, as an agent narrating tool calls into prose
|
|
1243
|
+
// because its model cannot make them. The catalogue only ever fails a
|
|
1244
|
+
// step on knowledge; unknown ids, presets and offline all pass through
|
|
1245
|
+
// to the model call, which stays the authority of last resort.
|
|
1246
|
+
let catalog = null;
|
|
1247
|
+
let tokenPrice = null;
|
|
1248
|
+
// The env this run's model calls actually ride: the workspace's own
|
|
1249
|
+
// provider block when one is declared, otherwise the platform's — the
|
|
1250
|
+
// "models included" path, where the host env points every run at the
|
|
1251
|
+
// platform's gateway and users only ever pick a model. The gate, the
|
|
1252
|
+
// effort fit and the repricing follow the credential, not the file:
|
|
1253
|
+
// keying them off the provider block alone left the platform-provided
|
|
1254
|
+
// path — the main one — without any of them.
|
|
1255
|
+
const rideEnv = Object.keys(providerEnv).length > 0 ? providerEnv : process.env;
|
|
1256
|
+
// Through the translator the endpoint is localhost and the model id is
|
|
1257
|
+
// the provider's own, sent as written; there is no catalogue to ask.
|
|
1258
|
+
if (translator)
|
|
1259
|
+
push("info", `translator: ${translator.label} — Anthropic Messages in, Chat Completions out`);
|
|
1260
|
+
// What actually goes on the wire: a tier the gateway remapped is sent
|
|
1261
|
+
// as the gateway's id, and that id — not our tier word — is what the
|
|
1262
|
+
// catalogue can have an opinion about.
|
|
1263
|
+
const wireModel = { haiku: rideEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
|
1264
|
+
sonnet: rideEnv.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
1265
|
+
opus: rideEnv.ANTHROPIC_DEFAULT_OPUS_MODEL }[model] ?? model;
|
|
1266
|
+
if (rideEnv.ANTHROPIC_BASE_URL && !translator) {
|
|
1267
|
+
catalog = await loadCatalog(rideEnv.ANTHROPIC_BASE_URL);
|
|
1268
|
+
const needsTools = allowed.length > 0 || mcpNames.length > 0;
|
|
1269
|
+
const verdict = checkModel(catalog, wireModel, { tools: needsTools });
|
|
1270
|
+
// Also what a token costs here, so a step can count its own spend
|
|
1271
|
+
// turn by turn against its ceiling instead of learning at the end.
|
|
1272
|
+
const priced = findModel(catalog, wireModel);
|
|
1273
|
+
if (priced && priced.promptPrice != null && priced.completionPrice != null) {
|
|
1274
|
+
tokenPrice = { input: priced.promptPrice, output: priced.completionPrice };
|
|
1275
|
+
}
|
|
1276
|
+
if (!verdict.ok) {
|
|
1277
|
+
push("error", verdict.reason);
|
|
1278
|
+
step.status = "failed";
|
|
1279
|
+
save();
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
// Effort, fitted to what this model actually takes: unchanged when the
|
|
1283
|
+
// level is supported, the nearest supported level when it isn't, gone
|
|
1284
|
+
// when the model doesn't reason — each departure said aloud. This is
|
|
1285
|
+
// what makes `effort:` portable rather than Anthropic-only: the word
|
|
1286
|
+
// in the file stays the intent, the wire carries what the model has.
|
|
1287
|
+
const fitted = clampEffort(catalog, wireModel, effort);
|
|
1288
|
+
if (fitted.note)
|
|
1289
|
+
push("info", fitted.note);
|
|
1290
|
+
effort = fitted.effort;
|
|
1291
|
+
}
|
|
1292
|
+
fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
|
|
1293
|
+
fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
|
|
1294
|
+
// Expose the workspace's shared scripts/ as ./shared so every agent reaches
|
|
1295
|
+
// them by the same path, without each one carrying a copy.
|
|
1296
|
+
// No symlinks into the agent directory — they would recurse into the
|
|
1297
|
+
// workspace's own file listing. Shared paths are resolved by the script
|
|
1298
|
+
// tool layer and described to the agent below.
|
|
1299
|
+
// The confinement boundary: agents in a workspace are one team, but the
|
|
1300
|
+
// workspace is where isolation is enforced.
|
|
1301
|
+
const workspaceRoot = path.resolve(agentDir, "..", "..");
|
|
1302
|
+
let prompt = step.instruction
|
|
1303
|
+
? resolveDocLinks(step.instruction, workspaceRoot)
|
|
1304
|
+
: "Begin your run now, following your instructions.";
|
|
1305
|
+
if (context)
|
|
1306
|
+
prompt += `\n\n<previous_step_results>\n${context.slice(0, CONTEXT_CHARS)}\n</previous_step_results>`;
|
|
1307
|
+
if (priorData !== undefined) {
|
|
1308
|
+
// The value itself, beside the prose that mentioned it. This is the
|
|
1309
|
+
// lossless half of a handoff: a URL step 1 returned reaches step 3 as
|
|
1310
|
+
// the URL, not as whatever a re-reading of step 1's paragraph yields.
|
|
1311
|
+
const json = JSON.stringify(priorData, null, 2);
|
|
1312
|
+
prompt +=
|
|
1313
|
+
`\n\n<previous_step_data>\n${json.length > 60000 ? `${json.slice(0, 60000)}\n…[truncated]` : json}\n</previous_step_data>`;
|
|
1314
|
+
}
|
|
1315
|
+
if (step.output === "json") {
|
|
1316
|
+
// The contract the runner parses back out — stated in the prompt so
|
|
1317
|
+
// the instruction and the parser agree by construction, like delegate:.
|
|
1318
|
+
prompt +=
|
|
1319
|
+
`\n\n<output>\nThis step returns data. End your reply with exactly one JSON value inside a ` +
|
|
1320
|
+
"```json fenced block — the value the next step will compute on. Prose before it is " +
|
|
1321
|
+
`welcome (lead with a one-line headline); nothing may follow the block. If the instruction ` +
|
|
1322
|
+
`names fields, use exactly those names.\n</output>`;
|
|
1323
|
+
}
|
|
1324
|
+
if (step.waitFor === "event" && step.eventPayload !== undefined) {
|
|
1325
|
+
// Not resolved for links, like the previous step's results: it is
|
|
1326
|
+
// whatever the outside world sent, and must not name a file by
|
|
1327
|
+
// happening to contain brackets.
|
|
1328
|
+
prompt += `\n\n<event>\nThis step waited for an external event. What arrived:\n${step.eventPayload}\n</event>`;
|
|
1329
|
+
}
|
|
1330
|
+
if (step.approvalNote) {
|
|
1331
|
+
// The person at the gate said something while letting the run through.
|
|
1332
|
+
// That is the freshest instruction the step has — later than the flow
|
|
1333
|
+
// file, aimed at this exact run. When the gate asked a question
|
|
1334
|
+
// (ask:), the note IS the answer and is framed as one.
|
|
1335
|
+
//
|
|
1336
|
+
// Both halves go through resolveDocLinks for the same reason the
|
|
1337
|
+
// instruction above does: this is authored text on its way to becoming
|
|
1338
|
+
// a prompt, and the rule is that ALL of it expands. A person answering
|
|
1339
|
+
// "which list?" with [[dead-ends]] was sending the model two literal
|
|
1340
|
+
// brackets — the one reference syntax the workspace teaches, and the
|
|
1341
|
+
// one place it silently did nothing.
|
|
1342
|
+
//
|
|
1343
|
+
// What is NOT resolved, deliberately: the previous step's results
|
|
1344
|
+
// above. That is model output, not something a person wrote, and a
|
|
1345
|
+
// model that happens to emit [[brackets]] must not thereby name a file.
|
|
1346
|
+
const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
|
|
1347
|
+
const note = resolveDocLinks(step.approvalNote, workspaceRoot);
|
|
1348
|
+
prompt += step.ask
|
|
1349
|
+
? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
|
|
1350
|
+
: `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
|
|
1351
|
+
}
|
|
1352
|
+
if (step.delegate?.length) {
|
|
1353
|
+
// The chooser's contract. The allowed set comes from the flow file;
|
|
1354
|
+
// the runner parses exactly this shape back out of the result, so the
|
|
1355
|
+
// instruction and the parser agree by construction.
|
|
1356
|
+
prompt +=
|
|
1357
|
+
`\n\n<delegation>\nAfter your analysis, end your reply with one line per agent you are delegating to, ` +
|
|
1358
|
+
`chosen ONLY from: ${step.delegate.join(", ")}.\nFormat, one per line (at most 5):\n` +
|
|
1359
|
+
`agent-name: the instruction for that agent\n` +
|
|
1360
|
+
`Lines naming any other agent are ignored.\n</delegation>`;
|
|
1361
|
+
}
|
|
1362
|
+
if (step.item) {
|
|
1363
|
+
// A fan-out instance: same instruction as its siblings, one item each.
|
|
1364
|
+
prompt += `\n\n<item>\nOf everything above, this instance of the step handles exactly one item — this one:\n${step.item}\n</item>`;
|
|
1365
|
+
}
|
|
1366
|
+
// Colleagues this agent may consult mid-turn — declared, like every
|
|
1367
|
+
// other capability.
|
|
1368
|
+
const { consults, missing: missingConsults } = gatherConsults(workspaceRoot, front.agents);
|
|
1369
|
+
for (const m of missingConsults) {
|
|
1370
|
+
push("error", `agents: "${m}" is not an agent in this workspace — no consult tool granted`);
|
|
1371
|
+
}
|
|
1372
|
+
const consultNames = consults.length ? [...mcpNames, "foldrun_agents"] : mcpNames;
|
|
1373
|
+
// The last provider refusal this step saw, for the fallback decision —
|
|
1374
|
+
// watched on both paths, because a refusal reads the same from a pod
|
|
1375
|
+
// and from this process.
|
|
1376
|
+
let lastRefusal = "";
|
|
1377
|
+
const pushWatching = (type, text, extra) => {
|
|
1378
|
+
if (type === "error" && isProviderRefusal(text))
|
|
1379
|
+
lastRefusal = text;
|
|
1380
|
+
push(type, text, extra);
|
|
1381
|
+
};
|
|
1382
|
+
// The second supply: the block's own fallback when it declared one (a
|
|
1383
|
+
// BYOK step's provider is the customer's arrangement, and so is its
|
|
1384
|
+
// fallback), else the platform's, and only for a step riding the
|
|
1385
|
+
// platform credential.
|
|
1386
|
+
const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
|
|
1387
|
+
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
1388
|
+
if (isolatedRun()) {
|
|
1389
|
+
// The isolated path: the whole loop — model, built-in tools, scripts —
|
|
1390
|
+
// runs inside a throwaway container (a docker sibling, or a pod), and
|
|
1391
|
+
// only filtered file changes come back. The vault stays out here:
|
|
1392
|
+
// secrets are substituted into API headers before the specs cross,
|
|
1393
|
+
// and reach scripts as env.
|
|
1394
|
+
push("info", `isolation: ${isolation}`);
|
|
1395
|
+
// The *merged* lists — inline declarations plus everything `tools:`
|
|
1396
|
+
// granted — not a re-parse of the frontmatter. Rebuilding from `front`
|
|
1397
|
+
// here silently dropped every tools:-granted http and script tool on
|
|
1398
|
+
// the isolated path (mcp grants survived only because mcpServers is
|
|
1399
|
+
// passed as the merged object): the agent's prompt promised a tool
|
|
1400
|
+
// the function list didn't have, and the step failed as the model
|
|
1401
|
+
// politely reporting the gap.
|
|
1402
|
+
// The egress lease: the sandbox sends through the worker, which holds
|
|
1403
|
+
// the values and fills each ${NAME} for the host that secret was
|
|
1404
|
+
// granted to. Built from what this step declared — every http tool's
|
|
1405
|
+
// secrets for that tool's host, the model key for the provider's —
|
|
1406
|
+
// before anything is copied in. Null where no proxy is configured
|
|
1407
|
+
// (the CLI, a compose install without FOLDRUN_EGRESS_URL), and then
|
|
1408
|
+
// everything below materialises exactly as it always did.
|
|
1409
|
+
const grant = { secrets: {} };
|
|
1410
|
+
for (const api of apiSpecs) {
|
|
1411
|
+
const host = hostOf(api.base);
|
|
1412
|
+
if (!host)
|
|
1413
|
+
continue;
|
|
1414
|
+
for (const v of [...Object.values(api.headers), ...Object.values(api.query)]) {
|
|
1415
|
+
for (const name of placeholderNames(v))
|
|
1416
|
+
if (name in liveSecrets)
|
|
1417
|
+
addGrant(grant, name, liveSecrets[name], host);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant });
|
|
1421
|
+
// Which secrets must still be real inside the sandbox: a script tool
|
|
1422
|
+
// reads them from its environment, and unless it says `secrets:
|
|
1423
|
+
// proxied` it needs the value, not the name. bash is a script by
|
|
1424
|
+
// another route. The record says which way it went, and why.
|
|
1425
|
+
const materialisers = [
|
|
1426
|
+
...scriptSpecs.filter((sc) => sc.secrets !== "proxied").map((sc) => sc.name),
|
|
1427
|
+
...(allowed.includes("Bash") ? ["bash"] : []),
|
|
1428
|
+
];
|
|
1429
|
+
const materialise = !lease || materialisers.length > 0;
|
|
1430
|
+
const declared = Object.keys(liveSecrets);
|
|
1431
|
+
if (lease) {
|
|
1432
|
+
push("info", declared.length
|
|
1433
|
+
? materialise
|
|
1434
|
+
? `secrets: ${declared.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}; api tools and the model key go through the egress proxy`
|
|
1435
|
+
: `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names`
|
|
1436
|
+
: "secrets: none declared; the model key goes through the egress proxy");
|
|
1437
|
+
}
|
|
1438
|
+
// Sandbox env for the declared secrets: the value, or its own name as
|
|
1439
|
+
// a placeholder that the proxy fills on the way out.
|
|
1440
|
+
const sandboxSecrets = materialise
|
|
1441
|
+
? liveSecrets
|
|
1442
|
+
: Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
|
|
1443
|
+
const substitutedApis = apiSpecs.map((api) => ({
|
|
1444
|
+
...api,
|
|
1445
|
+
headers: Object.fromEntries(Object.entries(api.headers).map(([k, v]) => [
|
|
1446
|
+
k,
|
|
1447
|
+
v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => sandboxSecrets[name] ?? whole),
|
|
1448
|
+
])),
|
|
1449
|
+
}));
|
|
1450
|
+
// The provider's own key and headers, when the step has a lease: the
|
|
1451
|
+
// key becomes ${FOLDRUN_MODEL_KEY}, the base URL goes via the proxy,
|
|
1452
|
+
// and a translator's upstream request does the same. The fallback
|
|
1453
|
+
// supply gets its own placeholder name — two keys, two names.
|
|
1454
|
+
const proxied = (env, name) => lease ? proxyModelEnv(env, lease.url, grant, providerSecrets, name) : env;
|
|
1455
|
+
const proxiedTranslator = (spec, name) => {
|
|
1456
|
+
if (!spec || !lease)
|
|
1457
|
+
return spec;
|
|
1458
|
+
const host = hostOf(spec.upstreamBase);
|
|
1459
|
+
if (!host)
|
|
1460
|
+
return spec;
|
|
1461
|
+
addGrant(grant, name, spec.upstreamKey, host);
|
|
1462
|
+
return {
|
|
1463
|
+
...spec,
|
|
1464
|
+
egress: lease.url,
|
|
1465
|
+
upstreamKey: `\${${name}}`,
|
|
1466
|
+
headers: Object.fromEntries(Object.entries(spec.headers ?? {}).map(([k, v]) => [k, unsubstitute(v, providerSecrets, host, grant)])),
|
|
1467
|
+
};
|
|
1468
|
+
};
|
|
1469
|
+
const primaryTranslator = proxiedTranslator(translator, MODEL_KEY_NAME);
|
|
1470
|
+
const secondTranslator = proxiedTranslator(fallbackTranslator, `${MODEL_KEY_NAME}_FALLBACK`);
|
|
1471
|
+
const runIsolated = isolation === "container" ? runStepInContainer : platform.isolation[isolation];
|
|
1472
|
+
const isolatedArgs = (modelEnv, keyName = MODEL_KEY_NAME) => ({
|
|
1473
|
+
workspaceRoot,
|
|
1474
|
+
libraryRoot: libraryDir(tenant),
|
|
1475
|
+
// Keys this step's dependency cache. Per-account by design — see
|
|
1476
|
+
// run-container.ts#runtimeCacheDir for why it is not shared.
|
|
1477
|
+
tenant,
|
|
1478
|
+
input: {
|
|
1479
|
+
agentRel: path.relative(workspaceRoot, agentDir).replaceAll("\\", "/"),
|
|
1480
|
+
prompt,
|
|
1481
|
+
model,
|
|
1482
|
+
effort,
|
|
1483
|
+
systemPrompt,
|
|
1484
|
+
allowed,
|
|
1485
|
+
mcpNames: consultNames,
|
|
1486
|
+
mcpServers,
|
|
1487
|
+
apis: substitutedApis,
|
|
1488
|
+
scripts: scriptSpecs,
|
|
1489
|
+
// The merged spec, not `front.runtime` — see the plan's `runtime`
|
|
1490
|
+
// field. A tool's own `runtime:` is part of what this step needs.
|
|
1491
|
+
runtime: runtime.spec,
|
|
1492
|
+
consults,
|
|
1493
|
+
timeoutSec: step.timeout,
|
|
1494
|
+
budgetUsd: stepBudgetUsd,
|
|
1495
|
+
price: tokenPrice,
|
|
1496
|
+
verify: step.verify,
|
|
1497
|
+
output: step.output,
|
|
1498
|
+
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
1499
|
+
// The container sees the workspace at /workspace and the library
|
|
1500
|
+
// at /library; the roots are named host-side and moved here.
|
|
1501
|
+
search: searchRoots.map((r) => ({
|
|
1502
|
+
label: r.label,
|
|
1503
|
+
dir: r.dir.startsWith(libraryDir(tenant))
|
|
1504
|
+
? path.posix.join("/library", path.relative(libraryDir(tenant), r.dir).replaceAll("\\", "/"))
|
|
1505
|
+
: path.posix.join("/workspace", path.relative(workspaceRoot, r.dir).replaceAll("\\", "/")),
|
|
1506
|
+
})),
|
|
1507
|
+
history: historyDigest,
|
|
1508
|
+
desks: deskDigest,
|
|
1509
|
+
},
|
|
1510
|
+
env: Object.fromEntries(Object.entries({
|
|
1511
|
+
// The platform's own model credential, when the agent names no
|
|
1512
|
+
// provider of its own — this is "models included": the host
|
|
1513
|
+
// process holds one key, and every isolated run borrows it.
|
|
1514
|
+
// A provider: block still wins, because providerEnv is spread
|
|
1515
|
+
// after and carries the agent's chosen endpoint + token.
|
|
1516
|
+
...proxied(Object.keys(providerEnv).length === 0 ? modelEnv : providerEnv, keyName),
|
|
1517
|
+
...(lease ? { [EGRESS_ENV]: lease.url } : {}),
|
|
1518
|
+
...clockEnv,
|
|
1519
|
+
// Who is running: the in-process path stamps scripts with these
|
|
1520
|
+
// host-side, but a container rebuilds its tools from its own
|
|
1521
|
+
// environment, so without them here every script and verify in
|
|
1522
|
+
// the pod ran anonymous — and a verify comparing a marker to
|
|
1523
|
+
// $FOLDRUN_RUN_ID compared it to nothing.
|
|
1524
|
+
FOLDRUN_RUN_ID: runId,
|
|
1525
|
+
FOLDRUN_AGENT: step.agent,
|
|
1526
|
+
FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
|
|
1527
|
+
...sandboxSecrets,
|
|
1528
|
+
}).filter((entry) => typeof entry[1] === "string")),
|
|
1529
|
+
emit: pushWatching,
|
|
1530
|
+
// Stamps the sandbox, so stopping this run can destroy it.
|
|
1531
|
+
runId,
|
|
1532
|
+
size,
|
|
1533
|
+
});
|
|
1534
|
+
// A busy provider gets another moment on the SAME supply before the
|
|
1535
|
+
// fallback is considered: under a fan-out, twenty steps against one
|
|
1536
|
+
// key are told "too many requests" together, and failing them all is
|
|
1537
|
+
// a broken run where a few seconds would have been a slow one. Each
|
|
1538
|
+
// attempt holds its own sandbox, so the meter owes the sum.
|
|
1539
|
+
const firstArgs = isolatedArgs(platformModelEnv());
|
|
1540
|
+
await lease?.commit();
|
|
1541
|
+
let outcome = await runIsolated(firstArgs);
|
|
1542
|
+
for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
|
|
1543
|
+
const wait = backoffMs(n, lastRefusal);
|
|
1544
|
+
push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
|
|
1545
|
+
await sleep(wait);
|
|
1546
|
+
// Judged fresh: the next attempt's own failure decides what happens
|
|
1547
|
+
// next, not the one that sent us here.
|
|
1548
|
+
lastRefusal = "";
|
|
1549
|
+
const previous = outcome.timing;
|
|
1550
|
+
const again = isolatedArgs(platformModelEnv());
|
|
1551
|
+
await lease?.commit();
|
|
1552
|
+
outcome = await runIsolated(again);
|
|
1553
|
+
outcome = withEarlierTiming(outcome, previous);
|
|
1554
|
+
}
|
|
1555
|
+
// The second supply, tried exactly once, and only when the primary
|
|
1556
|
+
// refused over money/auth/limits rather than the work failing. The
|
|
1557
|
+
// retry is a fresh sandbox — the failed one is gone, and driveRun's
|
|
1558
|
+
// whole contract is that re-driving is safe. A declared fallback
|
|
1559
|
+
// replaces the provider block's env wholesale, so the spread order in
|
|
1560
|
+
// isolatedArgs (providerEnv last) has to be overridden here.
|
|
1561
|
+
if (outcome.status === "failed" && lastRefusal && secondSupply) {
|
|
1562
|
+
push("info", `primary model supply refused (${lastRefusal.slice(0, 80)}) — retrying this step on the fallback provider`);
|
|
1563
|
+
const first = outcome.timing;
|
|
1564
|
+
const fallbackName = `${MODEL_KEY_NAME}_FALLBACK`;
|
|
1565
|
+
const retryArgs = isolatedArgs(secondSupply, fallbackName);
|
|
1566
|
+
// The fallback's own translator, or none: a fallback that speaks
|
|
1567
|
+
// Anthropic must not inherit the primary's Chat-Completions door.
|
|
1568
|
+
const fallbackArgs = {
|
|
1569
|
+
...retryArgs,
|
|
1570
|
+
input: { ...retryArgs.input, translator: fallbackEnv && secondSupply === fallbackEnv ? secondTranslator : null },
|
|
1571
|
+
env: Object.fromEntries(Object.entries({ ...retryArgs.env, ...proxied(secondSupply, fallbackName) })
|
|
1572
|
+
.filter((entry) => typeof entry[1] === "string")),
|
|
1573
|
+
};
|
|
1574
|
+
await lease?.commit();
|
|
1575
|
+
outcome = await runIsolated(fallbackArgs);
|
|
1576
|
+
// Both attempts held sandboxes; the meter owes the sum. The first
|
|
1577
|
+
// try's pod ran, was billed for by the platform, and must not
|
|
1578
|
+
// vanish from the record because a second try replaced its outcome.
|
|
1579
|
+
outcome = withEarlierTiming(outcome, first);
|
|
1580
|
+
}
|
|
1581
|
+
if (lease) {
|
|
1582
|
+
for (const line of await lease.drainLog())
|
|
1583
|
+
push("info", line);
|
|
1584
|
+
await lease.release();
|
|
1585
|
+
}
|
|
1586
|
+
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
1587
|
+
step.status = outcome.status;
|
|
1588
|
+
step.result = outcome.result;
|
|
1589
|
+
step.conclusion = outcome.conclusion;
|
|
1590
|
+
if (outcome.data !== undefined)
|
|
1591
|
+
step.data = outcome.data;
|
|
1592
|
+
step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
|
|
1593
|
+
step.tokens = outcome.usage
|
|
1594
|
+
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
1595
|
+
: null;
|
|
1596
|
+
step.actual = outcome.res ?? null;
|
|
1597
|
+
if (outcome.res?.peakMemBytes || outcome.res?.rxBytes) {
|
|
1598
|
+
const mb = (n) => (n === null ? "?" : (n / 1024 / 1024).toFixed(0));
|
|
1599
|
+
push("info", `resources: cpu busy ${outcome.res.busyCpuSecs?.toFixed(1) ?? "?"}s · ` +
|
|
1600
|
+
`peak mem ${mb(outcome.res.peakMemBytes)}MB · ` +
|
|
1601
|
+
`net ${mb(outcome.res.rxBytes)}MB in / ${mb(outcome.res.txBytes)}MB out`);
|
|
1602
|
+
}
|
|
1603
|
+
// The compute meter, recorded on the step rather than derived later:
|
|
1604
|
+
// the sandbox is gone by the time the run settles, and its lifetime
|
|
1605
|
+
// is not recoverable from the run's own timestamps.
|
|
1606
|
+
const secs = (ms) => Math.round(ms) / 1000;
|
|
1607
|
+
step.computeSecs = outcome.timing ? secs(outcome.timing.totalMs) : null;
|
|
1608
|
+
step.startupSecs = outcome.timing ? secs(outcome.timing.sandboxMs) : null;
|
|
1609
|
+
// Which reservation those seconds held — as numbers, because the bill
|
|
1610
|
+
// is per core-second and per GiB-second, and what was held must come
|
|
1611
|
+
// from the record, never from what the config says at billing time.
|
|
1612
|
+
step.size = size;
|
|
1613
|
+
const lim = sizeLimits(size);
|
|
1614
|
+
const gib = (() => {
|
|
1615
|
+
const m = lim.memory.match(/^(\d+(?:\.\d+)?)\s*(Gi?|Mi?|g|m)?/i);
|
|
1616
|
+
const n = m ? Number(m[1]) : 2;
|
|
1617
|
+
return (m?.[2] ?? "Gi").toLowerCase().startsWith("m") ? n / 1024 : n;
|
|
1618
|
+
})();
|
|
1619
|
+
step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
|
|
1620
|
+
}
|
|
1621
|
+
else {
|
|
1622
|
+
const consultTools = buildConsultTools(consults, { ...process.env, ...liveSecrets, ...providerEnv }, (type, text) => push(type, text));
|
|
1623
|
+
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
1624
|
+
// finally — once, however many attempts follow.
|
|
1625
|
+
const mat = materializeFileSecrets(agentDir, liveSecrets);
|
|
1626
|
+
fileDir = mat.dir;
|
|
1627
|
+
const attemptInProcess = (modelEnv) => executeStep({
|
|
1628
|
+
agentDir,
|
|
1629
|
+
workspaceRoot,
|
|
1630
|
+
libraryRoot: libraryDir(tenant),
|
|
1631
|
+
prompt,
|
|
1632
|
+
model,
|
|
1633
|
+
effort,
|
|
1634
|
+
systemPrompt,
|
|
1635
|
+
allowed,
|
|
1636
|
+
mcpNames: consultNames,
|
|
1637
|
+
mcpServers: {
|
|
1638
|
+
...(apiTools.server ? { foldrun_apis: apiTools.server } : {}),
|
|
1639
|
+
...(scriptTools.server ? { foldrun_scripts: scriptTools.server } : {}),
|
|
1640
|
+
...(consultTools.server ? { foldrun_agents: consultTools.server } : {}),
|
|
1641
|
+
...(searchTools.server ? { foldrun_search: searchTools.server } : {}),
|
|
1642
|
+
...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
|
|
1643
|
+
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
1644
|
+
...mcpServers,
|
|
1645
|
+
},
|
|
1646
|
+
// Declared secrets reach the agent's scripts as env vars; the model
|
|
1647
|
+
// only ever sees the variable names, not the values.
|
|
1648
|
+
env: { ...process.env, ...clockEnv, ...mat.env, ...modelEnv },
|
|
1649
|
+
timeoutSec: step.timeout,
|
|
1650
|
+
budgetUsd: stepBudgetUsd,
|
|
1651
|
+
price: tokenPrice,
|
|
1652
|
+
verify: step.verify,
|
|
1653
|
+
output: step.output,
|
|
1654
|
+
// What the scripts saw, the verify sees: a flow can then check that
|
|
1655
|
+
// the proof a step left names THIS run, not one that came before —
|
|
1656
|
+
// copy-back never propagates a deletion, so a marker from an earlier
|
|
1657
|
+
// run rides into every later sandbox.
|
|
1658
|
+
verifyEnv: {
|
|
1659
|
+
...clockEnv,
|
|
1660
|
+
...(runId ? { FOLDRUN_RUN_ID: runId } : {}),
|
|
1661
|
+
FOLDRUN_AGENT: step.agent,
|
|
1662
|
+
FOLDRUN_WORKSPACE: path.basename(path.resolve(agentDir, "..", "..")),
|
|
1663
|
+
...liveSecrets,
|
|
1664
|
+
},
|
|
1665
|
+
emit: pushWatching,
|
|
1666
|
+
});
|
|
1667
|
+
// With a translator the step's endpoint is the translator: started
|
|
1668
|
+
// here, pointed at by the SDK's env, closed when the attempt ends.
|
|
1669
|
+
const attemptThrough = async (modelEnv, spec) => {
|
|
1670
|
+
if (!spec)
|
|
1671
|
+
return attemptInProcess(modelEnv);
|
|
1672
|
+
const t = await startTranslator(spec);
|
|
1673
|
+
try {
|
|
1674
|
+
return await attemptInProcess({ ...modelEnv, ...t.env });
|
|
1675
|
+
}
|
|
1676
|
+
finally {
|
|
1677
|
+
for (const line of t.drainLog())
|
|
1678
|
+
push("info", line.startsWith("translator") ? line : `translator: ${line}`);
|
|
1679
|
+
await t.close();
|
|
1680
|
+
}
|
|
1681
|
+
};
|
|
1682
|
+
let outcome = await attemptThrough(providerEnv, translator);
|
|
1683
|
+
// Same rule as the isolated path: a busy provider is waited out on
|
|
1684
|
+
// the supply it refused from, before any fallback is considered.
|
|
1685
|
+
for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
|
|
1686
|
+
const wait = backoffMs(n, lastRefusal);
|
|
1687
|
+
push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
|
|
1688
|
+
await sleep(wait);
|
|
1689
|
+
lastRefusal = "";
|
|
1690
|
+
outcome = await attemptThrough(providerEnv, translator);
|
|
1691
|
+
}
|
|
1692
|
+
// Same rule as the isolated path: one retry, on a refusal, on the
|
|
1693
|
+
// declared or platform second supply.
|
|
1694
|
+
if (outcome.status === "failed" && lastRefusal && secondSupply) {
|
|
1695
|
+
push("info", `primary model supply refused (${lastRefusal.slice(0, 80)}) — retrying this step on the fallback provider`);
|
|
1696
|
+
outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
|
|
1697
|
+
}
|
|
1698
|
+
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
1699
|
+
step.status = outcome.status;
|
|
1700
|
+
step.result = outcome.result;
|
|
1701
|
+
step.conclusion = outcome.conclusion;
|
|
1702
|
+
if (outcome.data !== undefined)
|
|
1703
|
+
step.data = outcome.data;
|
|
1704
|
+
// A consult's spend belongs to the step that asked.
|
|
1705
|
+
const consultCost = consultTools.drainCost();
|
|
1706
|
+
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
|
|
1707
|
+
step.costUsd = stepCost === null && consultCost === 0 ? null : (stepCost ?? 0) + consultCost;
|
|
1708
|
+
step.tokens = outcome.usage
|
|
1709
|
+
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
1710
|
+
: null;
|
|
1711
|
+
for (const line of apiTools.drainLog())
|
|
1712
|
+
push("info", `api: ${line}`);
|
|
1713
|
+
for (const line of scriptTools.drainLog())
|
|
1714
|
+
push("info", `script: ${line}`);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
catch (err) {
|
|
1718
|
+
step.status = "failed";
|
|
1719
|
+
push("error", err instanceof Error ? err.message : String(err));
|
|
1720
|
+
}
|
|
1721
|
+
finally {
|
|
1722
|
+
try {
|
|
1723
|
+
cleanupFileSecrets(fileDir);
|
|
1724
|
+
}
|
|
1725
|
+
catch {
|
|
1726
|
+
// best-effort — a leftover scratch file is swept with the run's outputs
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
step.finishedAt = new Date().toISOString();
|
|
1730
|
+
save();
|
|
1731
|
+
}
|
|
1732
|
+
// Block until a human approves or rejects the pending steps. Polls the run
|
|
1733
|
+
// record — the same file the dashboard writes through the approval API — so
|
|
1734
|
+
// no in-memory queue has to survive a restart. For as long as it takes: a
|
|
1735
|
+
// person answering on the 25th hour used to find the run had given up on
|
|
1736
|
+
// them at the 24th, and no clock of the platform's gets to decide that.
|
|
1737
|
+
async function waitForDecision(tenant, workspace, runId, indexes) {
|
|
1738
|
+
for (;;) {
|
|
1739
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1740
|
+
const latest = readRun(tenant, workspace, runId);
|
|
1741
|
+
if (!latest)
|
|
1742
|
+
return false;
|
|
1743
|
+
const stillWaiting = indexes.some((i) => latest.steps[i]?.status === "awaiting-approval");
|
|
1744
|
+
if (!stillWaiting)
|
|
1745
|
+
return true;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Copy a directory tree by reading and writing the bytes.
|
|
1750
|
+
*
|
|
1751
|
+
* Not fs.cpSync: that copies through the kernel's clone/copy_file_range
|
|
1752
|
+
* path, which on a bind-mounted or network volume — exactly where a hosted
|
|
1753
|
+
* install keeps its data — can report success and leave a zero-length file
|
|
1754
|
+
* behind. Every archived run output was empty, and nothing failed to say
|
|
1755
|
+
* so, which is the worst shape a bug can take. This works on every
|
|
1756
|
+
* filesystem, and normalises the mode while it is here: what an agent
|
|
1757
|
+
* produced belongs to the platform, not to the container umask that
|
|
1758
|
+
* happened to create it.
|
|
1759
|
+
*/
|
|
1760
|
+
export function copyTreeBytes(from, to) {
|
|
1761
|
+
fs.mkdirSync(to, { recursive: true });
|
|
1762
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
1763
|
+
const src = path.join(from, entry.name);
|
|
1764
|
+
const dst = path.join(to, entry.name);
|
|
1765
|
+
if (entry.isDirectory())
|
|
1766
|
+
copyTreeBytes(src, dst);
|
|
1767
|
+
else if (entry.isFile())
|
|
1768
|
+
fs.writeFileSync(dst, fs.readFileSync(src), { mode: 0o644 });
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
// Which of the host's own credentials an isolated run may borrow. Only the
|
|
1772
|
+
// model credential — a run gets the ability to think on the platform's
|
|
1773
|
+
// account, never the platform's other secrets, which is the entire reason
|
|
1774
|
+
// the container env is allowlisted instead of inheriting process.env.
|
|
1775
|
+
/**
|
|
1776
|
+
* The platform's second model supply, tried when the first refuses over
|
|
1777
|
+
* money or auth. Order is a cost decision: a flat-rate subscription as the
|
|
1778
|
+
* engine, pay-per-token only when it runs dry —
|
|
1779
|
+
*
|
|
1780
|
+
* FOLDRUN_FALLBACK_BASE_URL e.g. https://openrouter.ai/api
|
|
1781
|
+
* FOLDRUN_FALLBACK_TOKEN bearer for that endpoint
|
|
1782
|
+
* FOLDRUN_FALLBACK_HAIKU/SONNET/OPUS_MODEL tier ids in ITS catalogue
|
|
1783
|
+
*
|
|
1784
|
+
* Empty ANTHROPIC_API_KEY on purpose: set, it goes out as x-api-key and a
|
|
1785
|
+
* gateway rejects it; the fallback's whole credential is the bearer.
|
|
1786
|
+
*/
|
|
1787
|
+
/**
|
|
1788
|
+
* Every `openapi:` URL the agent's API tools name — its own `apis:` and the
|
|
1789
|
+
* http tools it opted into — so they can be fetched before the context is
|
|
1790
|
+
* assembled. File paths need no fetching and are left out.
|
|
1791
|
+
*/
|
|
1792
|
+
function openApiSources(agentDir, tenant) {
|
|
1793
|
+
const out = new Set();
|
|
1794
|
+
try {
|
|
1795
|
+
const { data } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
|
|
1796
|
+
const front = data;
|
|
1797
|
+
for (const api of parseApis(front.apis))
|
|
1798
|
+
if (api.openapi?.startsWith("https://"))
|
|
1799
|
+
out.add(api.openapi);
|
|
1800
|
+
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
1801
|
+
const available = { ...libraryTools(tenant), ...workspaceTools(tenant, workspace) };
|
|
1802
|
+
for (const name of ownToolNames(front)) {
|
|
1803
|
+
const def = available[name];
|
|
1804
|
+
if (def?.kind === "http" && def.spec.openapi?.startsWith("https://"))
|
|
1805
|
+
out.add(def.spec.openapi);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
catch {
|
|
1809
|
+
// an unreadable agent.md fails the step properly a moment later
|
|
1810
|
+
}
|
|
1811
|
+
return [...out];
|
|
1812
|
+
}
|
|
1813
|
+
function platformFallbackEnv() {
|
|
1814
|
+
const base = process.env.FOLDRUN_FALLBACK_BASE_URL;
|
|
1815
|
+
const token = process.env.FOLDRUN_FALLBACK_TOKEN;
|
|
1816
|
+
if (!base || !token)
|
|
1817
|
+
return null;
|
|
1818
|
+
const out = {
|
|
1819
|
+
ANTHROPIC_BASE_URL: base,
|
|
1820
|
+
ANTHROPIC_AUTH_TOKEN: token,
|
|
1821
|
+
ANTHROPIC_API_KEY: "",
|
|
1822
|
+
CLAUDE_CODE_OAUTH_TOKEN: "",
|
|
1823
|
+
};
|
|
1824
|
+
for (const tier of ["HAIKU", "SONNET", "OPUS"]) {
|
|
1825
|
+
const id = process.env[`FOLDRUN_FALLBACK_${tier}_MODEL`];
|
|
1826
|
+
if (id)
|
|
1827
|
+
out[`ANTHROPIC_DEFAULT_${tier}_MODEL`] = id;
|
|
1828
|
+
}
|
|
1829
|
+
return out;
|
|
1830
|
+
}
|
|
1831
|
+
/** A model-call failure the fallback can answer: the primary refusing over
|
|
1832
|
+
* credit, quota or auth — never a failure of the work itself, which would
|
|
1833
|
+
* fail identically anywhere. */
|
|
1834
|
+
function isProviderRefusal(text) {
|
|
1835
|
+
// Money and auth words only, plus the API's own status codes. The first
|
|
1836
|
+
// version matched bare "exceed", which is also how the pod backstop
|
|
1837
|
+
// phrases a timeout — and a timed-out step got retried on the paid
|
|
1838
|
+
// fallback it could only time out on again. A refusal the fallback can
|
|
1839
|
+
// answer is about the ACCOUNT; anything about the work or the sandbox is
|
|
1840
|
+
// not, however similar the vocabulary.
|
|
1841
|
+
return /API Error: (401|402|403|429|5\d\d)|credits?\b|quota|billing|insufficient|overloaded/i.test(text);
|
|
1842
|
+
}
|
|
1843
|
+
/** How many times a busy provider is given another moment, and the longest
|
|
1844
|
+
* any one wait may be. Three is the industry's habit and the point where
|
|
1845
|
+
* waiting longer stops being kinder than failing. */
|
|
1846
|
+
export const OVERLOAD_RETRIES = 3;
|
|
1847
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
1848
|
+
/**
|
|
1849
|
+
* A refusal the SAME supply may answer in a moment — the provider is busy,
|
|
1850
|
+
* not the account broken.
|
|
1851
|
+
*
|
|
1852
|
+
* The distinction is the whole point. 429 means "too many requests", and a
|
|
1853
|
+
* provider says it for two unrelated reasons: the endpoint is momentarily
|
|
1854
|
+
* saturated (wait, and it works), or the account is out of credit or over
|
|
1855
|
+
* its quota (wait forever, it never works). Money words decide it: a 429
|
|
1856
|
+
* that mentions credit, quota or billing is the account, and belongs to the
|
|
1857
|
+
* fallback provider, not to a retry that can only fail again.
|
|
1858
|
+
*/
|
|
1859
|
+
export function isTransientOverload(text) {
|
|
1860
|
+
if (/credits?\b|quota|billing|insufficient|payment|401|402|403/i.test(text))
|
|
1861
|
+
return false;
|
|
1862
|
+
return /\b(429|503|529)\b|overloaded|rate.?limit|too many requests|try again/i.test(text);
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* How long to wait before attempt `n` (1-based). A `retry-after` the
|
|
1866
|
+
* provider named wins — it knows when it will be ready. Otherwise
|
|
1867
|
+
* exponential, with jitter: twenty steps of a fan-out that all failed
|
|
1868
|
+
* together must not all come back together, or the second wave is the first
|
|
1869
|
+
* wave again. Capped, because a step waiting half a minute is already the
|
|
1870
|
+
* limit of what a slow run should cost a person watching it.
|
|
1871
|
+
*/
|
|
1872
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1873
|
+
/**
|
|
1874
|
+
* Carry an earlier attempt's sandbox time into the outcome that replaced
|
|
1875
|
+
* it. Every attempt rented a pod; the meter owes all of them, and a record
|
|
1876
|
+
* that shows only the last one under-bills the platform and lies to the
|
|
1877
|
+
* customer about how long their step actually took.
|
|
1878
|
+
*/
|
|
1879
|
+
function withEarlierTiming(outcome, earlier) {
|
|
1880
|
+
if (!earlier)
|
|
1881
|
+
return outcome;
|
|
1882
|
+
if (!outcome.timing)
|
|
1883
|
+
return { ...outcome, timing: earlier };
|
|
1884
|
+
return { ...outcome, timing: { ...outcome.timing, totalMs: outcome.timing.totalMs + earlier.totalMs } };
|
|
1885
|
+
}
|
|
1886
|
+
export function backoffMs(attempt, text = "", random = Math.random) {
|
|
1887
|
+
// retry-after, retry_after, "retryAfter": — every spelling a provider or
|
|
1888
|
+
// its SDK has used for the same header.
|
|
1889
|
+
const named = text.match(/retry[-_\s]?after["':\s]+(\d+)/i);
|
|
1890
|
+
if (named)
|
|
1891
|
+
return Math.min(Math.max(Number(named[1]), 1) * 1000, MAX_BACKOFF_MS);
|
|
1892
|
+
const base = Math.min(2 ** attempt * 1000, MAX_BACKOFF_MS);
|
|
1893
|
+
return Math.round(base * (0.5 + random() * 0.5));
|
|
1894
|
+
}
|
|
1895
|
+
function platformModelEnv() {
|
|
1896
|
+
const { ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN,
|
|
1897
|
+
// What the tiers mean when the platform's own provider is not Anthropic.
|
|
1898
|
+
// These belong to the credential: a base URL pointing at OpenRouter with
|
|
1899
|
+
// Anthropic's model ids still attached is not a working provider, it is
|
|
1900
|
+
// a 404 per step. Forwarded for the same reason the base URL is — an
|
|
1901
|
+
// agent that names no provider gets the platform's, whole.
|
|
1902
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, } = process.env;
|
|
1903
|
+
return {
|
|
1904
|
+
ANTHROPIC_API_KEY,
|
|
1905
|
+
ANTHROPIC_AUTH_TOKEN,
|
|
1906
|
+
ANTHROPIC_BASE_URL,
|
|
1907
|
+
CLAUDE_CODE_OAUTH_TOKEN,
|
|
1908
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
|
1909
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
1910
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL,
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Write a fresh run record and reset outputs — everything starting a run
|
|
1915
|
+
* means *except* driving it. Split out so the queue can create a `queued`
|
|
1916
|
+
* record for a worker to pick up, while `startFlowRun` keeps its promise of
|
|
1917
|
+
* a run that is already going when it returns.
|
|
1918
|
+
*/
|
|
1919
|
+
/**
|
|
1920
|
+
* `each: rows of <path>` — one item per CSV data row, each carrying the
|
|
1921
|
+
* header so an instance reads as a one-row CSV. The path resolves the way
|
|
1922
|
+
* the agent's own instructions do (relative to its folder) and is confined
|
|
1923
|
+
* to the workspace: fan-out must not become a way to read past it.
|
|
1924
|
+
*/
|
|
1925
|
+
/**
|
|
1926
|
+
* `each: items` — one instance per element of the previous group's data.
|
|
1927
|
+
* An array is the items; an object with one array-valued field is that
|
|
1928
|
+
* field (the common `{ "leads": [...] }` shape); anything else is nothing,
|
|
1929
|
+
* said out loud. Each item crosses as JSON text, because `item` is what the
|
|
1930
|
+
* instance's prompt quotes and what its result is labelled with.
|
|
1931
|
+
*/
|
|
1932
|
+
function jsonItems(data, step, take) {
|
|
1933
|
+
let list = Array.isArray(data) ? data : null;
|
|
1934
|
+
if (!list && data && typeof data === "object") {
|
|
1935
|
+
const arrays = Object.entries(data).filter(([, v]) => Array.isArray(v));
|
|
1936
|
+
if (arrays.length === 1)
|
|
1937
|
+
list = arrays[0][1];
|
|
1938
|
+
else if (arrays.length > 1) {
|
|
1939
|
+
step.skipReason = `each: items — the previous data has ${arrays.length} array fields; return the list itself`;
|
|
1940
|
+
return [];
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
if (!list) {
|
|
1944
|
+
step.skipReason =
|
|
1945
|
+
data === undefined
|
|
1946
|
+
? "each: items needs an `output: json` step before it that returned an array"
|
|
1947
|
+
: "each: items — the previous data is not an array";
|
|
1948
|
+
return [];
|
|
1949
|
+
}
|
|
1950
|
+
return list.slice(0, take).map((v) => (typeof v === "string" ? v : JSON.stringify(v)));
|
|
1951
|
+
}
|
|
1952
|
+
function csvItems(workspaceDir, step, take) {
|
|
1953
|
+
const raw = step.eachPath ?? "";
|
|
1954
|
+
if (!raw) {
|
|
1955
|
+
step.skipReason = "each: rows needs a path — `each: rows of ../../storage/x.csv`";
|
|
1956
|
+
return [];
|
|
1957
|
+
}
|
|
1958
|
+
const resolved = path.resolve(workspaceDir, "agents", step.agent, raw);
|
|
1959
|
+
if (!resolved.startsWith(path.resolve(workspaceDir) + path.sep)) {
|
|
1960
|
+
step.skipReason = `each: rows — ${raw} is outside this workspace`;
|
|
1961
|
+
return [];
|
|
1962
|
+
}
|
|
1963
|
+
let text;
|
|
1964
|
+
try {
|
|
1965
|
+
text = fs.readFileSync(resolved, "utf8");
|
|
1966
|
+
}
|
|
1967
|
+
catch {
|
|
1968
|
+
step.skipReason = `each: rows — ${raw} does not exist (yet?)`;
|
|
1969
|
+
return [];
|
|
1970
|
+
}
|
|
1971
|
+
// Minimal CSV: quoted fields with embedded commas/newlines survive.
|
|
1972
|
+
const rows = [];
|
|
1973
|
+
let field = "", row = "", quoted = false;
|
|
1974
|
+
for (let i = 0; i < text.length; i++) {
|
|
1975
|
+
const c = text[i];
|
|
1976
|
+
if (quoted) {
|
|
1977
|
+
if (c === '"' && text[i + 1] === '"') {
|
|
1978
|
+
field += '""';
|
|
1979
|
+
i++;
|
|
1980
|
+
}
|
|
1981
|
+
else if (c === '"') {
|
|
1982
|
+
field += c;
|
|
1983
|
+
quoted = false;
|
|
1984
|
+
}
|
|
1985
|
+
else
|
|
1986
|
+
field += c;
|
|
1987
|
+
}
|
|
1988
|
+
else if (c === '"') {
|
|
1989
|
+
field += c;
|
|
1990
|
+
quoted = true;
|
|
1991
|
+
}
|
|
1992
|
+
else if (c === "\n") {
|
|
1993
|
+
rows.push(row + field);
|
|
1994
|
+
row = "";
|
|
1995
|
+
field = "";
|
|
1996
|
+
}
|
|
1997
|
+
else if (c !== "\r")
|
|
1998
|
+
field += c;
|
|
1999
|
+
}
|
|
2000
|
+
if (row + field)
|
|
2001
|
+
rows.push(row + field);
|
|
2002
|
+
const [header, ...data] = rows.filter((r) => r.trim() !== "");
|
|
2003
|
+
if (!header || data.length === 0) {
|
|
2004
|
+
step.skipReason = `each: rows — ${raw} has no data rows`;
|
|
2005
|
+
return [];
|
|
2006
|
+
}
|
|
2007
|
+
return data.slice(0, take).map((r) => `${header}\n${r}`);
|
|
2008
|
+
}
|
|
2009
|
+
export function createFlowRun(tenant, workspace, steps, flowName, status, tags = []) {
|
|
2010
|
+
const run = {
|
|
2011
|
+
id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
2012
|
+
flow: flowName,
|
|
2013
|
+
tags,
|
|
2014
|
+
status,
|
|
2015
|
+
startedAt: new Date().toISOString(),
|
|
2016
|
+
finishedAt: null,
|
|
2017
|
+
steps: steps.map((s) => ({
|
|
2018
|
+
agent: s.agent,
|
|
2019
|
+
instruction: s.instruction,
|
|
2020
|
+
group: s.group ?? 1,
|
|
2021
|
+
optional: s.optional ?? false,
|
|
2022
|
+
model: s.model,
|
|
2023
|
+
effort: s.effort,
|
|
2024
|
+
approve: s.approve,
|
|
2025
|
+
when: s.when,
|
|
2026
|
+
case: s.case,
|
|
2027
|
+
else: s.else,
|
|
2028
|
+
retry: s.retry,
|
|
2029
|
+
timeout: s.timeout,
|
|
2030
|
+
verify: s.verify,
|
|
2031
|
+
loop: s.loop,
|
|
2032
|
+
until: s.until,
|
|
2033
|
+
loopRemaining: s.loop,
|
|
2034
|
+
each: s.each,
|
|
2035
|
+
eachPath: s.eachPath,
|
|
2036
|
+
max: s.max,
|
|
2037
|
+
onFail: s.onFail,
|
|
2038
|
+
waitSecs: s.waitSecs,
|
|
2039
|
+
waitFor: s.waitFor,
|
|
2040
|
+
ask: s.ask,
|
|
2041
|
+
preview: s.preview,
|
|
2042
|
+
delegate: s.delegate,
|
|
2043
|
+
output: s.output,
|
|
2044
|
+
attempts: 0,
|
|
2045
|
+
status: "pending",
|
|
2046
|
+
events: [],
|
|
2047
|
+
result: null,
|
|
2048
|
+
costUsd: null,
|
|
2049
|
+
})),
|
|
2050
|
+
};
|
|
2051
|
+
writeRun(tenant, workspace, run);
|
|
2052
|
+
// Start every run from an empty outputs/ for each agent it will touch.
|
|
2053
|
+
//
|
|
2054
|
+
// Without this, `verify: test -s outputs/report.md` passes on run 2 because
|
|
2055
|
+
// run 1 left the file behind — the one check that exists to catch a model
|
|
2056
|
+
// claiming work it didn't do can be satisfied by a stale artifact. The
|
|
2057
|
+
// previous contents aren't lost: they were archived when that run finished.
|
|
2058
|
+
//
|
|
2059
|
+
// This is the one thing resuming must not do: a resumed run's earlier steps
|
|
2060
|
+
// already wrote there, and clearing it would delete the work being resumed.
|
|
2061
|
+
const runRoot = workspaceDir(tenant, workspace);
|
|
2062
|
+
for (const agent of new Set(run.steps.map((s) => s.agent))) {
|
|
2063
|
+
const outputs = path.join(runRoot, "agents", agent, "outputs");
|
|
2064
|
+
fs.rmSync(outputs, { recursive: true, force: true });
|
|
2065
|
+
fs.mkdirSync(outputs, { recursive: true });
|
|
2066
|
+
}
|
|
2067
|
+
return run;
|
|
2068
|
+
}
|
|
2069
|
+
export function startFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], effortOverride) {
|
|
2070
|
+
const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags);
|
|
2071
|
+
void driveRun(tenant, workspace, run, modelOverride, tags, { effortOverride });
|
|
2072
|
+
return run;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Drive a run to completion: groups in order, approval gates, retries.
|
|
2076
|
+
*
|
|
2077
|
+
* Separate from starting one so a run can be picked up again. The loop reads
|
|
2078
|
+
* every decision off the run record — `pending` steps are the work left, and
|
|
2079
|
+
* a completed group's results are already on it — so re-entering it on a
|
|
2080
|
+
* partly-finished record continues rather than restarts.
|
|
2081
|
+
*
|
|
2082
|
+
* `parkOnApproval` — a queue worker's slot is too expensive to spend polling
|
|
2083
|
+
* a file for a person who may answer tomorrow. With it set, an approval gate
|
|
2084
|
+
* saves the run as parked and returns; the approval API re-enqueues it and a
|
|
2085
|
+
* worker re-enters this loop, which continues where the record says it
|
|
2086
|
+
* stopped. Without it (the CLI, whose process belongs to the person waiting)
|
|
2087
|
+
* the gate blocks in place exactly as before.
|
|
2088
|
+
*/
|
|
2089
|
+
/**
|
|
2090
|
+
* Turn a gate's `preview:` patterns into the files under storage/ that
|
|
2091
|
+
* exist right now, relative to storage/. `*` matches within a segment,
|
|
2092
|
+
* `**` across segments; a pattern with no wildcard is kept only if the file
|
|
2093
|
+
* is there. Sorted, deduplicated, capped — a preview is a handful of files,
|
|
2094
|
+
* not a directory dump.
|
|
2095
|
+
*/
|
|
2096
|
+
export function resolvePreview(storageDir, patterns, cap = 12) {
|
|
2097
|
+
const out = new Set();
|
|
2098
|
+
for (const pattern of patterns) {
|
|
2099
|
+
if (!/[*?]/.test(pattern)) {
|
|
2100
|
+
if (fs.existsSync(path.join(storageDir, pattern)))
|
|
2101
|
+
out.add(pattern);
|
|
2102
|
+
continue;
|
|
2103
|
+
}
|
|
2104
|
+
const re = new RegExp("^" + pattern
|
|
2105
|
+
.split("**").map((part) => part.split("*").map((x) => x.replace(/[.+^${}()|[\]\\?]/g, "\\$&")).join("[^/]*"))
|
|
2106
|
+
.join(".*") + "$");
|
|
2107
|
+
const walk = (dir, rel) => {
|
|
2108
|
+
let entries;
|
|
2109
|
+
try {
|
|
2110
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2111
|
+
}
|
|
2112
|
+
catch {
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
for (const e of entries) {
|
|
2116
|
+
if (e.name.startsWith("."))
|
|
2117
|
+
continue;
|
|
2118
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
2119
|
+
if (e.isDirectory())
|
|
2120
|
+
walk(path.join(dir, e.name), r);
|
|
2121
|
+
else if (re.test(r))
|
|
2122
|
+
out.add(r);
|
|
2123
|
+
}
|
|
2124
|
+
};
|
|
2125
|
+
walk(storageDir, "");
|
|
2126
|
+
}
|
|
2127
|
+
return [...out].sort().slice(0, cap);
|
|
2128
|
+
}
|
|
2129
|
+
/** Store what a run has left in storage/ so far. Never fails the run: a
|
|
2130
|
+
* store that is down costs the gate its thumbnail, not the run its step. */
|
|
2131
|
+
/**
|
|
2132
|
+
* Copy onto the in-memory run any gate decision that reached the record on
|
|
2133
|
+
* disk while this driver was busy — status, stamps, note, the event line —
|
|
2134
|
+
* so the driver's next save keeps it. Returns whether anything landed. Only
|
|
2135
|
+
* steps this copy still shows as asking are touched: everything else on the
|
|
2136
|
+
* record is this driver's own, and newer in memory than on disk.
|
|
2137
|
+
*/
|
|
2138
|
+
export function adoptDecisions(run, disk) {
|
|
2139
|
+
if (!disk)
|
|
2140
|
+
return false;
|
|
2141
|
+
let any = false;
|
|
2142
|
+
run.steps.forEach((s, i) => {
|
|
2143
|
+
const d = disk.steps[i];
|
|
2144
|
+
if (!d || s.status !== "awaiting-approval" || d.status === "awaiting-approval")
|
|
2145
|
+
return;
|
|
2146
|
+
s.status = d.status;
|
|
2147
|
+
s.events = d.events;
|
|
2148
|
+
if (d.approvedAt)
|
|
2149
|
+
s.approvedAt = d.approvedAt;
|
|
2150
|
+
if (d.approvalNote)
|
|
2151
|
+
s.approvalNote = d.approvalNote;
|
|
2152
|
+
any = true;
|
|
2153
|
+
});
|
|
2154
|
+
return any;
|
|
2155
|
+
}
|
|
2156
|
+
async function harvestQuietly(tenant, workspace, run) {
|
|
2157
|
+
try {
|
|
2158
|
+
await harvestFiles(tenant, workspace, `run:${run.id}`);
|
|
2159
|
+
}
|
|
2160
|
+
catch {
|
|
2161
|
+
// bookkeeping only
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* The runs this process is driving right now. The one fact reconcile needs:
|
|
2166
|
+
* a run in here is alive however quiet its step is, and a `running` run not
|
|
2167
|
+
* in here has no driver — this is the only process that drives.
|
|
2168
|
+
*/
|
|
2169
|
+
export const drivingRuns = new Set();
|
|
2170
|
+
export function driveRun(tenant, workspace, run, modelOverride, tags = [], opts = {}) {
|
|
2171
|
+
drivingRuns.add(run.id);
|
|
2172
|
+
return driveRunInner(tenant, workspace, run, modelOverride, tags, opts).finally(() => {
|
|
2173
|
+
drivingRuns.delete(run.id);
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
2177
|
+
const effortOverride = opts.effortOverride ?? null;
|
|
2178
|
+
const pDir = workspaceDir(tenant, workspace);
|
|
2179
|
+
const runRoot = pDir;
|
|
2180
|
+
/**
|
|
2181
|
+
* Persist the run — carrying forward a stop that arrived while we were busy.
|
|
2182
|
+
*
|
|
2183
|
+
* A stop is written to the record by another process (stopRun), and this
|
|
2184
|
+
* loop holds the run in memory for as long as a group takes. Without the
|
|
2185
|
+
* merge below, the next save silently overwrote `stopRequested`, and the
|
|
2186
|
+
* between-groups check then read back the value it had just erased. A run
|
|
2187
|
+
* stopped mid-group carried on to the end of the flow as if nothing had
|
|
2188
|
+
* happened — observed on 2026-09-03, when a run stopped at 04:52 published
|
|
2189
|
+
* to five live Google Business Profiles at 06:05.
|
|
2190
|
+
*
|
|
2191
|
+
* The flag is one-way: nothing here clears it, so the read-modify-write can
|
|
2192
|
+
* only ever turn a stop on, never off.
|
|
2193
|
+
*/
|
|
2194
|
+
const save = () => {
|
|
2195
|
+
if (!run.stopRequested && readRun(tenant, workspace, run.id)?.stopRequested) {
|
|
2196
|
+
run.stopRequested = true;
|
|
2197
|
+
}
|
|
2198
|
+
writeRun(tenant, workspace, run);
|
|
2199
|
+
};
|
|
2200
|
+
// Provenance: a memory an agent wrote gets stamped with who wrote it, per
|
|
2201
|
+
// OKF v0.2. Without this a fact the model invented and a fact a person
|
|
2202
|
+
// verified are indistinguishable on disk — and the reader has no way to
|
|
2203
|
+
// know which one they're trusting.
|
|
2204
|
+
const stampMemories = () => {
|
|
2205
|
+
const agents = new Set(run.steps.map((s) => s.agent));
|
|
2206
|
+
// Every memory bundle a run can write to, not just the agent's own. The
|
|
2207
|
+
// workspace bundle is writable from any step — confine only denies
|
|
2208
|
+
// knowledge/ — so a fact left there was going unstamped and untyped, and
|
|
2209
|
+
// the bundle stopped conforming until someone ran `foldrun check`.
|
|
2210
|
+
// The account library is not here: it is read-only from a run.
|
|
2211
|
+
const dirs = [
|
|
2212
|
+
// Attributable only when one agent could have written it.
|
|
2213
|
+
{ dir: path.join(runRoot, "memory"), agent: agents.size === 1 ? [...agents][0] : null },
|
|
2214
|
+
...[...agents].map((agent) => ({
|
|
2215
|
+
dir: path.join(runRoot, "agents", agent, "memory"),
|
|
2216
|
+
agent,
|
|
2217
|
+
})),
|
|
2218
|
+
];
|
|
2219
|
+
const written = [];
|
|
2220
|
+
for (const { dir, agent } of dirs) {
|
|
2221
|
+
// Each stamped file is a Creation in the bundle's log — that is what the
|
|
2222
|
+
// log is for, and an agent's writes are exactly the changes a reader
|
|
2223
|
+
// most wants dated. syncBundleFor logs the file it is given, so passing
|
|
2224
|
+
// index.md logged nothing about the concept that actually appeared.
|
|
2225
|
+
for (const rel of stampBundle(dir, agent)) {
|
|
2226
|
+
syncBundleFor(path.join(dir, rel), "Creation");
|
|
2227
|
+
written.push(path.relative(runRoot, path.join(dir, rel)).replaceAll("\\", "/"));
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
// On the record, so the run page can say "this run learned two things"
|
|
2231
|
+
// and a reviewer can open exactly those files.
|
|
2232
|
+
if (written.length)
|
|
2233
|
+
run.memoryWrites = [...(run.memoryWrites ?? []), ...written];
|
|
2234
|
+
};
|
|
2235
|
+
// Archive what a run produced, so history survives the next run's reset.
|
|
2236
|
+
const archive = () => {
|
|
2237
|
+
for (const agent of new Set(run.steps.map((s) => s.agent))) {
|
|
2238
|
+
const from = path.join(runRoot, "agents", agent, "outputs");
|
|
2239
|
+
if (!fs.existsSync(from) || fs.readdirSync(from).length === 0)
|
|
2240
|
+
continue;
|
|
2241
|
+
copyTreeBytes(from, path.join(runRoot, "runs", run.id, "outputs", agent));
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
2244
|
+
// A parked run is paused, not finished — the completion bookkeeping in
|
|
2245
|
+
// `finally` (finishedAt, archive, memory stamps) must not touch it.
|
|
2246
|
+
let parked = false;
|
|
2247
|
+
return (async () => {
|
|
2248
|
+
try {
|
|
2249
|
+
// Entering the loop is what makes a run live, whatever state it was
|
|
2250
|
+
// saved in: `queued` from the queue, `awaiting-approval` from a park.
|
|
2251
|
+
if (run.status !== "running") {
|
|
2252
|
+
run.status = "running";
|
|
2253
|
+
run.parkedAt = null;
|
|
2254
|
+
save();
|
|
2255
|
+
}
|
|
2256
|
+
// A step still marked `running` here is an orphan: exactly one driver
|
|
2257
|
+
// holds a run's job, so whatever process set that status is gone —
|
|
2258
|
+
// killed mid-step by a deploy or a crash. Left alone it is worse than
|
|
2259
|
+
// failed: the group loop only runs `pending` steps, so the driver
|
|
2260
|
+
// would step over the unfinished work, run everything after it, and
|
|
2261
|
+
// mark the run completed — a run that reports success on work that
|
|
2262
|
+
// never happened, which reconcile can then never see. Reset it to
|
|
2263
|
+
// pending and it simply runs again; driveRun's whole contract is that
|
|
2264
|
+
// re-driving a partly-finished record is safe.
|
|
2265
|
+
let orphaned = false;
|
|
2266
|
+
for (const step of run.steps) {
|
|
2267
|
+
if (step.status !== "running")
|
|
2268
|
+
continue;
|
|
2269
|
+
orphaned = true;
|
|
2270
|
+
step.status = "pending";
|
|
2271
|
+
step.events.push({
|
|
2272
|
+
t: new Date().toISOString(),
|
|
2273
|
+
type: "info",
|
|
2274
|
+
text: "interrupted mid-step (platform restart) — running again from the start of the step",
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
if (orphaned) {
|
|
2278
|
+
// The sandbox outlives the process that created it — a step with no
|
|
2279
|
+
// `timeout:` carries no pod deadline at all — so the orphaned step
|
|
2280
|
+
// may still be executing, non-idempotent work included, while the
|
|
2281
|
+
// new attempt starts. Destroy it first; re-driving is only safe
|
|
2282
|
+
// once nothing else is driving.
|
|
2283
|
+
try {
|
|
2284
|
+
if (process.env.FOLDRUN_RUN_ISOLATION === "container")
|
|
2285
|
+
killRunSandboxes(run.id);
|
|
2286
|
+
else
|
|
2287
|
+
platform.killRunSandboxes(run.id);
|
|
2288
|
+
}
|
|
2289
|
+
catch {
|
|
2290
|
+
// a sandbox we cannot reach must not stop the run from resuming
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
save();
|
|
2294
|
+
// Put the workspace's files on disk before the first step, so they are
|
|
2295
|
+
// simply *there* when the step's container copy is taken. This is the
|
|
2296
|
+
// whole of the agent-facing file API: a directory. Nothing here hands
|
|
2297
|
+
// the model a bucket, a URL or a credential, and a run pod that has
|
|
2298
|
+
// never heard of S3 reads a 200MB PDF the same way it reads AGENTS.md.
|
|
2299
|
+
//
|
|
2300
|
+
// Runs on resume too — a parked run's container is long gone, and the
|
|
2301
|
+
// one it comes back in needs the same files the first one had.
|
|
2302
|
+
try {
|
|
2303
|
+
const brought = await materializeFiles(tenant, workspace);
|
|
2304
|
+
if (brought.length) {
|
|
2305
|
+
run.steps[0]?.events.push({
|
|
2306
|
+
t: new Date().toISOString(),
|
|
2307
|
+
type: "info",
|
|
2308
|
+
text: `files: ${brought.length} brought into the workspace`,
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
catch (err) {
|
|
2313
|
+
// A file store that is down must not take the run with it: most flows
|
|
2314
|
+
// never touch storage/, and the ones that do will fail their own verify.
|
|
2315
|
+
run.steps[0]?.events.push({
|
|
2316
|
+
t: new Date().toISOString(),
|
|
2317
|
+
type: "error",
|
|
2318
|
+
text: `files: could not be brought in — ${err instanceof Error ? err.message : String(err)}`,
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
// Group steps by their group number, ascending. Rebuilt on demand
|
|
2322
|
+
// because two patterns mutate the step list mid-run: fan-out expands a
|
|
2323
|
+
// step into instances, and an evaluator loop winds groups back.
|
|
2324
|
+
const orderedGroups = () => {
|
|
2325
|
+
const groups = new Map();
|
|
2326
|
+
for (const s of run.steps) {
|
|
2327
|
+
const list = groups.get(s.group) ?? [];
|
|
2328
|
+
list.push(s);
|
|
2329
|
+
groups.set(s.group, list);
|
|
2330
|
+
}
|
|
2331
|
+
return [...groups.entries()].sort(([a], [b]) => a - b).map(([, g]) => g);
|
|
2332
|
+
};
|
|
2333
|
+
let ordered = orderedGroups();
|
|
2334
|
+
// What each finished group produced, by position — so a loop that
|
|
2335
|
+
// winds back can hand the re-run the same upstream context the first
|
|
2336
|
+
// pass saw, instead of its own stale output.
|
|
2337
|
+
const groupResults = [];
|
|
2338
|
+
const groupConclusions = [];
|
|
2339
|
+
// Every earlier group, oldest first, so a group-3 reporter sees what
|
|
2340
|
+
// group 1 measured and not only what group 2 diagnosed. This used to
|
|
2341
|
+
// hand back the nearest earlier group alone, and a reporter two groups
|
|
2342
|
+
// after the measurement went looking on disk for a feed that was
|
|
2343
|
+
// already in the run. The cap (CONTEXT_CHARS, applied where the prompt
|
|
2344
|
+
// is assembled) keeps the newest: when the total is over it, the
|
|
2345
|
+
// oldest groups are dropped whole before anything is cut mid-text.
|
|
2346
|
+
const contextBefore = (gi) => joinEarlierGroups(groupResults, gi);
|
|
2347
|
+
// The data half of the same handoff: what the nearest earlier group
|
|
2348
|
+
// returned through `output: json`. Rebuilt from the record rather than
|
|
2349
|
+
// kept only in memory, so a resumed run hands step 3 the value step 1
|
|
2350
|
+
// returned before the park.
|
|
2351
|
+
const groupData = [];
|
|
2352
|
+
const dataBefore = (gi) => {
|
|
2353
|
+
for (let i = gi - 1; i >= 0; i--)
|
|
2354
|
+
if (groupData[i] !== undefined)
|
|
2355
|
+
return groupData[i];
|
|
2356
|
+
return undefined;
|
|
2357
|
+
};
|
|
2358
|
+
const dataOf = (group) => {
|
|
2359
|
+
const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
|
|
2360
|
+
if (carrying.length === 0)
|
|
2361
|
+
return undefined;
|
|
2362
|
+
if (carrying.length === 1 && !carrying[0].item)
|
|
2363
|
+
return carrying[0].data;
|
|
2364
|
+
return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
|
|
2365
|
+
};
|
|
2366
|
+
const resultsOf = (group) => {
|
|
2367
|
+
const results = group
|
|
2368
|
+
.filter((s) => s.status === "completed" && s.result)
|
|
2369
|
+
.map((s) => s.item
|
|
2370
|
+
? `## Result for item: ${s.item.slice(0, 80)} (${s.agent})\n\n${s.result}`
|
|
2371
|
+
: group.length > 1
|
|
2372
|
+
? `## Result from ${s.agent}\n\n${s.result}`
|
|
2373
|
+
: s.result);
|
|
2374
|
+
return results.length ? results.join("\n\n") : null;
|
|
2375
|
+
};
|
|
2376
|
+
// What each step concluded — its final turn — for the handoffs that
|
|
2377
|
+
// should read a conclusion rather than a transcript (`each: lines`).
|
|
2378
|
+
const conclusionsOf = (group) => {
|
|
2379
|
+
const out = group
|
|
2380
|
+
.filter((s) => s.status === "completed" && (s.conclusion || s.result))
|
|
2381
|
+
.map((s) => s.conclusion || s.result);
|
|
2382
|
+
return out.length ? out.join("\n") : null;
|
|
2383
|
+
};
|
|
2384
|
+
// Groups already finished before this drive (a resume, or a run picked
|
|
2385
|
+
// up after a gate) still carry their results and data on the record.
|
|
2386
|
+
// Rebuilding all three here is what lets the step after an approval
|
|
2387
|
+
// read what came before it, not only the gate's question.
|
|
2388
|
+
for (const [i, g] of ordered.entries()) {
|
|
2389
|
+
if (g.every((s) => s.status !== "pending" && s.status !== "running" && s.status !== "awaiting-approval")) {
|
|
2390
|
+
groupResults[i] = resultsOf(g);
|
|
2391
|
+
groupConclusions[i] = conclusionsOf(g);
|
|
2392
|
+
groupData[i] = dataOf(g);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
// budget: — the flow's own per-run cap, stamped on the record the first
|
|
2396
|
+
// time it is driven. Between groups, not mid-step: a step in flight is
|
|
2397
|
+
// money already committed, and stopping it half-way keeps the cost
|
|
2398
|
+
// and loses the work.
|
|
2399
|
+
if (run.budgetUsd === undefined) {
|
|
2400
|
+
run.budgetUsd = readFlow(tenant, workspace, run.flow)?.budget ?? null;
|
|
2401
|
+
save();
|
|
2402
|
+
}
|
|
2403
|
+
let gi = 0;
|
|
2404
|
+
let context = null;
|
|
2405
|
+
let ctxData = undefined;
|
|
2406
|
+
while (gi < ordered.length) {
|
|
2407
|
+
const group = ordered[gi];
|
|
2408
|
+
context = contextBefore(gi);
|
|
2409
|
+
ctxData = dataBefore(gi);
|
|
2410
|
+
if (run.budgetUsd && runCost(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
|
|
2411
|
+
const spent = runCost(run);
|
|
2412
|
+
const last = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
|
|
2413
|
+
last?.events.push({
|
|
2414
|
+
t: new Date().toISOString(),
|
|
2415
|
+
type: "error",
|
|
2416
|
+
text: `over budget — $${spent.toFixed(4)} spent of the flow's $${run.budgetUsd.toFixed(2)} (budget: in the flow file); the remaining steps were not run`,
|
|
2417
|
+
});
|
|
2418
|
+
for (const s of run.steps) {
|
|
2419
|
+
if (s.status === "pending") {
|
|
2420
|
+
s.status = "skipped";
|
|
2421
|
+
s.skipReason = "over budget";
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
run.status = "failed";
|
|
2425
|
+
save();
|
|
2426
|
+
break;
|
|
2427
|
+
}
|
|
2428
|
+
// wait: — the clock starts when the run REACHES the step, so
|
|
2429
|
+
// "wait: 3d" means three days after the previous group finished,
|
|
2430
|
+
// surviving restarts because the deadline is stamped on the record.
|
|
2431
|
+
for (const s of group) {
|
|
2432
|
+
if (s.status === "pending" && s.waitSecs && !s.waitUntil) {
|
|
2433
|
+
s.waitUntil = new Date(Date.now() + s.waitSecs * 1000).toISOString();
|
|
2434
|
+
s.events.push({
|
|
2435
|
+
t: new Date().toISOString(),
|
|
2436
|
+
type: "info",
|
|
2437
|
+
text: `waiting until ${s.waitUntil} (wait: ${s.waitSecs}s)`,
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
const stillWaiting = group
|
|
2442
|
+
.filter((s) => s.status === "pending" && s.waitUntil)
|
|
2443
|
+
.map((s) => new Date(s.waitUntil).getTime())
|
|
2444
|
+
.filter((t) => t > Date.now());
|
|
2445
|
+
if (stillWaiting.length) {
|
|
2446
|
+
save();
|
|
2447
|
+
if (opts.parkOnApproval) {
|
|
2448
|
+
// Back to the queue with a not-before — a three-day wait must
|
|
2449
|
+
// not hold a worker slot, a process, or survive on either. The
|
|
2450
|
+
// files so far are stored first, as at an approval park.
|
|
2451
|
+
await harvestQuietly(tenant, workspace, run);
|
|
2452
|
+
run.status = "queued";
|
|
2453
|
+
run.parkedAt = new Date().toISOString();
|
|
2454
|
+
parked = true;
|
|
2455
|
+
save();
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
// Foreground (CLI) runs sleep in slices so Ctrl-C stays prompt.
|
|
2459
|
+
await new Promise((r) => setTimeout(r, Math.min(Math.min(...stillWaiting) - Date.now(), 30_000)));
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
// Fan-out: a step with `each:` becomes one instance per item of the
|
|
2463
|
+
// previous group's result, capped, all in this same group — the
|
|
2464
|
+
// declared shape stays readable in the file, the width comes from
|
|
2465
|
+
// the data. The template survives as a skipped step for the trace.
|
|
2466
|
+
// `each: lines` splits what the previous group CONCLUDED — each step's
|
|
2467
|
+
// final turn — not every turn joined. A translator that narrated one
|
|
2468
|
+
// line per file write put the same sentence in front of eight
|
|
2469
|
+
// validators; its final turn was the list. Falls back to the joined
|
|
2470
|
+
// result for records made before `conclusion` existed.
|
|
2471
|
+
const eachSource = gi > 0 ? (groupConclusions[gi - 1] ?? groupResults[gi - 1]) : null;
|
|
2472
|
+
for (const step of [...group]) {
|
|
2473
|
+
if (!step.each || step.status !== "pending" || step.item)
|
|
2474
|
+
continue;
|
|
2475
|
+
const cap = Math.min(step.max ?? 10, 20);
|
|
2476
|
+
const items = step.each === "rows"
|
|
2477
|
+
? csvItems(pDir, step, cap + 1)
|
|
2478
|
+
: step.each === "items"
|
|
2479
|
+
? jsonItems(ctxData, step, cap + 1)
|
|
2480
|
+
: (eachSource ?? "")
|
|
2481
|
+
.split("\n")
|
|
2482
|
+
.map((l) => l.replace(/^[-*\d.)\s]+/, "").trim())
|
|
2483
|
+
.filter(Boolean)
|
|
2484
|
+
.slice(0, cap + 1);
|
|
2485
|
+
const dropped = items.length > cap ? "at least one" : "";
|
|
2486
|
+
const used = items.slice(0, cap);
|
|
2487
|
+
step.status = "skipped";
|
|
2488
|
+
if (used.length === 0) {
|
|
2489
|
+
step.skipReason ||= "each: the previous group produced no items";
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
step.skipReason = `expanded into ${used.length} item${used.length === 1 ? "" : "s"}`;
|
|
2493
|
+
step.events.push({
|
|
2494
|
+
t: new Date().toISOString(),
|
|
2495
|
+
type: "info",
|
|
2496
|
+
text: `fan-out: ${used.length} instance${used.length === 1 ? "" : "s"}` +
|
|
2497
|
+
(dropped ? ` (more items existed — capped at ${cap})` : ""),
|
|
2498
|
+
});
|
|
2499
|
+
const at = run.steps.indexOf(step);
|
|
2500
|
+
const instances = used.map((item) => ({
|
|
2501
|
+
...step,
|
|
2502
|
+
each: undefined,
|
|
2503
|
+
max: undefined,
|
|
2504
|
+
skipReason: undefined,
|
|
2505
|
+
item,
|
|
2506
|
+
status: "pending",
|
|
2507
|
+
events: [],
|
|
2508
|
+
result: null,
|
|
2509
|
+
costUsd: null,
|
|
2510
|
+
attempts: 0,
|
|
2511
|
+
}));
|
|
2512
|
+
run.steps.splice(at + 1, 0, ...instances);
|
|
2513
|
+
ordered = orderedGroups();
|
|
2514
|
+
}
|
|
2515
|
+
save();
|
|
2516
|
+
// A person asked for this to stop. Checked between groups, off the
|
|
2517
|
+
// record rather than in memory, because the request arrives in
|
|
2518
|
+
// another process — the same way an approval decision does. The
|
|
2519
|
+
// sandbox for any in-flight step was already destroyed by stopRun;
|
|
2520
|
+
// this is what keeps the *next* group from starting.
|
|
2521
|
+
const stopCheck = readRun(tenant, workspace, run.id);
|
|
2522
|
+
if (stopCheck?.stopRequested) {
|
|
2523
|
+
for (const s of run.steps) {
|
|
2524
|
+
if (s.status === "pending" || s.status === "running") {
|
|
2525
|
+
s.status = "skipped";
|
|
2526
|
+
s.skipReason = "run stopped";
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
run.status = "failed";
|
|
2530
|
+
run.stopRequested = true;
|
|
2531
|
+
save();
|
|
2532
|
+
break;
|
|
2533
|
+
}
|
|
2534
|
+
const freshGroup = ordered[gi];
|
|
2535
|
+
// Validate agents exist before launching the group.
|
|
2536
|
+
let missing = false;
|
|
2537
|
+
for (const step of freshGroup) {
|
|
2538
|
+
// Only what still has work to do. This walked every step in the
|
|
2539
|
+
// group regardless of status, which never showed on a fresh run
|
|
2540
|
+
// because they all start pending — but on a resumed one it rewrote
|
|
2541
|
+
// finished steps to `failed`, destroying the history it was
|
|
2542
|
+
// resuming around.
|
|
2543
|
+
if (step.status !== "pending")
|
|
2544
|
+
continue;
|
|
2545
|
+
const aDir = path.join(pDir, "agents", step.agent);
|
|
2546
|
+
if (!fs.existsSync(path.join(aDir, "agent.md"))) {
|
|
2547
|
+
step.status = "failed";
|
|
2548
|
+
step.events.push({
|
|
2549
|
+
t: new Date().toISOString(),
|
|
2550
|
+
type: "error",
|
|
2551
|
+
text: `agent "${step.agent}" not found in workspace`,
|
|
2552
|
+
});
|
|
2553
|
+
if (!step.optional)
|
|
2554
|
+
missing = true;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
save();
|
|
2558
|
+
if (missing) {
|
|
2559
|
+
run.status = "failed";
|
|
2560
|
+
break;
|
|
2561
|
+
}
|
|
2562
|
+
const ctx = context;
|
|
2563
|
+
// `when:` — skip a step whose condition isn't met by prior results.
|
|
2564
|
+
for (const step of freshGroup) {
|
|
2565
|
+
if (step.status !== "pending" || !step.when)
|
|
2566
|
+
continue;
|
|
2567
|
+
const met = (ctx ?? "").toLowerCase().includes(step.when.toLowerCase());
|
|
2568
|
+
if (!met) {
|
|
2569
|
+
step.status = "skipped";
|
|
2570
|
+
step.skipReason = `condition not met: when "${step.when}"`;
|
|
2571
|
+
step.events.push({
|
|
2572
|
+
t: new Date().toISOString(),
|
|
2573
|
+
type: "info",
|
|
2574
|
+
text: `skipped — previous results do not mention "${step.when}"`,
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
save();
|
|
2579
|
+
// `case:`/`else:` — exclusive routing. Of this group's case steps,
|
|
2580
|
+
// the FIRST whose text appears in the previous results runs; the
|
|
2581
|
+
// rest are routed past. `else:` runs only when no case matched.
|
|
2582
|
+
// (`when:` above stays independent — every matching when runs —
|
|
2583
|
+
// which is why routing is its own vocabulary instead of a mode.)
|
|
2584
|
+
const caseSteps = freshGroup.filter((s) => s.status === "pending" && s.case);
|
|
2585
|
+
if (caseSteps.length || freshGroup.some((s) => s.status === "pending" && s.else)) {
|
|
2586
|
+
let matchedCase = null;
|
|
2587
|
+
for (const step of caseSteps) {
|
|
2588
|
+
if (!matchedCase && (ctx ?? "").toLowerCase().includes(step.case.toLowerCase())) {
|
|
2589
|
+
matchedCase = step;
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
step.status = "skipped";
|
|
2593
|
+
step.skipReason = matchedCase
|
|
2594
|
+
? `routed past — "${matchedCase.case}" matched first`
|
|
2595
|
+
: `case not matched: "${step.case}"`;
|
|
2596
|
+
step.events.push({
|
|
2597
|
+
t: new Date().toISOString(),
|
|
2598
|
+
type: "info",
|
|
2599
|
+
text: step.skipReason,
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
for (const step of freshGroup) {
|
|
2603
|
+
if (step.status !== "pending" || !step.else)
|
|
2604
|
+
continue;
|
|
2605
|
+
if (matchedCase) {
|
|
2606
|
+
step.status = "skipped";
|
|
2607
|
+
step.skipReason = `routed past — "${matchedCase.case}" matched`;
|
|
2608
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: step.skipReason });
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
save();
|
|
2612
|
+
}
|
|
2613
|
+
// Approval gate — pause the whole run until a human decides. The run
|
|
2614
|
+
// record is the queue: it sits in awaiting-approval until the API
|
|
2615
|
+
// flips the step back to pending.
|
|
2616
|
+
// `!s.approvedAt` — a step approved before the process went away is
|
|
2617
|
+
// already answered. Filtering on status alone asked again.
|
|
2618
|
+
// `wait: event` parks in the same place: the gate is the same, the
|
|
2619
|
+
// key is held by a machine. A person can still release it by hand.
|
|
2620
|
+
const needsApproval = freshGroup.filter((s) => s.status === "pending" && (s.approve || s.ask || s.waitFor === "event") && !s.approvedAt);
|
|
2621
|
+
if (needsApproval.length) {
|
|
2622
|
+
for (const step of needsApproval) {
|
|
2623
|
+
step.status = "awaiting-approval";
|
|
2624
|
+
step.events.push({
|
|
2625
|
+
t: new Date().toISOString(),
|
|
2626
|
+
type: "info",
|
|
2627
|
+
// ask: is the same gate carrying a question — the answer
|
|
2628
|
+
// arrives as the approval note and rides into the prompt.
|
|
2629
|
+
text: step.ask
|
|
2630
|
+
? `waiting for an answer: ${step.ask}`
|
|
2631
|
+
: step.waitFor === "event"
|
|
2632
|
+
? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id)}`
|
|
2633
|
+
: "waiting for approval before running",
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
run.status = "awaiting-approval";
|
|
2637
|
+
save();
|
|
2638
|
+
// The gate's declared preview, resolved now against what the run
|
|
2639
|
+
// has actually produced — a glob at park time, a list of files on
|
|
2640
|
+
// the record for as long as the run waits.
|
|
2641
|
+
for (const s of needsApproval) {
|
|
2642
|
+
if (s.preview?.length)
|
|
2643
|
+
s.previewFiles = resolvePreview(path.join(workspaceDir(tenant, workspace), "storage"), s.preview);
|
|
2644
|
+
}
|
|
2645
|
+
save();
|
|
2646
|
+
if (opts.parkOnApproval) {
|
|
2647
|
+
// What the run has produced so far becomes stored files NOW, not
|
|
2648
|
+
// at the end: the gate shows the thumbnail a step is about to
|
|
2649
|
+
// publish, and the Storage page answers "what did it make" while
|
|
2650
|
+
// the person decides. Unchanged files are skipped by hash, and
|
|
2651
|
+
// the local copies stay for the resumed step to read.
|
|
2652
|
+
await harvestQuietly(tenant, workspace, run);
|
|
2653
|
+
// The gate has been on the record — and in an inbox — since the
|
|
2654
|
+
// save above, so a decision can land while the harvest runs.
|
|
2655
|
+
// The API wrote it to disk; it did not enqueue a resume, because
|
|
2656
|
+
// parkedAt was not set yet; and the save below, from memory,
|
|
2657
|
+
// would erase it. That was a lost approval and a run that waited
|
|
2658
|
+
// forever (2026-09-06, approved two seconds after it parked).
|
|
2659
|
+
// Take what landed, then do the enqueue the API would have done.
|
|
2660
|
+
const decided = adoptDecisions(run, readRun(tenant, workspace, run.id));
|
|
2661
|
+
// Hand the slot back. The approval API sees parkedAt and
|
|
2662
|
+
// re-enqueues; re-entering this loop skips finished groups and
|
|
2663
|
+
// lands back here with the decision already on the record.
|
|
2664
|
+
run.parkedAt = new Date().toISOString();
|
|
2665
|
+
parked = true;
|
|
2666
|
+
save();
|
|
2667
|
+
if (decided && run.steps.every((s) => s.status !== "awaiting-approval")) {
|
|
2668
|
+
await platform.enqueueResume(tenant, workspace, run.id);
|
|
2669
|
+
}
|
|
2670
|
+
return;
|
|
2671
|
+
}
|
|
2672
|
+
const decided = await waitForDecision(tenant, workspace, run.id, needsApproval.map((s) => run.steps.indexOf(s)));
|
|
2673
|
+
if (!decided) {
|
|
2674
|
+
run.status = "failed";
|
|
2675
|
+
break;
|
|
2676
|
+
}
|
|
2677
|
+
// Re-read: the API rewrote the file, so refresh statuses in place.
|
|
2678
|
+
const latest = readRun(tenant, workspace, run.id);
|
|
2679
|
+
if (latest) {
|
|
2680
|
+
latest.steps.forEach((s, i) => {
|
|
2681
|
+
run.steps[i].status = s.status;
|
|
2682
|
+
run.steps[i].events = s.events;
|
|
2683
|
+
// What the decision carried, not just that it happened: the
|
|
2684
|
+
// note and the event payload are what the step's prompt reads,
|
|
2685
|
+
// and copying status alone dropped them on the in-process path.
|
|
2686
|
+
run.steps[i].approvedAt = s.approvedAt;
|
|
2687
|
+
run.steps[i].approvalNote = s.approvalNote;
|
|
2688
|
+
run.steps[i].eventPayload = s.eventPayload;
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
run.status = "running";
|
|
2692
|
+
save();
|
|
2693
|
+
if (freshGroup.every((s) => s.status !== "pending")) {
|
|
2694
|
+
// everything in this group was rejected
|
|
2695
|
+
if (freshGroup.some((s) => s.status === "failed" && !s.optional)) {
|
|
2696
|
+
run.status = "failed";
|
|
2697
|
+
break;
|
|
2698
|
+
}
|
|
2699
|
+
gi += 1;
|
|
2700
|
+
continue;
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
// The hard cap: what is left of the flow's budget, shared evenly
|
|
2704
|
+
// across the steps this group launches together. Each step stops
|
|
2705
|
+
// itself mid-turn at its share, so a fan-out of twenty cannot end
|
|
2706
|
+
// twenty steps over — the shares sum to what was left. Between
|
|
2707
|
+
// groups the check above still refuses to start the next one.
|
|
2708
|
+
const launching = freshGroup.filter((s) => s.status === "pending");
|
|
2709
|
+
const ceilingUsd = stepCeiling(run.budgetUsd, runCost(run), launching.length);
|
|
2710
|
+
await Promise.all(launching
|
|
2711
|
+
.map(async (step) => {
|
|
2712
|
+
const attempts = (step.retry ?? 0) + 1;
|
|
2713
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
2714
|
+
step.attempts = attempt;
|
|
2715
|
+
step.status = "running";
|
|
2716
|
+
save();
|
|
2717
|
+
await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceilingUsd);
|
|
2718
|
+
// runStep mutates step.status; read it through a widened local
|
|
2719
|
+
// so TS doesn't keep the "running" narrowing from above.
|
|
2720
|
+
const outcome = step.status;
|
|
2721
|
+
if (outcome !== "failed" || attempt === attempts)
|
|
2722
|
+
break;
|
|
2723
|
+
// A stop destroys the step's sandbox, which reads here as a
|
|
2724
|
+
// failed attempt — and a failed attempt used to be retried in
|
|
2725
|
+
// a fresh sandbox, so a stopped `retry: 2` step ran twice
|
|
2726
|
+
// more. save() merges the flag from the record.
|
|
2727
|
+
save();
|
|
2728
|
+
if (run.stopRequested)
|
|
2729
|
+
break;
|
|
2730
|
+
step.events.push({
|
|
2731
|
+
t: new Date().toISOString(),
|
|
2732
|
+
type: "info",
|
|
2733
|
+
text: `attempt ${attempt} failed — retrying (${attempts - attempt} left)`,
|
|
2734
|
+
});
|
|
2735
|
+
save();
|
|
2736
|
+
}
|
|
2737
|
+
}));
|
|
2738
|
+
// on-fail: another agent takes the step over. Sequential and rare:
|
|
2739
|
+
// the rescue is a second full attempt at the step's job with the
|
|
2740
|
+
// failure in front of it, and its success is the step's success —
|
|
2741
|
+
// the trace keeps both records, so "who actually did this" stays
|
|
2742
|
+
// answerable.
|
|
2743
|
+
// Not after a stop: the failure a stop leaves behind is not one a
|
|
2744
|
+
// rescuer should take over. The merge in save() surfaces the flag.
|
|
2745
|
+
save();
|
|
2746
|
+
for (const step of freshGroup) {
|
|
2747
|
+
if (run.stopRequested)
|
|
2748
|
+
break;
|
|
2749
|
+
if (step.status !== "failed" || !step.onFail)
|
|
2750
|
+
continue;
|
|
2751
|
+
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
2752
|
+
if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
2753
|
+
step.events.push({
|
|
2754
|
+
t: new Date().toISOString(),
|
|
2755
|
+
type: "error",
|
|
2756
|
+
text: `on-fail: "${step.onFail}" is not an agent in this workspace — no rescue`,
|
|
2757
|
+
});
|
|
2758
|
+
continue;
|
|
2759
|
+
}
|
|
2760
|
+
step.events.push({
|
|
2761
|
+
t: new Date().toISOString(),
|
|
2762
|
+
type: "info",
|
|
2763
|
+
text: `failed — handing to [[${step.onFail}]] (on-fail)`,
|
|
2764
|
+
});
|
|
2765
|
+
const failure = (step.result ?? step.events.filter((e) => e.type === "error").map((e) => e.text).join("\n")).slice(0, 4000);
|
|
2766
|
+
const rescue = {
|
|
2767
|
+
agent: step.onFail,
|
|
2768
|
+
instruction: `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
|
|
2769
|
+
`Its failure, for whatever it is worth:\n${failure}\n</rescue>`,
|
|
2770
|
+
group: step.group,
|
|
2771
|
+
optional: false,
|
|
2772
|
+
model: step.model,
|
|
2773
|
+
effort: step.effort,
|
|
2774
|
+
timeout: step.timeout,
|
|
2775
|
+
// The rescuer inherits the contract, not just the job: a step
|
|
2776
|
+
// that promised data still owes data when someone else does it.
|
|
2777
|
+
output: step.output,
|
|
2778
|
+
attempts: 0,
|
|
2779
|
+
status: "pending",
|
|
2780
|
+
events: [],
|
|
2781
|
+
result: null,
|
|
2782
|
+
costUsd: null,
|
|
2783
|
+
};
|
|
2784
|
+
run.steps.splice(run.steps.indexOf(step) + 1, 0, rescue);
|
|
2785
|
+
save();
|
|
2786
|
+
rescue.attempts = 1;
|
|
2787
|
+
rescue.status = "running";
|
|
2788
|
+
save();
|
|
2789
|
+
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData);
|
|
2790
|
+
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
2791
|
+
const rescueOutcome = rescue.status;
|
|
2792
|
+
if (rescueOutcome === "completed") {
|
|
2793
|
+
step.status = "completed";
|
|
2794
|
+
step.result = rescue.result;
|
|
2795
|
+
step.conclusion = rescue.conclusion;
|
|
2796
|
+
if (rescue.data !== undefined)
|
|
2797
|
+
step.data = rescue.data;
|
|
2798
|
+
step.events.push({
|
|
2799
|
+
t: new Date().toISOString(),
|
|
2800
|
+
type: "info",
|
|
2801
|
+
text: `rescued by [[${step.onFail}]] — its result stands as this step's`,
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
else {
|
|
2805
|
+
step.events.push({
|
|
2806
|
+
t: new Date().toISOString(),
|
|
2807
|
+
type: "error",
|
|
2808
|
+
text: `on-fail: [[${step.onFail}]] also failed`,
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
save();
|
|
2812
|
+
}
|
|
2813
|
+
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional);
|
|
2814
|
+
if (requiredFailed) {
|
|
2815
|
+
run.status = "failed";
|
|
2816
|
+
break;
|
|
2817
|
+
}
|
|
2818
|
+
groupResults[gi] = resultsOf(freshGroup);
|
|
2819
|
+
groupConclusions[gi] = conclusionsOf(freshGroup);
|
|
2820
|
+
groupData[gi] = dataOf(freshGroup);
|
|
2821
|
+
// delegate: — the completed chooser's picks become a fresh group
|
|
2822
|
+
// right after this one. The choice SET is the file's; only the
|
|
2823
|
+
// picks and their instructions are the model's, and both are on
|
|
2824
|
+
// the record. Once per chooser, loop rewinds included.
|
|
2825
|
+
for (const chooser of freshGroup) {
|
|
2826
|
+
if (!chooser.delegate?.length || chooser.status !== "completed" || chooser.delegated)
|
|
2827
|
+
continue;
|
|
2828
|
+
chooser.delegated = true;
|
|
2829
|
+
const allowed = new Set(chooser.delegate);
|
|
2830
|
+
const picks = [];
|
|
2831
|
+
const seenAgents = new Set();
|
|
2832
|
+
for (const line of (chooser.result ?? "").split("\n")) {
|
|
2833
|
+
const m = line.match(/^\s*(?:[-*\d.)\s]*)\[?\[?([a-z0-9-]+)\]?\]?\s*:\s*(.+)$/);
|
|
2834
|
+
if (!m || !allowed.has(m[1]) || seenAgents.has(m[1]))
|
|
2835
|
+
continue;
|
|
2836
|
+
if (!fs.existsSync(path.join(pDir, "agents", m[1], "agent.md")))
|
|
2837
|
+
continue;
|
|
2838
|
+
seenAgents.add(m[1]);
|
|
2839
|
+
picks.push({
|
|
2840
|
+
agent: m[1],
|
|
2841
|
+
instruction: m[2].trim(),
|
|
2842
|
+
group: chooser.group,
|
|
2843
|
+
optional: false,
|
|
2844
|
+
attempts: 0,
|
|
2845
|
+
status: "pending",
|
|
2846
|
+
events: [],
|
|
2847
|
+
result: null,
|
|
2848
|
+
costUsd: null,
|
|
2849
|
+
});
|
|
2850
|
+
if (picks.length >= 5)
|
|
2851
|
+
break;
|
|
2852
|
+
}
|
|
2853
|
+
// Delegating to nobody is a legitimate answer, not a fault: a
|
|
2854
|
+
// `delegate:` set is who the step MAY call on, and most runs of a
|
|
2855
|
+
// healthy flow need none of them. Logging that as an error put a
|
|
2856
|
+
// red line on a step that did exactly the right thing, which is
|
|
2857
|
+
// how a person learns to stop reading red lines.
|
|
2858
|
+
chooser.events.push({
|
|
2859
|
+
t: new Date().toISOString(),
|
|
2860
|
+
type: "info",
|
|
2861
|
+
text: picks.length
|
|
2862
|
+
? `delegated to ${picks.map((p) => `[[${p.agent}]]`).join(", ")}`
|
|
2863
|
+
: `delegate: chose nobody (could have called on ${chooser.delegate.join(", ")})`,
|
|
2864
|
+
});
|
|
2865
|
+
if (picks.length) {
|
|
2866
|
+
run.steps.splice(run.steps.indexOf(chooser) + 1, 0, ...picks);
|
|
2867
|
+
ordered.splice(gi + 1, 0, picks);
|
|
2868
|
+
}
|
|
2869
|
+
save();
|
|
2870
|
+
}
|
|
2871
|
+
// Evaluator loop: a completed step with `loop:` whose result lacks
|
|
2872
|
+
// its `until:` marker sends this group and the one before it around
|
|
2873
|
+
// again — at most `loop:` times, so the worst case is still readable
|
|
2874
|
+
// off the flow file. Approval marks survive the rewind: a person
|
|
2875
|
+
// answered, and the question has not changed.
|
|
2876
|
+
const looper = freshGroup.find((s) => s.loop && s.until && s.status === "completed");
|
|
2877
|
+
if (looper?.until &&
|
|
2878
|
+
!saysUntilMarker(looper.result ?? null, looper.until)) {
|
|
2879
|
+
if ((looper.loopRemaining ?? 0) > 0 && gi > 0) {
|
|
2880
|
+
looper.loopRemaining = (looper.loopRemaining ?? 0) - 1;
|
|
2881
|
+
looper.events.push({
|
|
2882
|
+
t: new Date().toISOString(),
|
|
2883
|
+
type: "info",
|
|
2884
|
+
text: `loop: result does not say "${looper.until}" on a line of its own — winding back one group (${looper.loopRemaining} cycle${looper.loopRemaining === 1 ? "" : "s"} left)`,
|
|
2885
|
+
});
|
|
2886
|
+
for (const s of [...ordered[gi - 1], ...freshGroup]) {
|
|
2887
|
+
if (s.status === "skipped" && s.each)
|
|
2888
|
+
continue; // an expanded template stays expanded
|
|
2889
|
+
s.status = "pending";
|
|
2890
|
+
s.result = null;
|
|
2891
|
+
}
|
|
2892
|
+
groupResults[gi] = null;
|
|
2893
|
+
groupResults[gi - 1] = null;
|
|
2894
|
+
groupConclusions[gi] = null;
|
|
2895
|
+
groupConclusions[gi - 1] = null;
|
|
2896
|
+
save();
|
|
2897
|
+
gi -= 1;
|
|
2898
|
+
continue;
|
|
2899
|
+
}
|
|
2900
|
+
looper.status = "failed";
|
|
2901
|
+
looper.events.push({
|
|
2902
|
+
t: new Date().toISOString(),
|
|
2903
|
+
type: "error",
|
|
2904
|
+
text: `loop exhausted: after ${looper.loop} cycle${looper.loop === 1 ? "" : "s"} the result still does not say "${looper.until}"`,
|
|
2905
|
+
});
|
|
2906
|
+
save();
|
|
2907
|
+
if (!looper.optional) {
|
|
2908
|
+
run.status = "failed";
|
|
2909
|
+
break;
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
gi += 1;
|
|
2913
|
+
}
|
|
2914
|
+
if (run.status === "running")
|
|
2915
|
+
run.status = "completed";
|
|
2916
|
+
for (const s of run.steps)
|
|
2917
|
+
if (s.status === "pending")
|
|
2918
|
+
s.status = "skipped";
|
|
2919
|
+
}
|
|
2920
|
+
catch (err) {
|
|
2921
|
+
run.status = "failed";
|
|
2922
|
+
run.steps.forEach((s) => {
|
|
2923
|
+
if (s.status === "running") {
|
|
2924
|
+
s.status = "failed";
|
|
2925
|
+
s.events.push({
|
|
2926
|
+
t: new Date().toISOString(),
|
|
2927
|
+
type: "error",
|
|
2928
|
+
text: err instanceof Error ? err.message : String(err),
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2933
|
+
finally {
|
|
2934
|
+
if (!parked) {
|
|
2935
|
+
run.finishedAt = new Date().toISOString();
|
|
2936
|
+
// What this run concluded, in one line, captured before the record is
|
|
2937
|
+
// saved for the last time. Read from the reply that already says it
|
|
2938
|
+
// rather than asked for separately: the folder gains nothing to
|
|
2939
|
+
// learn, and the runs list and the notification stop having to say
|
|
2940
|
+
// only "completed".
|
|
2941
|
+
run.summary = runSummary(run);
|
|
2942
|
+
// Archive before the next run resets outputs/ — a failed run's
|
|
2943
|
+
// artifacts are usually the most interesting ones, so this runs on
|
|
2944
|
+
// failure too.
|
|
2945
|
+
try {
|
|
2946
|
+
stampMemories();
|
|
2947
|
+
archive();
|
|
2948
|
+
}
|
|
2949
|
+
catch {
|
|
2950
|
+
// never let bookkeeping fail a run that already finished
|
|
2951
|
+
}
|
|
2952
|
+
// Whatever the run left in storage/ becomes a stored file, stamped with
|
|
2953
|
+
// the run and the agent that wrote it — which is what lets the
|
|
2954
|
+
// dashboard answer "where did this PDF come from?" without a join
|
|
2955
|
+
// table. On failure too: a failed run's half-written artifact is
|
|
2956
|
+
// usually the one worth looking at.
|
|
2957
|
+
try {
|
|
2958
|
+
const { saved, errors } = await harvestFiles(tenant, workspace, `run:${run.id}`);
|
|
2959
|
+
const last = run.steps.findLast((s) => s.status !== "pending") ?? run.steps[0];
|
|
2960
|
+
if (saved.length) {
|
|
2961
|
+
last?.events.push({
|
|
2962
|
+
t: new Date().toISOString(),
|
|
2963
|
+
type: "info",
|
|
2964
|
+
text: `files: saved ${saved.join(", ")}`,
|
|
2965
|
+
});
|
|
2966
|
+
}
|
|
2967
|
+
for (const e of errors) {
|
|
2968
|
+
last?.events.push({ t: new Date().toISOString(), type: "error", text: `files: ${e}` });
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
catch {
|
|
2972
|
+
// same rule as archive(): bookkeeping never fails a finished run
|
|
2973
|
+
}
|
|
2974
|
+
save();
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
})();
|
|
2978
|
+
}
|
|
2979
|
+
function readFlow(tenant, workspace, flowName) {
|
|
2980
|
+
const dir = path.join(workspaceDir(tenant, workspace), "flows");
|
|
2981
|
+
if (!fs.existsSync(dir))
|
|
2982
|
+
return null;
|
|
2983
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
2984
|
+
const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
|
|
2985
|
+
if (flow.name === flowName)
|
|
2986
|
+
return flow;
|
|
2987
|
+
}
|
|
2988
|
+
return null;
|
|
2989
|
+
}
|
|
2990
|
+
// Splice `[[flow:other]]` steps into the parent's step list, renumbering
|
|
2991
|
+
// groups so the nested flow's own parallelism survives. Depth-limited, and
|
|
2992
|
+
// a cycle raises rather than expanding forever.
|
|
2993
|
+
function expandSubflows(tenant, workspace, steps, seen = [], depth = 0) {
|
|
2994
|
+
if (depth > 3)
|
|
2995
|
+
throw new Error("flow nesting too deep (max 3)");
|
|
2996
|
+
const out = [];
|
|
2997
|
+
let group = 0;
|
|
2998
|
+
for (const [i, step] of steps.entries()) {
|
|
2999
|
+
const startsNewGroup = i === 0 || step.group !== steps[i - 1].group;
|
|
3000
|
+
if (startsNewGroup)
|
|
3001
|
+
group += 1;
|
|
3002
|
+
if (!step.subflow) {
|
|
3003
|
+
out.push({ ...step, group });
|
|
3004
|
+
continue;
|
|
3005
|
+
}
|
|
3006
|
+
if (seen.includes(step.subflow)) {
|
|
3007
|
+
throw new Error(`flow cycle: ${[...seen, step.subflow].join(" → ")}`);
|
|
3008
|
+
}
|
|
3009
|
+
const nested = readFlow(tenant, workspace, step.subflow);
|
|
3010
|
+
if (!nested)
|
|
3011
|
+
throw new Error(`flow "${step.subflow}" not found`);
|
|
3012
|
+
const nestedSteps = expandSubflows(tenant, workspace, nested.steps, [...seen, step.subflow], depth + 1);
|
|
3013
|
+
// Nested groups continue from here; the parent's next group follows them.
|
|
3014
|
+
const base = group - 1;
|
|
3015
|
+
let maxGroup = group;
|
|
3016
|
+
for (const ns of nestedSteps) {
|
|
3017
|
+
const g = base + ns.group;
|
|
3018
|
+
maxGroup = Math.max(maxGroup, g);
|
|
3019
|
+
out.push({
|
|
3020
|
+
...ns,
|
|
3021
|
+
group: g,
|
|
3022
|
+
optional: ns.optional || step.optional,
|
|
3023
|
+
instruction: ns.instruction || step.instruction,
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
group = maxGroup;
|
|
3027
|
+
}
|
|
3028
|
+
return out;
|
|
3029
|
+
}
|
|
3030
|
+
export function loadFlow(tenant, workspace, flowName) {
|
|
3031
|
+
const flow = readFlow(tenant, workspace, flowName);
|
|
3032
|
+
if (!flow)
|
|
3033
|
+
return null;
|
|
3034
|
+
if (!flow.steps.some((s) => s.subflow))
|
|
3035
|
+
return flow;
|
|
3036
|
+
return { ...flow, steps: expandSubflows(tenant, workspace, flow.steps, [flowName]) };
|
|
3037
|
+
}
|
|
3038
|
+
// ---------------------------------------------------------------- recovery
|
|
3039
|
+
/**
|
|
3040
|
+
* How long a run may sit in `running` with nobody touching it before it is
|
|
3041
|
+
* considered abandoned.
|
|
3042
|
+
*
|
|
3043
|
+
* Long enough that a slow model call is never mistaken for a dead process —
|
|
3044
|
+
* a single step can legitimately take minutes — and short enough that a
|
|
3045
|
+
* restarted server does not leave yesterday's run looking live.
|
|
3046
|
+
*/
|
|
3047
|
+
// A run this process is driving is never abandoned, however long its step
|
|
3048
|
+
// is quiet — a 40-minute script is work, not a fault, and the platform sets
|
|
3049
|
+
// no clock on work. A `running` run nobody here is driving has lost its
|
|
3050
|
+
// driver (a restart; this is the only process that drives) and is closed
|
|
3051
|
+
// once it has been quiet for two minutes — the margin is for a record
|
|
3052
|
+
// written a beat before its driver registered, not a judgement about pace.
|
|
3053
|
+
const ABANDONED_AFTER_MS = 2 * 60 * 1000;
|
|
3054
|
+
/** The most recent moment a run showed any sign of life. */
|
|
3055
|
+
function lastActivity(run) {
|
|
3056
|
+
let latest = Date.parse(run.startedAt);
|
|
3057
|
+
for (const step of run.steps) {
|
|
3058
|
+
for (const event of step.events) {
|
|
3059
|
+
const t = Date.parse(event.t);
|
|
3060
|
+
if (t > latest)
|
|
3061
|
+
latest = t;
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
return latest;
|
|
3065
|
+
}
|
|
3066
|
+
/**
|
|
3067
|
+
* Close out runs abandoned by a process that is no longer here.
|
|
3068
|
+
*
|
|
3069
|
+
* Runs execute in the server process, and nothing writes a terminal status
|
|
3070
|
+
* when that process goes away — so a deploy, a crash or a Ctrl-C during a run
|
|
3071
|
+
* leaves `status: "running"` on disk forever. The run history then contains
|
|
3072
|
+
* entries that are not merely stale but false: they claim to be in progress,
|
|
3073
|
+
* the dashboard renders them as live, and no one can tell them apart from a
|
|
3074
|
+
* step that is genuinely still thinking.
|
|
3075
|
+
*
|
|
3076
|
+
* Called at startup, before anything reads run history. A run still being
|
|
3077
|
+
* worked on by a live process is protected by the idle window, not by a lock —
|
|
3078
|
+
* so this is safe to call while runs are in flight, and deliberately
|
|
3079
|
+
* conservative about what it declares dead.
|
|
3080
|
+
*
|
|
3081
|
+
* A run genuinely waiting for a person is left alone: that state legitimately
|
|
3082
|
+
* outlives any process. A run whose person already answered is not waiting,
|
|
3083
|
+
* and is picked back up — see below.
|
|
3084
|
+
*/
|
|
3085
|
+
export function reconcileRuns(tenant, now = Date.now()) {
|
|
3086
|
+
const closed = [];
|
|
3087
|
+
for (const workspace of listWorkspaces(tenant)) {
|
|
3088
|
+
for (const summary of listRuns(tenant, workspace.name)) {
|
|
3089
|
+
if (drivingRuns.has(summary.id))
|
|
3090
|
+
continue;
|
|
3091
|
+
if (now - lastActivity(summary) < ABANDONED_AFTER_MS)
|
|
3092
|
+
continue;
|
|
3093
|
+
// Approved, then abandoned. Approval only writes to the run record; the
|
|
3094
|
+
// loop that acts on it polls in memory, inside the process that started
|
|
3095
|
+
// the run. If that process goes away between the click and the next
|
|
3096
|
+
// poll, the decision is on disk with nobody left to read it — and the
|
|
3097
|
+
// run sits in `awaiting-approval` with no step awaiting approval, so
|
|
3098
|
+
// the dashboard cannot even offer the button again. Nothing else here
|
|
3099
|
+
// rescues it: this status is exempt from being closed, by design.
|
|
3100
|
+
if (summary.status === "awaiting-approval") {
|
|
3101
|
+
const stillAsking = summary.steps.some((s) => s.status === "awaiting-approval");
|
|
3102
|
+
const answered = summary.steps.some((s) => s.approvedAt && s.status === "pending");
|
|
3103
|
+
if (stillAsking || !answered)
|
|
3104
|
+
continue;
|
|
3105
|
+
const run = readRun(tenant, workspace.name, summary.id);
|
|
3106
|
+
if (!run || run.status !== "awaiting-approval")
|
|
3107
|
+
continue;
|
|
3108
|
+
run.status = "running";
|
|
3109
|
+
writeRun(tenant, workspace.name, run);
|
|
3110
|
+
// Park at any *later* gate rather than poll: reconcile runs inside
|
|
3111
|
+
// long-lived server processes, where a 24h file-poll is exactly the
|
|
3112
|
+
// slot-burning the queue exists to avoid.
|
|
3113
|
+
void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true });
|
|
3114
|
+
closed.push({
|
|
3115
|
+
runId: run.id,
|
|
3116
|
+
tenant,
|
|
3117
|
+
workspace: workspace.name,
|
|
3118
|
+
action: "resumed",
|
|
3119
|
+
interrupted: [],
|
|
3120
|
+
});
|
|
3121
|
+
continue;
|
|
3122
|
+
}
|
|
3123
|
+
if (summary.status !== "running")
|
|
3124
|
+
continue;
|
|
3125
|
+
const run = readRun(tenant, workspace.name, summary.id);
|
|
3126
|
+
if (!run || run.status !== "running")
|
|
3127
|
+
continue;
|
|
3128
|
+
const interrupted = [];
|
|
3129
|
+
for (const step of run.steps) {
|
|
3130
|
+
if (step.status === "running") {
|
|
3131
|
+
interrupted.push(step.agent);
|
|
3132
|
+
step.status = "failed";
|
|
3133
|
+
step.events.push({
|
|
3134
|
+
t: new Date(now).toISOString(),
|
|
3135
|
+
type: "error",
|
|
3136
|
+
text: "interrupted — the process running this step went away before it finished",
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
3139
|
+
else if (step.status === "pending") {
|
|
3140
|
+
step.status = "skipped";
|
|
3141
|
+
step.skipReason = "the run was interrupted before this step started";
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
run.status = "failed";
|
|
3145
|
+
run.finishedAt = new Date(now).toISOString();
|
|
3146
|
+
writeRun(tenant, workspace.name, run);
|
|
3147
|
+
// The record is closed; make the sandbox match. A step with no
|
|
3148
|
+
// `timeout:` has no pod deadline, so an orphan from a mid-step restart
|
|
3149
|
+
// is reaped here, by the run that owned it, not by a clock.
|
|
3150
|
+
try {
|
|
3151
|
+
if (process.env.FOLDRUN_RUN_ISOLATION === "container")
|
|
3152
|
+
killRunSandboxes(run.id);
|
|
3153
|
+
else
|
|
3154
|
+
platform.killRunSandboxes(run.id);
|
|
3155
|
+
}
|
|
3156
|
+
catch {
|
|
3157
|
+
// a sandbox we cannot reach must not stop the reconcile
|
|
3158
|
+
}
|
|
3159
|
+
closed.push({ runId: run.id, tenant, workspace: workspace.name, action: "closed", interrupted });
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
return closed;
|
|
3163
|
+
}
|
|
3164
|
+
/**
|
|
3165
|
+
* The same, for every account this installation holds.
|
|
3166
|
+
*
|
|
3167
|
+
* A server reconciles on boot, and it boots for everyone — reconciling only
|
|
3168
|
+
* one hard-coded account left every other account's interrupted runs marked
|
|
3169
|
+
* `running` forever, with no process left to finish them.
|
|
3170
|
+
*/
|
|
3171
|
+
export function reconcileAllRuns(now = Date.now()) {
|
|
3172
|
+
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now));
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Wait for a run to stop being in progress.
|
|
3176
|
+
*
|
|
3177
|
+
* Runs start and return immediately, which is right for a schedule and wrong
|
|
3178
|
+
* for a caller putting an agent behind their own request: they want the
|
|
3179
|
+
* answer, not a receipt. Polls the run record rather than holding a callback,
|
|
3180
|
+
* so it works from a different process than the one doing the work.
|
|
3181
|
+
*
|
|
3182
|
+
* `awaiting-approval` counts as stopping. It is not finished, but it is not
|
|
3183
|
+
* going to progress without a person, and a caller blocked on it would hang
|
|
3184
|
+
* for as long as the approval window allows.
|
|
3185
|
+
*/
|
|
3186
|
+
export async function waitForRun(tenant, workspace, runId, timeoutMs = 5 * 60_000) {
|
|
3187
|
+
const deadline = Date.now() + timeoutMs;
|
|
3188
|
+
for (;;) {
|
|
3189
|
+
const run = readRun(tenant, workspace, runId);
|
|
3190
|
+
if (!run)
|
|
3191
|
+
return { run: null, timedOut: false };
|
|
3192
|
+
// Queued counts as in flight — a caller asking to wait wants the result,
|
|
3193
|
+
// not a report that the worker hadn't picked the job up yet.
|
|
3194
|
+
if (run.status !== "running" && run.status !== "queued")
|
|
3195
|
+
return { run, timedOut: false };
|
|
3196
|
+
if (Date.now() >= deadline)
|
|
3197
|
+
return { run, timedOut: true };
|
|
3198
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
/**
|
|
3202
|
+
* What a run produced, for a caller that wanted an answer.
|
|
3203
|
+
*
|
|
3204
|
+
* The last step that actually returned something: a flow's final step is the
|
|
3205
|
+
* conclusion, and skipping back past steps that were skipped or produced
|
|
3206
|
+
* nothing is what makes `?wait=true` useful rather than merely blocking.
|
|
3207
|
+
*/
|
|
3208
|
+
export function runResult(run) {
|
|
3209
|
+
for (let i = run.steps.length - 1; i >= 0; i--) {
|
|
3210
|
+
const r = run.steps[i].result;
|
|
3211
|
+
if (r && r.trim())
|
|
3212
|
+
return r;
|
|
3213
|
+
}
|
|
3214
|
+
return null;
|
|
3215
|
+
}
|