@foldrun/core 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- package/dist/src/agent-import.js +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- package/dist/src/alerts.d.ts +60 -0
- package/dist/src/alerts.js +161 -0
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/capture.d.ts +23 -0
- package/dist/src/capture.js +80 -0
- package/dist/src/compare.d.ts +57 -0
- package/dist/src/compare.js +116 -0
- package/dist/src/completions.js +26 -3
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +237 -13
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +27 -2
- package/dist/src/evals.js +62 -19
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +60 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +33 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/history.d.ts +4 -0
- package/dist/src/history.js +15 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/insights.d.ts +30 -0
- package/dist/src/insights.js +152 -0
- package/dist/src/issues.d.ts +140 -0
- package/dist/src/issues.js +302 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/library.js +1 -1
- package/dist/src/limits.d.ts +8 -0
- package/dist/src/limits.js +36 -5
- package/dist/src/linkable.js +1 -1
- package/dist/src/matter.d.ts +42 -0
- package/dist/src/matter.js +132 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/network.d.ts +12 -1
- package/dist/src/network.js +35 -8
- package/dist/src/notify.d.ts +16 -0
- package/dist/src/notify.js +17 -7
- package/dist/src/okf.js +1 -1
- package/dist/src/online-checks.d.ts +76 -0
- package/dist/src/online-checks.js +432 -0
- package/dist/src/operator.d.ts +277 -0
- package/dist/src/operator.js +176 -2
- package/dist/src/otel-config.d.ts +18 -0
- package/dist/src/otel-config.js +69 -0
- package/dist/src/otel.d.ts +38 -0
- package/dist/src/otel.js +85 -0
- package/dist/src/outcome.d.ts +40 -0
- package/dist/src/outcome.js +77 -0
- package/dist/src/package.js +9 -2
- package/dist/src/platform.d.ts +1 -1
- package/dist/src/prompt-layers.d.ts +30 -0
- package/dist/src/prompt-layers.js +77 -0
- package/dist/src/provider-health.js +2 -2
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +25 -2
- package/dist/src/run-container.js +61 -6
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +471 -102
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +30 -1
- package/dist/src/scan.js +145 -14
- package/dist/src/starter.js +27 -2
- package/dist/src/step-exec.d.ts +39 -3
- package/dist/src/step-exec.js +118 -15
- package/dist/src/store.d.ts +144 -3
- package/dist/src/store.js +150 -7
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +57 -0
- package/dist/src/tool-names.js +1 -1
- package/dist/src/tool-programs.js +1 -1
- package/dist/src/trace-tree.d.ts +40 -0
- package/dist/src/trace-tree.js +156 -0
- package/dist/src/translator.d.ts +8 -0
- package/dist/src/translator.js +7 -2
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/workspace-check.d.ts +18 -0
- package/dist/src/workspace-check.js +34 -0
- package/package.json +2 -2
- package/src/agent-activity.ts +32 -0
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/alerts.ts +190 -0
- package/src/browser-pod.ts +3 -0
- package/src/capture.ts +88 -0
- package/src/compare.ts +146 -0
- package/src/completions.ts +27 -3
- package/src/deploy.ts +238 -12
- package/src/egress.ts +12 -2
- package/src/evals.ts +64 -19
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +83 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +33 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/history.ts +13 -0
- package/src/hooks.ts +1 -1
- package/src/insights.ts +174 -0
- package/src/issues.ts +396 -0
- package/src/judge-verdict.ts +52 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +145 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +35 -7
- package/src/notify.ts +22 -7
- package/src/okf.ts +1 -1
- package/src/online-checks.ts +450 -0
- package/src/operator.ts +213 -3
- package/src/otel-config.ts +67 -0
- package/src/otel.ts +100 -0
- package/src/outcome.ts +90 -0
- package/src/package.ts +8 -2
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +2 -2
- package/src/providers.ts +7 -2
- package/src/run-container.ts +77 -8
- package/src/runner.ts +477 -79
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +153 -14
- package/src/starter.ts +27 -2
- package/src/step-exec.ts +168 -17
- package/src/store.ts +268 -9
- package/src/tokens.ts +68 -0
- package/src/tool-names.ts +1 -1
- package/src/tool-programs.ts +1 -1
- package/src/trace-tree.ts +187 -0
- package/src/translator.ts +13 -2
- package/src/trigger-log.ts +1 -0
- package/src/workspace-check.ts +41 -0
package/dist/src/runner.js
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
// group receives every prior result, labeled per agent. Optional steps ("2?")
|
|
3
3
|
// fail without failing the flow. A direct agent run is a one-step ad-hoc
|
|
4
4
|
// flow, so there is a single execution path.
|
|
5
|
+
import { latestRevisionId } from "./history.js";
|
|
6
|
+
import { recordCallIo, redactionsFor } from "./capture.js";
|
|
7
|
+
import { AUTH_REFUSAL } from "./issues.js";
|
|
5
8
|
import { hookNames } from "./hooks.js";
|
|
6
9
|
import fs from "node:fs";
|
|
7
10
|
import os from "node:os";
|
|
8
11
|
import path from "node:path";
|
|
9
|
-
import matter from "
|
|
12
|
+
import matter, { parseLenient, configMatter } from "./matter.js";
|
|
10
13
|
import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
|
|
11
14
|
import { cascadeLimits, toolOwners } from "./limits.js";
|
|
12
15
|
import { resolveAgentPath } from "./confine.js";
|
|
@@ -14,7 +17,8 @@ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js
|
|
|
14
17
|
import { eventUrl } from "./webhook.js";
|
|
15
18
|
import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
|
|
16
19
|
import { hostSafeEnv, platformApiUrl } from "./host-env.js";
|
|
17
|
-
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
|
|
20
|
+
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.js";
|
|
21
|
+
import { parseJudge } from "./judge-verdict.js";
|
|
18
22
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
|
|
19
23
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
|
|
20
24
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
|
|
@@ -29,15 +33,16 @@ const isolatedRun = () => {
|
|
|
29
33
|
};
|
|
30
34
|
import { gatherConsults, buildConsultTools } from "./agent-tools.js";
|
|
31
35
|
import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools } from "./subagents.js";
|
|
32
|
-
import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.js";
|
|
36
|
+
import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
|
|
33
37
|
import { sendPlainNotification } from "./notify.js";
|
|
34
38
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
|
|
35
39
|
import { refNames } from "./refs.js";
|
|
36
|
-
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
40
|
+
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isRescueRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
37
41
|
import { canReadBack } from "./tool-output.js";
|
|
42
|
+
import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
|
|
38
43
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
|
|
39
44
|
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
|
|
40
|
-
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
|
|
45
|
+
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.js";
|
|
41
46
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
42
47
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
|
|
43
48
|
import { attachOperations, prefetchOpenApi } from "./openapi.js";
|
|
@@ -155,6 +160,105 @@ export function slotPool(size) {
|
|
|
155
160
|
},
|
|
156
161
|
};
|
|
157
162
|
}
|
|
163
|
+
/** Steps whose model has begun its first response — the step's executor
|
|
164
|
+
* says so the moment the stream starts (EventExtra.responding). Memory
|
|
165
|
+
* only: it is scheduling, not something the run record needs. */
|
|
166
|
+
const respondingSteps = new WeakSet();
|
|
167
|
+
export function markResponding(step) {
|
|
168
|
+
respondingSteps.add(step);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Is this step's prompt in the cache yet? An entry is readable from the
|
|
172
|
+
* moment the response to the request that wrote it begins streaming
|
|
173
|
+
* (Anthropic's prompt-caching docs), which is what `responding` marks.
|
|
174
|
+
* The first text, tool call or recorded model call says the same a little
|
|
175
|
+
* later, for an executor that cannot see the stream (the stub, an older
|
|
176
|
+
* driver). Resolves on the first of those, or when the step is over
|
|
177
|
+
* without one (a refusal before the first turn, a failed start).
|
|
178
|
+
*/
|
|
179
|
+
export function untilFirstReply(step, done, pollMs = 250) {
|
|
180
|
+
const replied = () => respondingSteps.has(step) || step.events.some((e) => e.type === "text" || e.type === "tool") || (step.calls?.length ?? 0) > 0;
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
let finished = false;
|
|
183
|
+
const stop = () => {
|
|
184
|
+
finished = true;
|
|
185
|
+
resolve();
|
|
186
|
+
};
|
|
187
|
+
done.then(stop, stop);
|
|
188
|
+
const tick = () => {
|
|
189
|
+
if (finished)
|
|
190
|
+
return;
|
|
191
|
+
if (replied())
|
|
192
|
+
return stop();
|
|
193
|
+
setTimeout(tick, pollMs).unref?.();
|
|
194
|
+
};
|
|
195
|
+
tick();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Steps of one agent launched together share a prompt prefix — tools and
|
|
200
|
+
* system prompt — and that is what the model's cache keeps. The cache is
|
|
201
|
+
* written by the first request that carries a prefix and readable only
|
|
202
|
+
* once that response has begun, so N copies started at once all write it
|
|
203
|
+
* and none reads: a fan-out of twenty paid twenty cold prefixes. The gate
|
|
204
|
+
* lets the first instance of each agent go alone and holds its siblings
|
|
205
|
+
* until its first response has begun (or it ended); then they all start
|
|
206
|
+
* and read what it wrote — Claude Code holds a workflow's fan-out the same
|
|
207
|
+
* way. Not a fixed delay: a sandbox takes seconds to start before its
|
|
208
|
+
* first model call, so a timer from launch would release the siblings
|
|
209
|
+
* before there was anything to read. Keyed by agent and model, since a
|
|
210
|
+
* cache is per model.
|
|
211
|
+
*/
|
|
212
|
+
export function warmGate(launching, pollMs = 250) {
|
|
213
|
+
const key = (s) => `${s.agent}\u0000${s.model ?? ""}`;
|
|
214
|
+
const counts = new Map();
|
|
215
|
+
for (const s of launching)
|
|
216
|
+
counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
|
|
217
|
+
const leads = new Map();
|
|
218
|
+
const isLead = new WeakSet();
|
|
219
|
+
return {
|
|
220
|
+
/** Claim the step's place — synchronously, before anything awaits, or
|
|
221
|
+
* the copies that reach their first await together would all find no
|
|
222
|
+
* lead and all go (a `parallel:` pool's acquire was exactly that).
|
|
223
|
+
* The first of its agent leads and gets null; a later one gets the
|
|
224
|
+
* lead's gate to await before it starts; a lone step gets null too. */
|
|
225
|
+
claim(step) {
|
|
226
|
+
const k = key(step);
|
|
227
|
+
if ((counts.get(k) ?? 0) < 2)
|
|
228
|
+
return null;
|
|
229
|
+
const lead = leads.get(k);
|
|
230
|
+
if (lead)
|
|
231
|
+
return lead.opened;
|
|
232
|
+
let open;
|
|
233
|
+
const opened = new Promise((r) => (open = r));
|
|
234
|
+
leads.set(k, { opened, open });
|
|
235
|
+
isLead.add(step);
|
|
236
|
+
return null;
|
|
237
|
+
},
|
|
238
|
+
/** A lead that will not run after all (the run was stopped while it
|
|
239
|
+
* waited for a pool slot) still opens its gate — or its siblings,
|
|
240
|
+
* which also wake to the stop, would wait on it forever. */
|
|
241
|
+
release(step) {
|
|
242
|
+
if (isLead.has(step))
|
|
243
|
+
leads.get(key(step)).open();
|
|
244
|
+
},
|
|
245
|
+
/** Run the step; a lead opens its agent's gate on its first reply (or
|
|
246
|
+
* its end), whatever else happens to it. */
|
|
247
|
+
async run(step, launch) {
|
|
248
|
+
if (!isLead.has(step))
|
|
249
|
+
return launch();
|
|
250
|
+
const { open } = leads.get(key(step));
|
|
251
|
+
const done = launch();
|
|
252
|
+
try {
|
|
253
|
+
await untilFirstReply(step, done, pollMs);
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
open();
|
|
257
|
+
}
|
|
258
|
+
await done;
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
158
262
|
const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" && v.trim() ? [v.trim()] : [];
|
|
159
263
|
// gray-matter throws on invalid YAML. Skills authored for another client
|
|
160
264
|
// sometimes ship YAML that that client's parser happened to accept — most
|
|
@@ -162,31 +266,10 @@ const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" &
|
|
|
162
266
|
// The Agent Skills client guide recommends quoting such values and retrying
|
|
163
267
|
// rather than dropping the skill, so a cross-client skill still loads here.
|
|
164
268
|
function safeMatter(raw) {
|
|
165
|
-
// gray-matter caches
|
|
166
|
-
// call
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
// once — otherwise the same skill loads on one run and vanishes on the next.
|
|
170
|
-
matter.clearCache();
|
|
171
|
-
try {
|
|
172
|
-
return matter(raw);
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
176
|
-
if (fm) {
|
|
177
|
-
const fixed = fm[1].replace(/^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm, (_m, k, v) => `${k}: ${JSON.stringify(v)}`);
|
|
178
|
-
try {
|
|
179
|
-
// A function replacer, not a string: a fixed block containing `$&` or
|
|
180
|
-
// `$$` (a value with those in it) would otherwise be treated as a
|
|
181
|
-
// replacement pattern and splice the whole frontmatter into itself.
|
|
182
|
-
return matter(raw.replace(fm[1], () => fixed));
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
/* fall through */
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return { data: {} };
|
|
189
|
-
}
|
|
269
|
+
// gray-matter caches a *failed* parse as a hollow success; ./matter.ts
|
|
270
|
+
// parses every call, so the strict read throws every time and the lenient
|
|
271
|
+
// one (shared with check, which warns about it) fires every time.
|
|
272
|
+
return { data: parseLenient(raw).data };
|
|
190
273
|
}
|
|
191
274
|
// Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
|
|
192
275
|
// <root>/<name>.md convenience form. `subdir` is the skills root under the
|
|
@@ -285,7 +368,7 @@ export function readAgentsMd(dir) {
|
|
|
285
368
|
if (!fs.existsSync(file))
|
|
286
369
|
continue;
|
|
287
370
|
try {
|
|
288
|
-
const parsed =
|
|
371
|
+
const parsed = configMatter(fs.readFileSync(file, "utf8"));
|
|
289
372
|
return { data: parsed.data, body: withoutAgentRules(parsed.content).trim() };
|
|
290
373
|
}
|
|
291
374
|
catch {
|
|
@@ -351,6 +434,9 @@ export function workspaceRuntimePlan(tenant, workspace) {
|
|
|
351
434
|
}
|
|
352
435
|
return [...byFp.values()];
|
|
353
436
|
}
|
|
437
|
+
/** The most tool content one step records (FOL-54 step 4): about 60 calls
|
|
438
|
+
* at the per-part cap; past it, metadata only. */
|
|
439
|
+
const STEP_CAPTURE_LIMIT = 4_000_000;
|
|
354
440
|
function workspaceFrontmatter(agentDir, tenant) {
|
|
355
441
|
return {
|
|
356
442
|
...(readAgentsMd(accountDir(tenant))?.data ?? {}),
|
|
@@ -650,8 +736,12 @@ grantsOnly = false) {
|
|
|
650
736
|
// agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
|
|
651
737
|
// workspace a secret should resolve against is two levels up.
|
|
652
738
|
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
653
|
-
const { data: front, content: body } =
|
|
654
|
-
|
|
739
|
+
const { data: front, content: body } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
|
|
740
|
+
// The prompt's sections, by name and by how often they change. Added in
|
|
741
|
+
// the order they are computed; assembled stable first, so the model's
|
|
742
|
+
// cache keeps the agent's half across steps and runs (prompt-layers.ts).
|
|
743
|
+
const parts = new PromptLayers();
|
|
744
|
+
parts.add("persona", resolveDocLinks(body.trim(), path.resolve(agentDir, "..", "..")));
|
|
655
745
|
// Where the agent is standing. Every path below this line is relative to the
|
|
656
746
|
// agent's own directory, and a run showed why that has to be said rather
|
|
657
747
|
// than implied: told to read `../../knowledge/index.md`, the model asked for
|
|
@@ -672,12 +762,12 @@ grantsOnly = false) {
|
|
|
672
762
|
const languageSet = language.from !== "default" && language.from !== "env";
|
|
673
763
|
const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
|
|
674
764
|
if (languageSet || regionSet) {
|
|
675
|
-
parts.
|
|
765
|
+
parts.add("locale", `# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
|
|
676
766
|
`Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
|
|
677
767
|
`use these currency, units and calendar conventions in anything you produce. ` +
|
|
678
768
|
`Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`);
|
|
679
769
|
}
|
|
680
|
-
parts.
|
|
770
|
+
parts.add("where", `# Where you are\n\n` +
|
|
681
771
|
`Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
|
|
682
772
|
`\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
|
|
683
773
|
`\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
|
|
@@ -720,7 +810,7 @@ grantsOnly = false) {
|
|
|
720
810
|
// background the rest of the prompt is read against, not an afterthought.
|
|
721
811
|
const sharedContext = sharedInstructions(agentDir, tenant);
|
|
722
812
|
if (sharedContext)
|
|
723
|
-
parts.
|
|
813
|
+
parts.add("agents.md", sharedContext);
|
|
724
814
|
// Skills follow the open Agent Skills format (agentskills.io): a folder
|
|
725
815
|
// with SKILL.md, optionally bundling its own scripts/ and references/. The
|
|
726
816
|
// flat `skills/<name>.md` form is also accepted for small skills.
|
|
@@ -754,7 +844,7 @@ grantsOnly = false) {
|
|
|
754
844
|
`Full instructions: \`${s.path}\`` +
|
|
755
845
|
(bundles.length ? ` (also bundles ${bundles.join(", ")})` : ""));
|
|
756
846
|
});
|
|
757
|
-
parts.
|
|
847
|
+
parts.add("skills", `# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
|
|
758
848
|
`read its file for the full instructions before proceeding:\n\n${lines.join("\n")}` +
|
|
759
849
|
(withheld ? `\n\n(${withheld} further skills exist but do not apply to this run.)` : ""));
|
|
760
850
|
}
|
|
@@ -773,10 +863,11 @@ grantsOnly = false) {
|
|
|
773
863
|
: "";
|
|
774
864
|
const projectMem = workspaceMemoryIndex(tenant, workspace);
|
|
775
865
|
const shared = libraryMemoryIndex(tenant);
|
|
776
|
-
|
|
866
|
+
// Dynamic: agents write memory, so the index moves between runs.
|
|
867
|
+
parts.add("memory", `# Memory — what you have learned\n\n${memoryIndex ? `Your own (files under memory/):\n\n${memoryIndex}` : "You have no memories of your own yet."}` +
|
|
777
868
|
(projectMem ? `\n\nShared across this workspace:\n\n${projectMem}` : "") +
|
|
778
869
|
(shared ? `\n\nShared across every workspace:\n\n${shared}` : "") +
|
|
779
|
-
howTo);
|
|
870
|
+
howTo, { dynamic: true });
|
|
780
871
|
}
|
|
781
872
|
// Knowledge — reference material the agent was given. Same three scopes and
|
|
782
873
|
// the same nearest-wins reading order as memory, but never written back:
|
|
@@ -791,7 +882,7 @@ grantsOnly = false) {
|
|
|
791
882
|
projectKnowledge && `Shared across this workspace:\n\n${projectKnowledge}`,
|
|
792
883
|
sharedKnowledge && `Shared across every workspace:\n\n${sharedKnowledge}`,
|
|
793
884
|
].filter(Boolean);
|
|
794
|
-
parts.
|
|
885
|
+
parts.add("knowledge", `# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
|
|
795
886
|
`on it rather than guessing at its contents. This is read-only: it is maintained by ` +
|
|
796
887
|
`people, not by you — if you learn something new, write it to memory/ instead.\n\n` +
|
|
797
888
|
sections.join("\n\n"));
|
|
@@ -822,7 +913,7 @@ grantsOnly = false) {
|
|
|
822
913
|
shared.length ? `Shared across this workspace:\n${shared.join("\n")}` : null,
|
|
823
914
|
global.length ? `From the account library (read-only):\n${global.join("\n")}` : null,
|
|
824
915
|
].filter(Boolean);
|
|
825
|
-
parts.
|
|
916
|
+
parts.add("scripts", `# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
|
|
826
917
|
`Read a script before running it if you are unsure what it does. Credentials your ` +
|
|
827
918
|
`scripts need are already in the environment as the secrets listed below.`);
|
|
828
919
|
}
|
|
@@ -837,7 +928,8 @@ grantsOnly = false) {
|
|
|
837
928
|
// file to size it against, not before.
|
|
838
929
|
const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
|
|
839
930
|
if (state.length) {
|
|
840
|
-
|
|
931
|
+
// Dynamic: runs add and remove state files.
|
|
932
|
+
parts.add("state", `# State — what you carry between runs\n\n` +
|
|
841
933
|
`What this workspace keeps across runs: a count, a cursor, where you got to. ` +
|
|
842
934
|
`Read and write these directly, and update them before you finish so the next ` +
|
|
843
935
|
`run starts where you left off.\n\n${state.join("\n")}\n\n` +
|
|
@@ -845,7 +937,7 @@ grantsOnly = false) {
|
|
|
845
937
|
`A count reads as well in a sentence as in JSON, and a person edits these in the ` +
|
|
846
938
|
`same editor as everything else — data formats are allowed here, not preferred.\n\n` +
|
|
847
939
|
`This is not memory. A fact you learned goes in memory/, where it is indexed and ` +
|
|
848
|
-
`its provenance recorded — state is the bookmark, not the lesson
|
|
940
|
+
`its provenance recorded — state is the bookmark, not the lesson.`, { dynamic: true });
|
|
849
941
|
}
|
|
850
942
|
// APIs — the agent's own definitions, plus workspace tools it granted by
|
|
851
943
|
// name. Library tools are defined once and reused.
|
|
@@ -928,7 +1020,7 @@ grantsOnly = false) {
|
|
|
928
1020
|
const keepIn = { dir: agentDir, readable: () => readsFiles };
|
|
929
1021
|
const apiTools = buildApiTools(tenant, apis, workspace, undefined, keepIn);
|
|
930
1022
|
if (apiTools.promptLines.length) {
|
|
931
|
-
parts.
|
|
1023
|
+
parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
|
|
932
1024
|
}
|
|
933
1025
|
// Secrets an agent declared become env vars for its scripts and bash.
|
|
934
1026
|
//
|
|
@@ -1079,6 +1171,10 @@ grantsOnly = false) {
|
|
|
1079
1171
|
name: spec.name,
|
|
1080
1172
|
maxTokensParam: preset?.maxTokensParam,
|
|
1081
1173
|
reasoningEffort: preset?.reasoningEffort,
|
|
1174
|
+
// The agent's prompts share an opening; the key says so to an
|
|
1175
|
+
// endpoint that routes its cache by one. Only where the preset
|
|
1176
|
+
// says the parameter is understood — elsewhere it is a 400.
|
|
1177
|
+
cacheKey: preset?.promptCacheKey ? promptCacheKey(tenant, workspace, path.basename(agentDir)) : undefined,
|
|
1082
1178
|
});
|
|
1083
1179
|
// What the run is actually pointed at, in one line: the endpoint, the
|
|
1084
1180
|
// tiers this gateway renames, and whether the credential landed.
|
|
@@ -1261,10 +1357,12 @@ grantsOnly = false) {
|
|
|
1261
1357
|
FOLDRUN_WORKSPACE: workspace,
|
|
1262
1358
|
...clockEnv,
|
|
1263
1359
|
}, libraryDir(tenant, "scripts"), runtime.interpreters, exec, keepIn.readable);
|
|
1264
|
-
|
|
1265
|
-
|
|
1360
|
+
// Dynamic: the date moves daily, and would otherwise invalidate every
|
|
1361
|
+
// layer behind it once a day.
|
|
1362
|
+
parts.add("clock", `# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
|
|
1363
|
+
`write or check — it is the workspace's calendar, not the container's clock.`, { dynamic: true });
|
|
1266
1364
|
if (scriptTools.promptLines.length) {
|
|
1267
|
-
parts.
|
|
1365
|
+
parts.add("script-tools", `# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
|
|
1268
1366
|
`\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`);
|
|
1269
1367
|
}
|
|
1270
1368
|
// The web tool's own description lists every action, so an agent whose
|
|
@@ -1273,15 +1371,15 @@ grantsOnly = false) {
|
|
|
1273
1371
|
// reddit-desk validator step on 6 Oct 2026. Say it before the first call.
|
|
1274
1372
|
if (builtins.length) {
|
|
1275
1373
|
const named = builtins.map((b) => b.split("="));
|
|
1276
|
-
parts.
|
|
1374
|
+
parts.add("web", `# Web search and fetch on this run\n\n` +
|
|
1277
1375
|
named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
|
|
1278
1376
|
`\n\nThe web tool's other actions work as usual.`);
|
|
1279
1377
|
}
|
|
1280
1378
|
if (declared.length) {
|
|
1281
|
-
parts.
|
|
1379
|
+
parts.add("secrets", `# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
|
|
1282
1380
|
`Never print, echo, or write them to a file.`);
|
|
1283
1381
|
}
|
|
1284
|
-
parts.
|
|
1382
|
+
parts.add("rules", "Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
|
|
1285
1383
|
"what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
|
|
1286
1384
|
"for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
|
|
1287
1385
|
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool. " +
|
|
@@ -1298,7 +1396,7 @@ grantsOnly = false) {
|
|
|
1298
1396
|
// run, and an agent that opened with "I'll start by cloning the repo" made
|
|
1299
1397
|
// all three useless. Asking here rather than in each agent.md means every
|
|
1300
1398
|
// agent obeys it, including the ones written before the summary existed.
|
|
1301
|
-
parts.
|
|
1399
|
+
parts.add("rules", "Open your reply with one sentence saying what you concluded or produced — the outcome, " +
|
|
1302
1400
|
"not your plan. Everything else follows it. That sentence is what a person sees in the " +
|
|
1303
1401
|
"run list and in the notification, without opening the run.");
|
|
1304
1402
|
// One list. `tools:` grants anything: a built-in group, an exact SDK tool
|
|
@@ -1340,6 +1438,7 @@ grantsOnly = false) {
|
|
|
1340
1438
|
let wantHistory = false;
|
|
1341
1439
|
let wantDesks = false;
|
|
1342
1440
|
let wantAsk = false;
|
|
1441
|
+
let wantPeers = false;
|
|
1343
1442
|
for (const ref of toolRefs(front)) {
|
|
1344
1443
|
const toolName = ref.name;
|
|
1345
1444
|
if (ref.mode === "ask") {
|
|
@@ -1374,7 +1473,7 @@ grantsOnly = false) {
|
|
|
1374
1473
|
if (available[toolName])
|
|
1375
1474
|
shadowed.push(toolName);
|
|
1376
1475
|
}
|
|
1377
|
-
else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
|
|
1476
|
+
else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask" || toolName === "peers") {
|
|
1378
1477
|
// The platform's own groups, served in-process — built below, once
|
|
1379
1478
|
// the roots and the records are known.
|
|
1380
1479
|
if (toolName === "search")
|
|
@@ -1383,6 +1482,8 @@ grantsOnly = false) {
|
|
|
1383
1482
|
wantHistory = true;
|
|
1384
1483
|
else if (toolName === "ask")
|
|
1385
1484
|
wantAsk = true;
|
|
1485
|
+
else if (toolName === "peers")
|
|
1486
|
+
wantPeers = true;
|
|
1386
1487
|
else
|
|
1387
1488
|
wantDesks = true;
|
|
1388
1489
|
if (available[toolName])
|
|
@@ -1444,10 +1545,17 @@ grantsOnly = false) {
|
|
|
1444
1545
|
const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
|
|
1445
1546
|
if (ask) {
|
|
1446
1547
|
allowed.push(ASK_TOOL);
|
|
1447
|
-
parts.
|
|
1548
|
+
parts.add("ask", `# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
|
|
1448
1549
|
}
|
|
1550
|
+
// tools: [peers] — peers_list and peers_message, to the other steps of
|
|
1551
|
+
// this step's parallel group. Who they are is known only when the group
|
|
1552
|
+
// launches, so the grant is here and the roster, the server and the
|
|
1553
|
+
// prompt's line are the step's (runStep).
|
|
1554
|
+
const peers = wantPeers;
|
|
1555
|
+
if (peers)
|
|
1556
|
+
allowed.push(...PEER_TOOLS);
|
|
1449
1557
|
if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
|
|
1450
|
-
parts.
|
|
1558
|
+
parts.add("finding", `# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
|
|
1451
1559
|
}
|
|
1452
1560
|
// An MCP server declares its tools when it connects, so they can't be listed
|
|
1453
1561
|
// ahead of time. Server-level specs cover them: granting the server grants
|
|
@@ -1469,13 +1577,16 @@ grantsOnly = false) {
|
|
|
1469
1577
|
denied.push(INSTALL_TOOL);
|
|
1470
1578
|
const finalAllowed = allowed.filter((t) => !denied.includes(t));
|
|
1471
1579
|
readsFiles = canReadBack(finalAllowed);
|
|
1580
|
+
const prompt = parts.assemble();
|
|
1472
1581
|
return {
|
|
1473
1582
|
front,
|
|
1474
1583
|
clockEnv,
|
|
1475
1584
|
/** The zone, where it came from, and anything unreadable on the way —
|
|
1476
1585
|
* reported by the step, not here, so it lands in the run's trace. */
|
|
1477
1586
|
clock,
|
|
1478
|
-
systemPrompt:
|
|
1587
|
+
systemPrompt: prompt.text,
|
|
1588
|
+
/** What the record keeps of each layer of that prompt. */
|
|
1589
|
+
promptLayers: prompt.layers,
|
|
1479
1590
|
allowed: finalAllowed,
|
|
1480
1591
|
disabled,
|
|
1481
1592
|
unknownTools,
|
|
@@ -1499,6 +1610,7 @@ grantsOnly = false) {
|
|
|
1499
1610
|
historyTools,
|
|
1500
1611
|
deskTools,
|
|
1501
1612
|
ask,
|
|
1613
|
+
peers,
|
|
1502
1614
|
// `spec` is the MERGED declaration — the agent's own block or its
|
|
1503
1615
|
// workspace's, plus every granted tool's. The isolated path builds its
|
|
1504
1616
|
// runtime from this; passing the agent's frontmatter alone silently drops
|
|
@@ -1730,7 +1842,13 @@ flowTimezone,
|
|
|
1730
1842
|
/** The step's index in run.steps, carried on its egress lease so a
|
|
1731
1843
|
* person's message to this step reaches this step's own inbox and not a
|
|
1732
1844
|
* sibling's running in the same group. Undefined outside a flow. */
|
|
1733
|
-
stepIndex
|
|
1845
|
+
stepIndex,
|
|
1846
|
+
/** The other steps launched with this one (its parallel group), for
|
|
1847
|
+
* tools: [peers]. Empty when it runs alone. */
|
|
1848
|
+
siblings = [],
|
|
1849
|
+
/** The in-process colleagues' bus for this group (localPeerBus), when
|
|
1850
|
+
* the step runs in this process rather than a pod. */
|
|
1851
|
+
peerBus) {
|
|
1734
1852
|
// Secret values are injected into scripts as environment variables and
|
|
1735
1853
|
// substituted into API headers, so a model that reads one back — from a
|
|
1736
1854
|
// script that prints it, an API error that echoes it, or its own `env` —
|
|
@@ -1776,9 +1894,65 @@ stepIndex) {
|
|
|
1776
1894
|
return value;
|
|
1777
1895
|
}
|
|
1778
1896
|
};
|
|
1897
|
+
// What this workspace records of each step (FOL-54 step 4): `capture:` in
|
|
1898
|
+
// AGENTS.md — every model call always; each tool call's input and output
|
|
1899
|
+
// unless it says `metadata`.
|
|
1900
|
+
const capture = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
|
|
1901
|
+
// `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
|
|
1902
|
+
// grades with — field by field, the workspace's else the account's.
|
|
1903
|
+
const { judgeModel, judgeEffort } = (() => {
|
|
1904
|
+
const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
|
|
1905
|
+
const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
|
|
1906
|
+
const model = ws.model ?? account.model;
|
|
1907
|
+
const effort = resolveEffort(ws.effort ?? account.effort ?? "");
|
|
1908
|
+
return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
|
|
1909
|
+
})();
|
|
1910
|
+
// A step that pours out content stops being recorded past this — its
|
|
1911
|
+
// calls keep their names, sizes and times (FOL-54 step 4).
|
|
1912
|
+
let capturedChars = 0;
|
|
1913
|
+
let captureFull = false;
|
|
1779
1914
|
const push = (type, text, extra) => {
|
|
1780
1915
|
const t = new Date().toISOString();
|
|
1781
|
-
|
|
1916
|
+
// The model has begun its first response: held only in memory, for
|
|
1917
|
+
// the fan-out gate (warmGate). Not a line in the trace.
|
|
1918
|
+
if (extra?.responding) {
|
|
1919
|
+
markResponding(step);
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
// A model call is a span on the step, not a line in its trace.
|
|
1923
|
+
// Numbered here, across attempts: a retry's calls follow the first
|
|
1924
|
+
// attempt's, and an id-less turn of one attempt never overwrites
|
|
1925
|
+
// another's. No save of its own — a turn's blocks arrive as several
|
|
1926
|
+
// messages, and the next event saves them all.
|
|
1927
|
+
if (extra?.turn) {
|
|
1928
|
+
const calls = (step.calls ??= []);
|
|
1929
|
+
// The loop counts attempts from 1; a call of the first carries none.
|
|
1930
|
+
const attempt = Math.max(1, step.attempts ?? 1);
|
|
1931
|
+
const at = calls.findIndex((c) => c.id === extra.turn.id && (c.attempt ?? 1) === attempt);
|
|
1932
|
+
const call = { ...extra.turn, n: at >= 0 ? calls[at].n : calls.length + 1, ...(attempt > 1 ? { attempt } : {}) };
|
|
1933
|
+
if (at >= 0)
|
|
1934
|
+
calls[at] = call;
|
|
1935
|
+
else
|
|
1936
|
+
calls.push(call);
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
// A tool call's content goes beside the run, masked (capture.ts), and the
|
|
1940
|
+
// event keeps only that it was recorded.
|
|
1941
|
+
let rest = extra;
|
|
1942
|
+
if (extra?.io) {
|
|
1943
|
+
const { io, ...others } = extra;
|
|
1944
|
+
rest = others;
|
|
1945
|
+
const size = (io.input?.length ?? 0) + (io.output?.length ?? 0);
|
|
1946
|
+
if (runId && extra.call && capture === "full" && capturedChars + size > STEP_CAPTURE_LIMIT && !captureFull) {
|
|
1947
|
+
captureFull = true;
|
|
1948
|
+
step.events.push({ t, type: "info", text: `capture: this step recorded ${Math.round(capturedChars / 1000)}k characters of tool content — the rest of its calls keep only their names, sizes and times` });
|
|
1949
|
+
}
|
|
1950
|
+
if (runId && extra.call && capture === "full" && !captureFull && recordCallIo(path.resolve(agentDir, "..", ".."), runId, extra.call, text, io, redact)) {
|
|
1951
|
+
capturedChars += size;
|
|
1952
|
+
rest = { ...others, captured: true };
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
step.events.push({ t, type, text: redact(text), ...rest });
|
|
1782
1956
|
// A person in the loop: the question, its answer, a message delivered —
|
|
1783
1957
|
// onto the step, so the run page, the API and history show them.
|
|
1784
1958
|
if (extra?.operator) {
|
|
@@ -1810,7 +1984,27 @@ stepIndex) {
|
|
|
1810
1984
|
step.status = "completed";
|
|
1811
1985
|
// A stub answer may open with `cost: 0.25` — a pretend price, so the
|
|
1812
1986
|
// parts of orchestration that watch money (budget:) are testable too.
|
|
1813
|
-
|
|
1987
|
+
let answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
|
|
1988
|
+
// `call: <tool> <input> => <output>` lines: a pretend tool call, sent
|
|
1989
|
+
// through the same push a real step's events take — with its model call
|
|
1990
|
+
// and its content — so what the runner records of a step is testable.
|
|
1991
|
+
answer = answer
|
|
1992
|
+
.split("\n")
|
|
1993
|
+
.filter((line) => {
|
|
1994
|
+
const m = /^call: (\S+) (.*?) => (.*)$/.exec(line);
|
|
1995
|
+
if (!m)
|
|
1996
|
+
return true;
|
|
1997
|
+
const id = `toolu_stub${(step.calls?.length ?? 0) + 1}`;
|
|
1998
|
+
// Twice under one id, as the SDK sends a turn's blocks: one row.
|
|
1999
|
+
const turn = { id: `msg_stub${(step.calls?.length ?? 0) + 1}`, n: 0, at: new Date().toISOString(), ms: 5, model: "stub", tokens: { input: 100, output: 10, cacheRead: 80, cacheWrite: 0 }, tools: 0 };
|
|
2000
|
+
push("info", "", { turn });
|
|
2001
|
+
push("info", "", { turn: { ...turn, stop: "tool_use", tools: 1 } });
|
|
2002
|
+
push("tool", m[1], { call: id, ...(capture ? { io: { input: m[2], inputChars: m[2].length } } : {}) });
|
|
2003
|
+
push("tool", m[1], { call: id, ms: 3, io: { output: m[3], outputChars: m[3].length } });
|
|
2004
|
+
return false;
|
|
2005
|
+
})
|
|
2006
|
+
.join("\n")
|
|
2007
|
+
.trim();
|
|
1814
2008
|
const priced = answer.match(/^cost:\s*([\d.]+)\n?/);
|
|
1815
2009
|
const body = (priced ? answer.slice(priced[0].length) : answer).trim();
|
|
1816
2010
|
// A `===` line inside an answer separates earlier turns from the final
|
|
@@ -1849,12 +2043,15 @@ stepIndex) {
|
|
|
1849
2043
|
// step: the generic HTTP tool still works without the typed ones.
|
|
1850
2044
|
for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
|
|
1851
2045
|
push("error", `openapi: ${w}`);
|
|
1852
|
-
const { front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
2046
|
+
const { front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, peers, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
1853
2047
|
disposeRuntime = runtime.dispose;
|
|
1854
2048
|
canActOutward = outward;
|
|
1855
2049
|
// Sent again on every turn; with turns and the tools' result sizes, what
|
|
1856
2050
|
// a token report reads to say where a step's input went.
|
|
1857
2051
|
step.systemPromptChars = systemPrompt.length;
|
|
2052
|
+
// And each layer's size and hash, so a later reader can say which part
|
|
2053
|
+
// of the prompt changed between this step and the agent's last.
|
|
2054
|
+
step.promptLayers = promptLayers;
|
|
1858
2055
|
// A retry that moved the step up a class (an evicted or OOM-killed
|
|
1859
2056
|
// attempt) wins over the agent's own `size:` for the attempts after.
|
|
1860
2057
|
const size = step.sizeUp ?? agentSize;
|
|
@@ -1872,11 +2069,7 @@ stepIndex) {
|
|
|
1872
2069
|
// Populate before the first push: everything after this point may quote a
|
|
1873
2070
|
// credential. Short values are skipped — a two-character secret would
|
|
1874
2071
|
// redact half the English language, and anything that short is not one.
|
|
1875
|
-
redactions =
|
|
1876
|
-
.filter(([, value]) => typeof value === "string" && value.length >= 8)
|
|
1877
|
-
.map(([name, value]) => [value, name])
|
|
1878
|
-
// Longest first, so a value containing another is replaced whole.
|
|
1879
|
-
.sort((a, b) => b[0].length - a[0].length);
|
|
2072
|
+
redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
|
|
1880
2073
|
// One line for the clock: which zone this step works to, and which level
|
|
1881
2074
|
// set it. "Why is this dated yesterday" should be answerable from the run.
|
|
1882
2075
|
push("info", `clock: ${clock.timezone} (${clock.source}) — today is ${clockEnv.FOLDRUN_DATE}`);
|
|
@@ -2115,7 +2308,14 @@ stepIndex) {
|
|
|
2115
2308
|
...mcpNames,
|
|
2116
2309
|
...(consults.length ? ["foldrun_agents"] : []),
|
|
2117
2310
|
...(ask ? ["foldrun_ask"] : []),
|
|
2311
|
+
...(peers ? ["foldrun_peers"] : []),
|
|
2118
2312
|
];
|
|
2313
|
+
if (peers) {
|
|
2314
|
+
// The colleagues' contract, in the prompt the step reads: who runs
|
|
2315
|
+
// beside it this time (none, when it runs alone), and that a message
|
|
2316
|
+
// is a request, never a hand-over.
|
|
2317
|
+
prompt += `\n\n<colleagues>\n${peersPromptLine(siblings)}\n</colleagues>`;
|
|
2318
|
+
}
|
|
2119
2319
|
// Colleagues this agent may DELEGATE to: each with its own context and
|
|
2120
2320
|
// its own tools — resolved exactly as its own step's would be, then cut
|
|
2121
2321
|
// to what THIS step holds (subagents.ts). Built host-side, like consults.
|
|
@@ -2286,7 +2486,21 @@ stepIndex) {
|
|
|
2286
2486
|
],
|
|
2287
2487
|
});
|
|
2288
2488
|
grant.network = { level: network.level, hosts: network.hosts };
|
|
2289
|
-
|
|
2489
|
+
// network_enforce: true at the account or the workspace asks the proxy
|
|
2490
|
+
// to refuse; the proxy adds the install's own switch and has the last
|
|
2491
|
+
// word (egress-proxy.ts), so false here never loosens anything.
|
|
2492
|
+
grant.enforce = networkEnforceWanted(readAgentsMd(accountDir(tenant))?.data?.network_enforce, readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce);
|
|
2493
|
+
const lease = await platform.egress.lease({
|
|
2494
|
+
tenant,
|
|
2495
|
+
runId: runId ?? "adhoc",
|
|
2496
|
+
grant,
|
|
2497
|
+
test: testRun,
|
|
2498
|
+
...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
|
|
2499
|
+
// tools: [peers] — the roster the proxy serves, and the inboxes a
|
|
2500
|
+
// colleague's message may be addressed to. Only when granted: a
|
|
2501
|
+
// step without the tools has no reason to know its siblings.
|
|
2502
|
+
...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
|
|
2503
|
+
});
|
|
2290
2504
|
// The forward door: every program in the sandbox sends through the
|
|
2291
2505
|
// proxy, which holds the site list and fills bound secrets. A proxy
|
|
2292
2506
|
// without the door (an older platform) leaves programs going direct.
|
|
@@ -2357,6 +2571,18 @@ stepIndex) {
|
|
|
2357
2571
|
// carrying TEST_MODE_WITHHELD would only fail where it should have
|
|
2358
2572
|
// been sunk or refused.
|
|
2359
2573
|
const apiValues = testRun && lease ? placeholders : sandboxSecrets;
|
|
2574
|
+
// A declared secret named like the step's own model variables
|
|
2575
|
+
// (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, …) was spread over them below
|
|
2576
|
+
// and broke, or redirected, the step's model calls (FOL-19). It crosses
|
|
2577
|
+
// as FOLDRUN_DECLARED_<NAME> instead: the model keeps its settings, and
|
|
2578
|
+
// the driver gives the agent's API and script tools the value under its
|
|
2579
|
+
// own name (run-container.ts) — never the model's key in its place.
|
|
2580
|
+
const modelNamed = Object.keys(sandboxSecrets).filter(isModelEnvName);
|
|
2581
|
+
if (modelNamed.length) {
|
|
2582
|
+
push("info", `secrets: ${modelNamed.join(", ")} kept away from the model's own settings — ${modelNamed.length === 1 ? "that name is" : "those names are"} what the step's model call uses; ` +
|
|
2583
|
+
"its script and API tools and verify: get the declared value, its own shell the model's. Give the secret another name, or run the model on it with provider:");
|
|
2584
|
+
}
|
|
2585
|
+
const sandboxEnvSecrets = Object.fromEntries(Object.entries(sandboxSecrets).map(([name, value]) => [isModelEnvName(name) ? `${DECLARED_ENV_PREFIX}${name}` : name, value]));
|
|
2360
2586
|
const substitutedApis = apiSpecs.map((api) => ({
|
|
2361
2587
|
...api,
|
|
2362
2588
|
headers: Object.fromEntries(Object.entries(api.headers).map(([k, v]) => [
|
|
@@ -2410,15 +2636,19 @@ stepIndex) {
|
|
|
2410
2636
|
// field. A tool's own `runtime:` is part of what this step needs.
|
|
2411
2637
|
runtime: runtime.spec,
|
|
2412
2638
|
consults,
|
|
2639
|
+
...(peers ? { peers: { self: step.agent, siblings } } : {}),
|
|
2413
2640
|
...(subagents.length ? { subagents } : {}),
|
|
2414
2641
|
timeoutSec: step.timeout,
|
|
2415
2642
|
budgetUsd: stepBudgetUsd,
|
|
2416
2643
|
budgetNote: stepBudgetNote,
|
|
2417
2644
|
price: tokenPrice,
|
|
2418
2645
|
verify: step.verify,
|
|
2646
|
+
...(judgeModel ? { judgeModel } : {}),
|
|
2647
|
+
...(judgeEffort ? { judgeEffort } : {}),
|
|
2419
2648
|
output: step.output,
|
|
2420
2649
|
schema: resolvedSchema ?? undefined,
|
|
2421
2650
|
maxTurns: step.maxTurns,
|
|
2651
|
+
capture,
|
|
2422
2652
|
...limitOpts,
|
|
2423
2653
|
...hookOpts,
|
|
2424
2654
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
@@ -2464,11 +2694,14 @@ stepIndex) {
|
|
|
2464
2694
|
FOLDRUN_AGENT: step.agent,
|
|
2465
2695
|
FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
|
|
2466
2696
|
...(testRun ? testModeEnv() : {}),
|
|
2467
|
-
...
|
|
2697
|
+
...sandboxEnvSecrets,
|
|
2468
2698
|
}).filter((entry) => typeof entry[1] === "string")),
|
|
2469
2699
|
emit: pushWatching,
|
|
2470
2700
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
2471
2701
|
runId,
|
|
2702
|
+
// A step still waiting for room has no sandbox to destroy; the
|
|
2703
|
+
// executor asks this before it creates one (pod-room.ts).
|
|
2704
|
+
...(stopRequested ? { stopRequested } : {}),
|
|
2472
2705
|
size,
|
|
2473
2706
|
// The pod's network, for the executor's own wall (the k8s tier's
|
|
2474
2707
|
// network policy): a filtered pod reaches the proxy and DNS only.
|
|
@@ -2742,6 +2975,13 @@ stepIndex) {
|
|
|
2742
2975
|
// On this machine nobody can write in and there is no proxy: the
|
|
2743
2976
|
// question goes to the terminal when there is one, else unanswered.
|
|
2744
2977
|
const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
|
|
2978
|
+
// Colleagues in the same group reach each other over the run's own
|
|
2979
|
+
// bus here — no proxy, so no person can write in, but a sibling can.
|
|
2980
|
+
// Every grouped step gets the inbox (a pod reads the proxy's inbox
|
|
2981
|
+
// whatever its tools); only tools: [peers] gets the tools to send.
|
|
2982
|
+
const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
|
|
2983
|
+
const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
|
|
2984
|
+
const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
|
|
2745
2985
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
2746
2986
|
// finally — once, however many attempts follow.
|
|
2747
2987
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
@@ -2765,6 +3005,9 @@ stepIndex) {
|
|
|
2765
3005
|
workspaceRoot,
|
|
2766
3006
|
libraryRoot: libraryDir(tenant),
|
|
2767
3007
|
...(subagents.length ? { subagents } : {}),
|
|
3008
|
+
// The colleagues' inbox, read after every tool call — the same hook
|
|
3009
|
+
// a pod uses for a person's messages.
|
|
3010
|
+
...(localInbox ? { inbox: localInbox } : {}),
|
|
2768
3011
|
prompt,
|
|
2769
3012
|
model,
|
|
2770
3013
|
effort,
|
|
@@ -2779,6 +3022,7 @@ stepIndex) {
|
|
|
2779
3022
|
...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
|
|
2780
3023
|
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
2781
3024
|
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
3025
|
+
...(peerTools ? { foldrun_peers: peerTools.server } : {}),
|
|
2782
3026
|
...(installTools ? { foldrun_install: installTools.server } : {}),
|
|
2783
3027
|
...mcpServers,
|
|
2784
3028
|
},
|
|
@@ -2793,9 +3037,12 @@ stepIndex) {
|
|
|
2793
3037
|
budgetNote: stepBudgetNote,
|
|
2794
3038
|
price: tokenPrice,
|
|
2795
3039
|
verify: step.verify,
|
|
3040
|
+
...(judgeModel ? { judgeModel } : {}),
|
|
3041
|
+
...(judgeEffort ? { judgeEffort } : {}),
|
|
2796
3042
|
output: step.output,
|
|
2797
3043
|
schema: resolvedSchema ?? undefined,
|
|
2798
3044
|
maxTurns: step.maxTurns,
|
|
3045
|
+
capture,
|
|
2799
3046
|
...limitOpts,
|
|
2800
3047
|
...hookOpts,
|
|
2801
3048
|
stopRequested,
|
|
@@ -2999,7 +3246,7 @@ export function copyTreeBytes(from, to) {
|
|
|
2999
3246
|
function openApiSources(agentDir, tenant) {
|
|
3000
3247
|
const out = new Set();
|
|
3001
3248
|
try {
|
|
3002
|
-
const { data } =
|
|
3249
|
+
const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
|
|
3003
3250
|
const front = data;
|
|
3004
3251
|
for (const api of parseApis(front.apis))
|
|
3005
3252
|
if (api.openapi?.startsWith("https://"))
|
|
@@ -3065,7 +3312,7 @@ const CREDENTIAL_POLL_MS = 2_000;
|
|
|
3065
3312
|
* step started one second before the 09:07:52Z refresh) matched none of the
|
|
3066
3313
|
* older two, so the step failed instead of waiting for the new token. */
|
|
3067
3314
|
export function isAuthRefusal(text) {
|
|
3068
|
-
return
|
|
3315
|
+
return AUTH_REFUSAL.test(text);
|
|
3069
3316
|
}
|
|
3070
3317
|
/**
|
|
3071
3318
|
* Wait, briefly, for the mounted credential to become one we have not
|
|
@@ -3591,10 +3838,31 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3591
3838
|
*
|
|
3592
3839
|
* The flag is one-way: nothing here clears it, so the read-modify-write can
|
|
3593
3840
|
* only ever turn a stop on, never off.
|
|
3841
|
+
*
|
|
3842
|
+
* And once it is on, this copy may not undo what stopRun wrote: the run
|
|
3843
|
+
* stays failed, and steps that had not started stay skipped. Before
|
|
3844
|
+
* 21 Oct 2026 only the flag came across, so the next save put the record
|
|
3845
|
+
* back to `running` with its steps pending; a second stop then answered
|
|
3846
|
+
* "failed" instead of "already failed", and the group kept starting pods
|
|
3847
|
+
* (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
|
|
3848
|
+
* as it is: its sandbox is gone, it ends on its own, and records how.
|
|
3594
3849
|
*/
|
|
3595
3850
|
const save = () => {
|
|
3596
|
-
|
|
3851
|
+
const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
|
|
3852
|
+
if (disk?.stopRequested)
|
|
3597
3853
|
run.stopRequested = true;
|
|
3854
|
+
if (run.stopRequested) {
|
|
3855
|
+
if (run.status !== "completed" && run.status !== "failed") {
|
|
3856
|
+
run.status = "failed";
|
|
3857
|
+
run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
|
|
3858
|
+
}
|
|
3859
|
+
run.parkedAt = null;
|
|
3860
|
+
for (const s of run.steps) {
|
|
3861
|
+
if (s.status === "pending" || s.status === "awaiting-approval") {
|
|
3862
|
+
s.status = "skipped";
|
|
3863
|
+
s.skipReason ??= "run stopped";
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3598
3866
|
}
|
|
3599
3867
|
writeRun(tenant, workspace, run);
|
|
3600
3868
|
};
|
|
@@ -3649,6 +3917,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3649
3917
|
try {
|
|
3650
3918
|
// Entering the loop is what makes a run live, whatever state it was
|
|
3651
3919
|
// saved in: `queued` from the queue, `awaiting-approval` from a park.
|
|
3920
|
+
// The first pick-up (FOL-54 step 4): startedAt to this is the time it
|
|
3921
|
+
// queued; and the files it runs are the workspace's latest revision
|
|
3922
|
+
// as it starts — a deploy's revision is its commit, and an edit through
|
|
3923
|
+
// /source makes one too. A re-run comes through here as well.
|
|
3924
|
+
run.drivenAt ??= new Date().toISOString();
|
|
3925
|
+
if (run.revision === undefined)
|
|
3926
|
+
run.revision = latestRevisionId(tenant, workspace);
|
|
3652
3927
|
if (run.status !== "running") {
|
|
3653
3928
|
run.status = "running";
|
|
3654
3929
|
run.parkedAt = null;
|
|
@@ -3808,7 +4083,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3808
4083
|
return groupData[i];
|
|
3809
4084
|
return undefined;
|
|
3810
4085
|
};
|
|
3811
|
-
|
|
4086
|
+
// A rescue's result already stands on the step it took over, so the
|
|
4087
|
+
// three handoffs below read the flow's steps only: counting the rescue
|
|
4088
|
+
// too handed the next group the same output twice, or a stale one
|
|
4089
|
+
// from an evaluator loop's earlier cycle.
|
|
4090
|
+
const flowStepsOf = (group) => group.filter((s) => !isRescueRecord(s));
|
|
4091
|
+
const dataOf = (all) => {
|
|
4092
|
+
const group = flowStepsOf(all);
|
|
3812
4093
|
const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
|
|
3813
4094
|
if (carrying.length === 0)
|
|
3814
4095
|
return undefined;
|
|
@@ -3816,7 +4097,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3816
4097
|
return carrying[0].data;
|
|
3817
4098
|
return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
|
|
3818
4099
|
};
|
|
3819
|
-
const resultsOf = (
|
|
4100
|
+
const resultsOf = (all) => {
|
|
4101
|
+
const group = flowStepsOf(all);
|
|
3820
4102
|
const results = group
|
|
3821
4103
|
.filter((s) => s.status === "completed" && s.result)
|
|
3822
4104
|
.map((s) => s.item
|
|
@@ -3828,8 +4110,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3828
4110
|
};
|
|
3829
4111
|
// What each step concluded — its final turn — for the handoffs that
|
|
3830
4112
|
// should read a conclusion rather than a transcript (`each: lines`).
|
|
3831
|
-
const conclusionsOf = (
|
|
3832
|
-
const out =
|
|
4113
|
+
const conclusionsOf = (all) => {
|
|
4114
|
+
const out = flowStepsOf(all)
|
|
3833
4115
|
.filter((s) => s.status === "completed" && (s.conclusion || s.result))
|
|
3834
4116
|
.map((s) => s.conclusion || s.result);
|
|
3835
4117
|
return out.length ? out.join("\n") : null;
|
|
@@ -4204,7 +4486,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4204
4486
|
save();
|
|
4205
4487
|
if (freshGroup.every((s) => s.status !== "pending")) {
|
|
4206
4488
|
// everything in this group was rejected
|
|
4207
|
-
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
|
|
4489
|
+
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
|
|
4208
4490
|
run.status = "failed";
|
|
4209
4491
|
break;
|
|
4210
4492
|
}
|
|
@@ -4217,7 +4499,17 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4217
4499
|
// itself mid-turn at its share, so a fan-out of twenty cannot end
|
|
4218
4500
|
// twenty steps over — the shares sum to what was left. Between
|
|
4219
4501
|
// groups the check above still refuses to start the next one.
|
|
4220
|
-
|
|
4502
|
+
// A rescue record is the on-fail of a step in this group, not a step
|
|
4503
|
+
// of the flow: one left pending by a drive that died is picked up by
|
|
4504
|
+
// rescueStep below, never launched alongside the group (21 Oct 2026:
|
|
4505
|
+
// a resumed run launched 77 leftover fixers at once).
|
|
4506
|
+
const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
|
|
4507
|
+
// tools: [peers] — what each step of the group is told about the
|
|
4508
|
+
// others, and the bus they share when they run in this process.
|
|
4509
|
+
const peerBus = localPeerBus();
|
|
4510
|
+
const siblingsOf = (step) => launching
|
|
4511
|
+
.filter((s) => s !== step)
|
|
4512
|
+
.map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
|
|
4221
4513
|
// An agent's own `budget:` — the most it may spend in one run — is
|
|
4222
4514
|
// read at launch, so a cap raised mid-run applies to the next step.
|
|
4223
4515
|
const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
|
|
@@ -4249,16 +4541,49 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4249
4541
|
pools.set(key, (pool = slotPool(step.parallel)));
|
|
4250
4542
|
return pool;
|
|
4251
4543
|
};
|
|
4544
|
+
// One instance of each agent goes first so its siblings read the
|
|
4545
|
+
// prompt cache it wrote — see warmGate. The claim is the callback's
|
|
4546
|
+
// first statement, before any await; the wait comes before the
|
|
4547
|
+
// pool, so nothing holds a slot while it waits.
|
|
4548
|
+
const gate = warmGate(launching);
|
|
4252
4549
|
await Promise.all(launching
|
|
4253
4550
|
.map(async (step) => {
|
|
4551
|
+
const lead = gate.claim(step);
|
|
4552
|
+
if (lead) {
|
|
4553
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: `prompt cache: waiting for the first ${step.agent} step's reply before starting, so this one reads its prefix` });
|
|
4554
|
+
save();
|
|
4555
|
+
await lead;
|
|
4556
|
+
}
|
|
4254
4557
|
const pool = poolFor(step);
|
|
4255
4558
|
if (pool)
|
|
4256
4559
|
await pool.acquire();
|
|
4257
4560
|
try {
|
|
4258
|
-
|
|
4561
|
+
// A stop that arrived while this copy waited — for the
|
|
4562
|
+
// lead's reply or for a slot — must not start it: the
|
|
4563
|
+
// between-groups check runs only once this group is over,
|
|
4564
|
+
// and save() is what carries the flag in from the record.
|
|
4565
|
+
{
|
|
4566
|
+
save();
|
|
4567
|
+
if (run.stopRequested) {
|
|
4568
|
+
step.status = "skipped";
|
|
4569
|
+
step.skipReason = "run stopped";
|
|
4570
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
|
|
4571
|
+
save();
|
|
4572
|
+
gate.release(step);
|
|
4573
|
+
return;
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
await gate.run(step, () => launchStep(step));
|
|
4259
4577
|
}
|
|
4260
4578
|
finally {
|
|
4261
4579
|
pool?.release();
|
|
4580
|
+
// Every attempt is over: off its siblings' rosters, so a
|
|
4581
|
+
// message to it is not reported delivered and never read.
|
|
4582
|
+
// Here, not per attempt — a step between retries is still
|
|
4583
|
+
// one its colleagues can reach.
|
|
4584
|
+
const index = run.steps.indexOf(step);
|
|
4585
|
+
peerBus.leave(index);
|
|
4586
|
+
await platform.egress.stepEnded?.({ tenant, runId: run.id, step: index }).catch(() => undefined);
|
|
4262
4587
|
}
|
|
4263
4588
|
}));
|
|
4264
4589
|
async function launchStep(step) {
|
|
@@ -4304,7 +4629,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4304
4629
|
() => {
|
|
4305
4630
|
save();
|
|
4306
4631
|
return run.stopRequested === true;
|
|
4307
|
-
}, flowTimezone, run.steps.indexOf(step)).finally(() => reserved.delete(step));
|
|
4632
|
+
}, flowTimezone, run.steps.indexOf(step), siblingsOf(step), peerBus).finally(() => reserved.delete(step));
|
|
4308
4633
|
// runStep mutates step.status; read it through a widened local
|
|
4309
4634
|
// so TS doesn't keep the "running" narrowing from above.
|
|
4310
4635
|
const outcome = step.status;
|
|
@@ -4400,6 +4725,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4400
4725
|
// runs out of cycles below, which used to fail the run without ever
|
|
4401
4726
|
// reaching the step's on-fail (medium-desk's claim-trimmer was
|
|
4402
4727
|
// written for exactly that case and could never run).
|
|
4728
|
+
// A rescue hears a stop the way a group step does: its model loop and
|
|
4729
|
+
// its executor's wait for room both poll this (no sandbox exists yet
|
|
4730
|
+
// for a stop to destroy while it waits).
|
|
4731
|
+
const stopPoll = () => {
|
|
4732
|
+
save();
|
|
4733
|
+
return run.stopRequested === true;
|
|
4734
|
+
};
|
|
4403
4735
|
const rescueStep = async (step) => {
|
|
4404
4736
|
if (!step.onFail)
|
|
4405
4737
|
return;
|
|
@@ -4412,6 +4744,38 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4412
4744
|
});
|
|
4413
4745
|
return;
|
|
4414
4746
|
}
|
|
4747
|
+
// One rescue per failure, across drives. A worker that died
|
|
4748
|
+
// mid-rescue left an unfinished record behind it; the drive that
|
|
4749
|
+
// resumes picks that one up instead of splicing another — a crash
|
|
4750
|
+
// loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
|
|
4751
|
+
// ~80). The leftover carries the same instruction, built from the
|
|
4752
|
+
// same failure, and a running one may still hold a sandbox to
|
|
4753
|
+
// re-attach to, so it is THE rescue; any further unfinished copies
|
|
4754
|
+
// are superseded. A FINISHED rescue is history, never reused: it
|
|
4755
|
+
// answered an earlier failure — an evaluator loop's previous cycle
|
|
4756
|
+
// — not this one.
|
|
4757
|
+
const leftovers = [];
|
|
4758
|
+
for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
|
|
4759
|
+
const r = run.steps[j];
|
|
4760
|
+
if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
|
|
4761
|
+
leftovers.push(r);
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4764
|
+
const resumed = leftovers[0];
|
|
4765
|
+
for (const r of leftovers.slice(1)) {
|
|
4766
|
+
r.status = "skipped";
|
|
4767
|
+
r.skipReason = "superseded on resume — one rescue per failure";
|
|
4768
|
+
}
|
|
4769
|
+
if (resumed) {
|
|
4770
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
|
|
4771
|
+
resumed.rescueOf ??= step.agent;
|
|
4772
|
+
resumed.attempts = Math.max(1, resumed.attempts ?? 0);
|
|
4773
|
+
resumed.status = "running";
|
|
4774
|
+
save();
|
|
4775
|
+
await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
|
|
4776
|
+
settleRescue(step, resumed);
|
|
4777
|
+
return;
|
|
4778
|
+
}
|
|
4415
4779
|
step.events.push({
|
|
4416
4780
|
t: new Date().toISOString(),
|
|
4417
4781
|
type: "info",
|
|
@@ -4428,6 +4792,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4428
4792
|
const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
|
|
4429
4793
|
const rescue = {
|
|
4430
4794
|
agent: step.onFail,
|
|
4795
|
+
rescueOf: step.agent,
|
|
4431
4796
|
instruction: `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
|
|
4432
4797
|
`Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
|
|
4433
4798
|
...(step.ask ? { ask: step.ask } : {}),
|
|
@@ -4457,7 +4822,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4457
4822
|
rescue.attempts = 1;
|
|
4458
4823
|
rescue.status = "running";
|
|
4459
4824
|
save();
|
|
4460
|
-
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true,
|
|
4825
|
+
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
|
|
4826
|
+
settleRescue(step, rescue);
|
|
4827
|
+
};
|
|
4828
|
+
// What a rescue's end means for the step it took over.
|
|
4829
|
+
const settleRescue = (step, rescue) => {
|
|
4461
4830
|
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
4462
4831
|
const rescueOutcome = rescue.status;
|
|
4463
4832
|
if (rescueOutcome === "completed") {
|
|
@@ -4492,7 +4861,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4492
4861
|
// here: the person chose to start after it. Counting it stopped
|
|
4493
4862
|
// fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
|
|
4494
4863
|
// a second, before the one step it was asked to run.
|
|
4495
|
-
|
|
4864
|
+
// A rescue record's own failure is already on the step it took over
|
|
4865
|
+
// ("also failed"); counted here it would fail a group whose step a
|
|
4866
|
+
// later rescue completed.
|
|
4867
|
+
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
|
|
4496
4868
|
if (requiredFailed) {
|
|
4497
4869
|
run.status = "failed";
|
|
4498
4870
|
break;
|
|
@@ -4568,6 +4940,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4568
4940
|
for (const s of [...ordered[gi - 1], ...freshGroup]) {
|
|
4569
4941
|
if (s.status === "skipped" && s.each)
|
|
4570
4942
|
continue; // an expanded template stays expanded
|
|
4943
|
+
// A rescue answered its own failure and stays as it ended: reset
|
|
4944
|
+
// to pending it read as an interrupted rescue, and the next
|
|
4945
|
+
// cycle's failure was handed the old one's instructions.
|
|
4946
|
+
if (isRescueRecord(s))
|
|
4947
|
+
continue;
|
|
4571
4948
|
s.status = "pending";
|
|
4572
4949
|
s.result = null;
|
|
4573
4950
|
}
|
|
@@ -4767,26 +5144,7 @@ function lastActivity(run) {
|
|
|
4767
5144
|
}
|
|
4768
5145
|
return latest;
|
|
4769
5146
|
}
|
|
4770
|
-
|
|
4771
|
-
* Close out runs abandoned by a process that is no longer here.
|
|
4772
|
-
*
|
|
4773
|
-
* Runs execute in the server process, and nothing writes a terminal status
|
|
4774
|
-
* when that process goes away — so a deploy, a crash or a Ctrl-C during a run
|
|
4775
|
-
* leaves `status: "running"` on disk forever. The run history then contains
|
|
4776
|
-
* entries that are not merely stale but false: they claim to be in progress,
|
|
4777
|
-
* the dashboard renders them as live, and no one can tell them apart from a
|
|
4778
|
-
* step that is genuinely still thinking.
|
|
4779
|
-
*
|
|
4780
|
-
* Called at startup, before anything reads run history. A run still being
|
|
4781
|
-
* worked on by a live process is protected by the idle window, not by a lock —
|
|
4782
|
-
* so this is safe to call while runs are in flight, and deliberately
|
|
4783
|
-
* conservative about what it declares dead.
|
|
4784
|
-
*
|
|
4785
|
-
* A run genuinely waiting for a person is left alone: that state legitimately
|
|
4786
|
-
* outlives any process. A run whose person already answered is not waiting,
|
|
4787
|
-
* and is picked back up — see below.
|
|
4788
|
-
*/
|
|
4789
|
-
export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
5147
|
+
export function reconcileRuns(tenant, now = Date.now(), live, onEnd) {
|
|
4790
5148
|
const closed = [];
|
|
4791
5149
|
for (const workspace of listWorkspaces(tenant)) {
|
|
4792
5150
|
// Live runs only, read whole — every other status is passed over below,
|
|
@@ -4823,7 +5181,13 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
|
4823
5181
|
// Park at any *later* gate rather than poll: reconcile runs inside
|
|
4824
5182
|
// long-lived server processes, where a 24h file-poll is exactly the
|
|
4825
5183
|
// slot-burning the queue exists to avoid.
|
|
4826
|
-
void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
|
|
5184
|
+
void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
|
|
5185
|
+
.then(() => {
|
|
5186
|
+
const after = readRun(tenant, workspace.name, run.id);
|
|
5187
|
+
if (onEnd && after && (after.status === "completed" || after.status === "failed"))
|
|
5188
|
+
return onEnd(tenant, workspace.name, run.id);
|
|
5189
|
+
})
|
|
5190
|
+
.catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after resume:`, err));
|
|
4827
5191
|
closed.push({
|
|
4828
5192
|
runId: run.id,
|
|
4829
5193
|
tenant,
|
|
@@ -4870,6 +5234,11 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
|
4870
5234
|
// a sandbox we cannot reach must not stop the reconcile
|
|
4871
5235
|
}
|
|
4872
5236
|
closed.push({ runId: run.id, tenant, workspace: workspace.name, action: "closed", interrupted });
|
|
5237
|
+
if (onEnd) {
|
|
5238
|
+
void Promise.resolve()
|
|
5239
|
+
.then(() => onEnd(tenant, workspace.name, run.id))
|
|
5240
|
+
.catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after close:`, err));
|
|
5241
|
+
}
|
|
4873
5242
|
}
|
|
4874
5243
|
}
|
|
4875
5244
|
return closed;
|
|
@@ -4881,8 +5250,8 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
|
4881
5250
|
* one hard-coded account left every other account's interrupted runs marked
|
|
4882
5251
|
* `running` forever, with no process left to finish them.
|
|
4883
5252
|
*/
|
|
4884
|
-
export function reconcileAllRuns(now = Date.now(), live) {
|
|
4885
|
-
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
|
|
5253
|
+
export function reconcileAllRuns(now = Date.now(), live, onEnd) {
|
|
5254
|
+
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live, onEnd));
|
|
4886
5255
|
}
|
|
4887
5256
|
/**
|
|
4888
5257
|
* Wait for a run to stop being in progress.
|