@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/src/runner.ts
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
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
5
|
|
|
6
|
+
import { latestRevisionId } from "./history.ts";
|
|
7
|
+
import { recordCallIo, redactionsFor } from "./capture.ts";
|
|
8
|
+
import { AUTH_REFUSAL } from "./issues.ts";
|
|
6
9
|
import { hookNames } from "./hooks.ts";
|
|
7
10
|
import fs from "node:fs";
|
|
8
11
|
import os from "node:os";
|
|
9
12
|
import path from "node:path";
|
|
10
|
-
import matter from "
|
|
13
|
+
import matter, { parseLenient, configMatter } from "./matter.ts";
|
|
11
14
|
import { spawn } from "node:child_process";
|
|
12
15
|
import {
|
|
13
16
|
knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
|
|
@@ -17,7 +20,8 @@ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.ts
|
|
|
17
20
|
import { eventUrl } from "./webhook.ts";
|
|
18
21
|
import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
|
|
19
22
|
import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
|
|
20
|
-
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
|
|
23
|
+
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.ts";
|
|
24
|
+
import { parseJudge } from "./judge-verdict.ts";
|
|
21
25
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
22
26
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
|
|
23
27
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
|
|
@@ -33,7 +37,7 @@ const isolatedRun = () => {
|
|
|
33
37
|
};
|
|
34
38
|
import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
|
|
35
39
|
import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools, type SubagentSpec } from "./subagents.ts";
|
|
36
|
-
import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.ts";
|
|
40
|
+
import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine, type PeerInfo } from "./operator.ts";
|
|
37
41
|
import { sendPlainNotification } from "./notify.ts";
|
|
38
42
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.ts";
|
|
39
43
|
import { refNames } from "./refs.ts";
|
|
@@ -67,6 +71,7 @@ import {
|
|
|
67
71
|
checkFormatVersion,
|
|
68
72
|
readRun,
|
|
69
73
|
writeRun,
|
|
74
|
+
isRescueRecord,
|
|
70
75
|
runSummary,
|
|
71
76
|
verdictOf,
|
|
72
77
|
type FlowStep,
|
|
@@ -83,9 +88,10 @@ import {
|
|
|
83
88
|
runCost,
|
|
84
89
|
} from "./store.ts";
|
|
85
90
|
import { canReadBack } from "./tool-output.ts";
|
|
91
|
+
import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
|
|
86
92
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
|
|
87
93
|
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
|
|
88
|
-
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
94
|
+
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
89
95
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
|
|
90
96
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
|
|
91
97
|
import { attachOperations, prefetchOpenApi } from "./openapi.ts";
|
|
@@ -210,6 +216,101 @@ export function slotPool(size: number): { acquire: () => Promise<void>; release:
|
|
|
210
216
|
};
|
|
211
217
|
}
|
|
212
218
|
|
|
219
|
+
/** Steps whose model has begun its first response — the step's executor
|
|
220
|
+
* says so the moment the stream starts (EventExtra.responding). Memory
|
|
221
|
+
* only: it is scheduling, not something the run record needs. */
|
|
222
|
+
const respondingSteps = new WeakSet<StepRecord>();
|
|
223
|
+
export function markResponding(step: StepRecord): void {
|
|
224
|
+
respondingSteps.add(step);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Is this step's prompt in the cache yet? An entry is readable from the
|
|
229
|
+
* moment the response to the request that wrote it begins streaming
|
|
230
|
+
* (Anthropic's prompt-caching docs), which is what `responding` marks.
|
|
231
|
+
* The first text, tool call or recorded model call says the same a little
|
|
232
|
+
* later, for an executor that cannot see the stream (the stub, an older
|
|
233
|
+
* driver). Resolves on the first of those, or when the step is over
|
|
234
|
+
* without one (a refusal before the first turn, a failed start).
|
|
235
|
+
*/
|
|
236
|
+
export function untilFirstReply(step: StepRecord, done: Promise<unknown>, pollMs = 250): Promise<void> {
|
|
237
|
+
const replied = () =>
|
|
238
|
+
respondingSteps.has(step) || step.events.some((e) => e.type === "text" || e.type === "tool") || (step.calls?.length ?? 0) > 0;
|
|
239
|
+
return new Promise<void>((resolve) => {
|
|
240
|
+
let finished = false;
|
|
241
|
+
const stop = () => {
|
|
242
|
+
finished = true;
|
|
243
|
+
resolve();
|
|
244
|
+
};
|
|
245
|
+
done.then(stop, stop);
|
|
246
|
+
const tick = () => {
|
|
247
|
+
if (finished) return;
|
|
248
|
+
if (replied()) return stop();
|
|
249
|
+
setTimeout(tick, pollMs).unref?.();
|
|
250
|
+
};
|
|
251
|
+
tick();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Steps of one agent launched together share a prompt prefix — tools and
|
|
257
|
+
* system prompt — and that is what the model's cache keeps. The cache is
|
|
258
|
+
* written by the first request that carries a prefix and readable only
|
|
259
|
+
* once that response has begun, so N copies started at once all write it
|
|
260
|
+
* and none reads: a fan-out of twenty paid twenty cold prefixes. The gate
|
|
261
|
+
* lets the first instance of each agent go alone and holds its siblings
|
|
262
|
+
* until its first response has begun (or it ended); then they all start
|
|
263
|
+
* and read what it wrote — Claude Code holds a workflow's fan-out the same
|
|
264
|
+
* way. Not a fixed delay: a sandbox takes seconds to start before its
|
|
265
|
+
* first model call, so a timer from launch would release the siblings
|
|
266
|
+
* before there was anything to read. Keyed by agent and model, since a
|
|
267
|
+
* cache is per model.
|
|
268
|
+
*/
|
|
269
|
+
export function warmGate(launching: StepRecord[], pollMs = 250) {
|
|
270
|
+
const key = (s: StepRecord) => `${s.agent}\u0000${s.model ?? ""}`;
|
|
271
|
+
const counts = new Map<string, number>();
|
|
272
|
+
for (const s of launching) counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
|
|
273
|
+
const leads = new Map<string, { opened: Promise<void>; open: () => void }>();
|
|
274
|
+
const isLead = new WeakSet<StepRecord>();
|
|
275
|
+
return {
|
|
276
|
+
/** Claim the step's place — synchronously, before anything awaits, or
|
|
277
|
+
* the copies that reach their first await together would all find no
|
|
278
|
+
* lead and all go (a `parallel:` pool's acquire was exactly that).
|
|
279
|
+
* The first of its agent leads and gets null; a later one gets the
|
|
280
|
+
* lead's gate to await before it starts; a lone step gets null too. */
|
|
281
|
+
claim(step: StepRecord): Promise<void> | null {
|
|
282
|
+
const k = key(step);
|
|
283
|
+
if ((counts.get(k) ?? 0) < 2) return null;
|
|
284
|
+
const lead = leads.get(k);
|
|
285
|
+
if (lead) return lead.opened;
|
|
286
|
+
let open!: () => void;
|
|
287
|
+
const opened = new Promise<void>((r) => (open = r));
|
|
288
|
+
leads.set(k, { opened, open });
|
|
289
|
+
isLead.add(step);
|
|
290
|
+
return null;
|
|
291
|
+
},
|
|
292
|
+
/** A lead that will not run after all (the run was stopped while it
|
|
293
|
+
* waited for a pool slot) still opens its gate — or its siblings,
|
|
294
|
+
* which also wake to the stop, would wait on it forever. */
|
|
295
|
+
release(step: StepRecord): void {
|
|
296
|
+
if (isLead.has(step)) leads.get(key(step))!.open();
|
|
297
|
+
},
|
|
298
|
+
/** Run the step; a lead opens its agent's gate on its first reply (or
|
|
299
|
+
* its end), whatever else happens to it. */
|
|
300
|
+
async run(step: StepRecord, launch: () => Promise<void>): Promise<void> {
|
|
301
|
+
if (!isLead.has(step)) return launch();
|
|
302
|
+
const { open } = leads.get(key(step))!;
|
|
303
|
+
const done = launch();
|
|
304
|
+
try {
|
|
305
|
+
await untilFirstReply(step, done, pollMs);
|
|
306
|
+
} finally {
|
|
307
|
+
open();
|
|
308
|
+
}
|
|
309
|
+
await done;
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
213
314
|
export interface DiscoveredSkill {
|
|
214
315
|
name: string;
|
|
215
316
|
description: string;
|
|
@@ -235,32 +336,10 @@ const toTags = (v: unknown): string[] =>
|
|
|
235
336
|
// The Agent Skills client guide recommends quoting such values and retrying
|
|
236
337
|
// rather than dropping the skill, so a cross-client skill still loads here.
|
|
237
338
|
function safeMatter(raw: string): { data: Record<string, unknown> } {
|
|
238
|
-
// gray-matter caches
|
|
239
|
-
// call
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
// once — otherwise the same skill loads on one run and vanishes on the next.
|
|
243
|
-
(matter as unknown as { clearCache: () => void }).clearCache();
|
|
244
|
-
try {
|
|
245
|
-
return matter(raw) as { data: Record<string, unknown> };
|
|
246
|
-
} catch {
|
|
247
|
-
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
248
|
-
if (fm) {
|
|
249
|
-
const fixed = fm[1].replace(
|
|
250
|
-
/^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm,
|
|
251
|
-
(_m, k: string, v: string) => `${k}: ${JSON.stringify(v)}`,
|
|
252
|
-
);
|
|
253
|
-
try {
|
|
254
|
-
// A function replacer, not a string: a fixed block containing `$&` or
|
|
255
|
-
// `$$` (a value with those in it) would otherwise be treated as a
|
|
256
|
-
// replacement pattern and splice the whole frontmatter into itself.
|
|
257
|
-
return matter(raw.replace(fm[1], () => fixed)) as { data: Record<string, unknown> };
|
|
258
|
-
} catch {
|
|
259
|
-
/* fall through */
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
return { data: {} };
|
|
263
|
-
}
|
|
339
|
+
// gray-matter caches a *failed* parse as a hollow success; ./matter.ts
|
|
340
|
+
// parses every call, so the strict read throws every time and the lenient
|
|
341
|
+
// one (shared with check, which warns about it) fires every time.
|
|
342
|
+
return { data: parseLenient(raw).data };
|
|
264
343
|
}
|
|
265
344
|
|
|
266
345
|
// Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
|
|
@@ -356,7 +435,7 @@ export function readAgentsMd(dir: string): { data: Record<string, unknown>; body
|
|
|
356
435
|
const file = path.join(dir, name);
|
|
357
436
|
if (!fs.existsSync(file)) continue;
|
|
358
437
|
try {
|
|
359
|
-
const parsed =
|
|
438
|
+
const parsed = configMatter(fs.readFileSync(file, "utf8"));
|
|
360
439
|
return { data: parsed.data as Record<string, unknown>, body: withoutAgentRules(parsed.content).trim() };
|
|
361
440
|
} catch {
|
|
362
441
|
return null;
|
|
@@ -427,6 +506,10 @@ export function workspaceRuntimePlan(
|
|
|
427
506
|
return [...byFp.values()];
|
|
428
507
|
}
|
|
429
508
|
|
|
509
|
+
/** The most tool content one step records (FOL-54 step 4): about 60 calls
|
|
510
|
+
* at the per-part cap; past it, metadata only. */
|
|
511
|
+
const STEP_CAPTURE_LIMIT = 4_000_000;
|
|
512
|
+
|
|
430
513
|
function workspaceFrontmatter(agentDir: string, tenant: string): Record<string, unknown> {
|
|
431
514
|
return {
|
|
432
515
|
...(readAgentsMd(accountDir(tenant))?.data ?? {}),
|
|
@@ -755,10 +838,14 @@ export function agentContext(
|
|
|
755
838
|
// agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
|
|
756
839
|
// workspace a secret should resolve against is two levels up.
|
|
757
840
|
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
758
|
-
const { data: front, content: body } =
|
|
841
|
+
const { data: front, content: body } = configMatter(
|
|
759
842
|
fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"),
|
|
760
843
|
);
|
|
761
|
-
|
|
844
|
+
// The prompt's sections, by name and by how often they change. Added in
|
|
845
|
+
// the order they are computed; assembled stable first, so the model's
|
|
846
|
+
// cache keeps the agent's half across steps and runs (prompt-layers.ts).
|
|
847
|
+
const parts = new PromptLayers();
|
|
848
|
+
parts.add("persona", resolveDocLinks(body.trim(), path.resolve(agentDir, "..", "..")));
|
|
762
849
|
|
|
763
850
|
// Where the agent is standing. Every path below this line is relative to the
|
|
764
851
|
// agent's own directory, and a run showed why that has to be said rather
|
|
@@ -780,14 +867,14 @@ export function agentContext(
|
|
|
780
867
|
const languageSet = language.from !== "default" && language.from !== "env";
|
|
781
868
|
const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
|
|
782
869
|
if (languageSet || regionSet) {
|
|
783
|
-
parts.
|
|
870
|
+
parts.add("locale",
|
|
784
871
|
`# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
|
|
785
872
|
`Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
|
|
786
873
|
`use these currency, units and calendar conventions in anything you produce. ` +
|
|
787
874
|
`Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`,
|
|
788
875
|
);
|
|
789
876
|
}
|
|
790
|
-
parts.
|
|
877
|
+
parts.add("where",
|
|
791
878
|
`# Where you are\n\n` +
|
|
792
879
|
`Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
|
|
793
880
|
`\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
|
|
@@ -832,7 +919,7 @@ export function agentContext(
|
|
|
832
919
|
// Shared context before anything derived — an account or workspace rule is
|
|
833
920
|
// background the rest of the prompt is read against, not an afterthought.
|
|
834
921
|
const sharedContext = sharedInstructions(agentDir, tenant);
|
|
835
|
-
if (sharedContext) parts.
|
|
922
|
+
if (sharedContext) parts.add("agents.md", sharedContext);
|
|
836
923
|
|
|
837
924
|
// Skills follow the open Agent Skills format (agentskills.io): a folder
|
|
838
925
|
// with SKILL.md, optionally bundling its own scripts/ and references/. The
|
|
@@ -871,7 +958,7 @@ export function agentContext(
|
|
|
871
958
|
(bundles.length ? ` (also bundles ${bundles.join(", ")})` : "")
|
|
872
959
|
);
|
|
873
960
|
});
|
|
874
|
-
parts.
|
|
961
|
+
parts.add("skills",
|
|
875
962
|
`# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
|
|
876
963
|
`read its file for the full instructions before proceeding:\n\n${lines.join("\n")}` +
|
|
877
964
|
(withheld ? `\n\n(${withheld} further skills exist but do not apply to this run.)` : ""),
|
|
@@ -893,11 +980,13 @@ export function agentContext(
|
|
|
893
980
|
: "";
|
|
894
981
|
const projectMem = workspaceMemoryIndex(tenant, workspace);
|
|
895
982
|
const shared = libraryMemoryIndex(tenant);
|
|
896
|
-
|
|
983
|
+
// Dynamic: agents write memory, so the index moves between runs.
|
|
984
|
+
parts.add("memory",
|
|
897
985
|
`# Memory — what you have learned\n\n${memoryIndex ? `Your own (files under memory/):\n\n${memoryIndex}` : "You have no memories of your own yet."}` +
|
|
898
986
|
(projectMem ? `\n\nShared across this workspace:\n\n${projectMem}` : "") +
|
|
899
987
|
(shared ? `\n\nShared across every workspace:\n\n${shared}` : "") +
|
|
900
988
|
howTo,
|
|
989
|
+
{ dynamic: true },
|
|
901
990
|
);
|
|
902
991
|
}
|
|
903
992
|
|
|
@@ -914,7 +1003,7 @@ export function agentContext(
|
|
|
914
1003
|
projectKnowledge && `Shared across this workspace:\n\n${projectKnowledge}`,
|
|
915
1004
|
sharedKnowledge && `Shared across every workspace:\n\n${sharedKnowledge}`,
|
|
916
1005
|
].filter(Boolean);
|
|
917
|
-
parts.
|
|
1006
|
+
parts.add("knowledge",
|
|
918
1007
|
`# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
|
|
919
1008
|
`on it rather than guessing at its contents. This is read-only: it is maintained by ` +
|
|
920
1009
|
`people, not by you — if you learn something new, write it to memory/ instead.\n\n` +
|
|
@@ -947,7 +1036,7 @@ export function agentContext(
|
|
|
947
1036
|
shared.length ? `Shared across this workspace:\n${shared.join("\n")}` : null,
|
|
948
1037
|
global.length ? `From the account library (read-only):\n${global.join("\n")}` : null,
|
|
949
1038
|
].filter(Boolean);
|
|
950
|
-
parts.
|
|
1039
|
+
parts.add("scripts",
|
|
951
1040
|
`# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
|
|
952
1041
|
`Read a script before running it if you are unsure what it does. Credentials your ` +
|
|
953
1042
|
`scripts need are already in the environment as the secrets listed below.`,
|
|
@@ -965,7 +1054,8 @@ export function agentContext(
|
|
|
965
1054
|
// file to size it against, not before.
|
|
966
1055
|
const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
|
|
967
1056
|
if (state.length) {
|
|
968
|
-
|
|
1057
|
+
// Dynamic: runs add and remove state files.
|
|
1058
|
+
parts.add("state",
|
|
969
1059
|
`# State — what you carry between runs\n\n` +
|
|
970
1060
|
`What this workspace keeps across runs: a count, a cursor, where you got to. ` +
|
|
971
1061
|
`Read and write these directly, and update them before you finish so the next ` +
|
|
@@ -975,6 +1065,7 @@ export function agentContext(
|
|
|
975
1065
|
`same editor as everything else — data formats are allowed here, not preferred.\n\n` +
|
|
976
1066
|
`This is not memory. A fact you learned goes in memory/, where it is indexed and ` +
|
|
977
1067
|
`its provenance recorded — state is the bookmark, not the lesson.`,
|
|
1068
|
+
{ dynamic: true },
|
|
978
1069
|
);
|
|
979
1070
|
}
|
|
980
1071
|
|
|
@@ -1057,7 +1148,7 @@ export function agentContext(
|
|
|
1057
1148
|
const keepIn = { dir: agentDir, readable: () => readsFiles };
|
|
1058
1149
|
const apiTools = buildApiTools(tenant, apis, workspace, undefined, keepIn);
|
|
1059
1150
|
if (apiTools.promptLines.length) {
|
|
1060
|
-
parts.
|
|
1151
|
+
parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
|
|
1061
1152
|
}
|
|
1062
1153
|
|
|
1063
1154
|
// Secrets an agent declared become env vars for its scripts and bash.
|
|
@@ -1214,6 +1305,10 @@ export function agentContext(
|
|
|
1214
1305
|
name: spec.name,
|
|
1215
1306
|
maxTokensParam: preset?.maxTokensParam,
|
|
1216
1307
|
reasoningEffort: preset?.reasoningEffort,
|
|
1308
|
+
// The agent's prompts share an opening; the key says so to an
|
|
1309
|
+
// endpoint that routes its cache by one. Only where the preset
|
|
1310
|
+
// says the parameter is understood — elsewhere it is a 400.
|
|
1311
|
+
cacheKey: preset?.promptCacheKey ? promptCacheKey(tenant, workspace, path.basename(agentDir)) : undefined,
|
|
1217
1312
|
});
|
|
1218
1313
|
// What the run is actually pointed at, in one line: the endpoint, the
|
|
1219
1314
|
// tiers this gateway renames, and whether the credential landed.
|
|
@@ -1404,12 +1499,15 @@ export function agentContext(
|
|
|
1404
1499
|
exec,
|
|
1405
1500
|
keepIn.readable,
|
|
1406
1501
|
);
|
|
1407
|
-
|
|
1502
|
+
// Dynamic: the date moves daily, and would otherwise invalidate every
|
|
1503
|
+
// layer behind it once a day.
|
|
1504
|
+
parts.add("clock",
|
|
1408
1505
|
`# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
|
|
1409
1506
|
`write or check — it is the workspace's calendar, not the container's clock.`,
|
|
1507
|
+
{ dynamic: true },
|
|
1410
1508
|
);
|
|
1411
1509
|
if (scriptTools.promptLines.length) {
|
|
1412
|
-
parts.
|
|
1510
|
+
parts.add("script-tools",
|
|
1413
1511
|
`# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
|
|
1414
1512
|
`\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`,
|
|
1415
1513
|
);
|
|
@@ -1420,20 +1518,20 @@ export function agentContext(
|
|
|
1420
1518
|
// reddit-desk validator step on 6 Oct 2026. Say it before the first call.
|
|
1421
1519
|
if (builtins.length) {
|
|
1422
1520
|
const named = builtins.map((b) => b.split("="));
|
|
1423
|
-
parts.
|
|
1521
|
+
parts.add("web",
|
|
1424
1522
|
`# Web search and fetch on this run\n\n` +
|
|
1425
1523
|
named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
|
|
1426
1524
|
`\n\nThe web tool's other actions work as usual.`,
|
|
1427
1525
|
);
|
|
1428
1526
|
}
|
|
1429
1527
|
if (declared.length) {
|
|
1430
|
-
parts.
|
|
1528
|
+
parts.add("secrets",
|
|
1431
1529
|
`# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
|
|
1432
1530
|
`Never print, echo, or write them to a file.`,
|
|
1433
1531
|
);
|
|
1434
1532
|
}
|
|
1435
1533
|
|
|
1436
|
-
parts.
|
|
1534
|
+
parts.add("rules",
|
|
1437
1535
|
"Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
|
|
1438
1536
|
"what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
|
|
1439
1537
|
"for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
|
|
@@ -1453,7 +1551,7 @@ export function agentContext(
|
|
|
1453
1551
|
// run, and an agent that opened with "I'll start by cloning the repo" made
|
|
1454
1552
|
// all three useless. Asking here rather than in each agent.md means every
|
|
1455
1553
|
// agent obeys it, including the ones written before the summary existed.
|
|
1456
|
-
parts.
|
|
1554
|
+
parts.add("rules",
|
|
1457
1555
|
"Open your reply with one sentence saying what you concluded or produced — the outcome, " +
|
|
1458
1556
|
"not your plan. Everything else follows it. That sentence is what a person sees in the " +
|
|
1459
1557
|
"run list and in the notification, without opening the run.",
|
|
@@ -1500,6 +1598,7 @@ export function agentContext(
|
|
|
1500
1598
|
let wantHistory = false;
|
|
1501
1599
|
let wantDesks = false;
|
|
1502
1600
|
let wantAsk = false;
|
|
1601
|
+
let wantPeers = false;
|
|
1503
1602
|
|
|
1504
1603
|
for (const ref of toolRefs(front)) {
|
|
1505
1604
|
const toolName = ref.name;
|
|
@@ -1532,12 +1631,13 @@ export function agentContext(
|
|
|
1532
1631
|
} else if (TOOL_MAP[toolName]) {
|
|
1533
1632
|
allowed.push(...TOOL_MAP[toolName]);
|
|
1534
1633
|
if (available[toolName]) shadowed.push(toolName);
|
|
1535
|
-
} else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
|
|
1634
|
+
} else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask" || toolName === "peers") {
|
|
1536
1635
|
// The platform's own groups, served in-process — built below, once
|
|
1537
1636
|
// the roots and the records are known.
|
|
1538
1637
|
if (toolName === "search") wantSearch = true;
|
|
1539
1638
|
else if (toolName === "history") wantHistory = true;
|
|
1540
1639
|
else if (toolName === "ask") wantAsk = true;
|
|
1640
|
+
else if (toolName === "peers") wantPeers = true;
|
|
1541
1641
|
else wantDesks = true;
|
|
1542
1642
|
if (available[toolName]) shadowed.push(toolName);
|
|
1543
1643
|
} else if (toolName === "install") {
|
|
@@ -1593,10 +1693,16 @@ export function agentContext(
|
|
|
1593
1693
|
const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
|
|
1594
1694
|
if (ask) {
|
|
1595
1695
|
allowed.push(ASK_TOOL);
|
|
1596
|
-
parts.
|
|
1696
|
+
parts.add("ask", `# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
|
|
1597
1697
|
}
|
|
1698
|
+
// tools: [peers] — peers_list and peers_message, to the other steps of
|
|
1699
|
+
// this step's parallel group. Who they are is known only when the group
|
|
1700
|
+
// launches, so the grant is here and the roster, the server and the
|
|
1701
|
+
// prompt's line are the step's (runStep).
|
|
1702
|
+
const peers = wantPeers;
|
|
1703
|
+
if (peers) allowed.push(...PEER_TOOLS);
|
|
1598
1704
|
if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
|
|
1599
|
-
parts.
|
|
1705
|
+
parts.add("finding", `# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
|
|
1600
1706
|
}
|
|
1601
1707
|
// An MCP server declares its tools when it connects, so they can't be listed
|
|
1602
1708
|
// ahead of time. Server-level specs cover them: granting the server grants
|
|
@@ -1618,6 +1724,7 @@ export function agentContext(
|
|
|
1618
1724
|
if (denied.includes("install")) denied.push(INSTALL_TOOL);
|
|
1619
1725
|
const finalAllowed = allowed.filter((t) => !denied.includes(t));
|
|
1620
1726
|
readsFiles = canReadBack(finalAllowed);
|
|
1727
|
+
const prompt = parts.assemble();
|
|
1621
1728
|
|
|
1622
1729
|
return {
|
|
1623
1730
|
front,
|
|
@@ -1625,7 +1732,9 @@ export function agentContext(
|
|
|
1625
1732
|
/** The zone, where it came from, and anything unreadable on the way —
|
|
1626
1733
|
* reported by the step, not here, so it lands in the run's trace. */
|
|
1627
1734
|
clock,
|
|
1628
|
-
systemPrompt:
|
|
1735
|
+
systemPrompt: prompt.text,
|
|
1736
|
+
/** What the record keeps of each layer of that prompt. */
|
|
1737
|
+
promptLayers: prompt.layers,
|
|
1629
1738
|
allowed: finalAllowed,
|
|
1630
1739
|
disabled,
|
|
1631
1740
|
unknownTools,
|
|
@@ -1649,6 +1758,7 @@ export function agentContext(
|
|
|
1649
1758
|
historyTools,
|
|
1650
1759
|
deskTools,
|
|
1651
1760
|
ask,
|
|
1761
|
+
peers,
|
|
1652
1762
|
// `spec` is the MERGED declaration — the agent's own block or its
|
|
1653
1763
|
// workspace's, plus every granted tool's. The isolated path builds its
|
|
1654
1764
|
// runtime from this; passing the agent's frontmatter alone silently drops
|
|
@@ -1906,6 +2016,12 @@ async function runStep(
|
|
|
1906
2016
|
* person's message to this step reaches this step's own inbox and not a
|
|
1907
2017
|
* sibling's running in the same group. Undefined outside a flow. */
|
|
1908
2018
|
stepIndex?: number,
|
|
2019
|
+
/** The other steps launched with this one (its parallel group), for
|
|
2020
|
+
* tools: [peers]. Empty when it runs alone. */
|
|
2021
|
+
siblings: PeerInfo[] = [],
|
|
2022
|
+
/** The in-process colleagues' bus for this group (localPeerBus), when
|
|
2023
|
+
* the step runs in this process rather than a pod. */
|
|
2024
|
+
peerBus?: ReturnType<typeof localPeerBus>,
|
|
1909
2025
|
) {
|
|
1910
2026
|
// Secret values are injected into scripts as environment variables and
|
|
1911
2027
|
// substituted into API headers, so a model that reads one back — from a
|
|
@@ -1952,9 +2068,63 @@ async function runStep(
|
|
|
1952
2068
|
}
|
|
1953
2069
|
};
|
|
1954
2070
|
|
|
2071
|
+
// What this workspace records of each step (FOL-54 step 4): `capture:` in
|
|
2072
|
+
// AGENTS.md — every model call always; each tool call's input and output
|
|
2073
|
+
// unless it says `metadata`.
|
|
2074
|
+
const capture: "metadata" | "full" = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
|
|
2075
|
+
// `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
|
|
2076
|
+
// grades with — field by field, the workspace's else the account's.
|
|
2077
|
+
const { judgeModel, judgeEffort } = (() => {
|
|
2078
|
+
const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
|
|
2079
|
+
const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
|
|
2080
|
+
const model = ws.model ?? account.model;
|
|
2081
|
+
const effort = resolveEffort(ws.effort ?? account.effort ?? "");
|
|
2082
|
+
return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
|
|
2083
|
+
})();
|
|
2084
|
+
// A step that pours out content stops being recorded past this — its
|
|
2085
|
+
// calls keep their names, sizes and times (FOL-54 step 4).
|
|
2086
|
+
let capturedChars = 0;
|
|
2087
|
+
let captureFull = false;
|
|
1955
2088
|
const push = (type: StepRecord["events"][number]["type"], text: string, extra?: EventExtra) => {
|
|
1956
2089
|
const t = new Date().toISOString();
|
|
1957
|
-
|
|
2090
|
+
// The model has begun its first response: held only in memory, for
|
|
2091
|
+
// the fan-out gate (warmGate). Not a line in the trace.
|
|
2092
|
+
if (extra?.responding) {
|
|
2093
|
+
markResponding(step);
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
// A model call is a span on the step, not a line in its trace.
|
|
2097
|
+
// Numbered here, across attempts: a retry's calls follow the first
|
|
2098
|
+
// attempt's, and an id-less turn of one attempt never overwrites
|
|
2099
|
+
// another's. No save of its own — a turn's blocks arrive as several
|
|
2100
|
+
// messages, and the next event saves them all.
|
|
2101
|
+
if (extra?.turn) {
|
|
2102
|
+
const calls = (step.calls ??= []);
|
|
2103
|
+
// The loop counts attempts from 1; a call of the first carries none.
|
|
2104
|
+
const attempt = Math.max(1, step.attempts ?? 1);
|
|
2105
|
+
const at = calls.findIndex((c) => c.id === extra.turn!.id && (c.attempt ?? 1) === attempt);
|
|
2106
|
+
const call = { ...extra.turn, n: at >= 0 ? calls[at]!.n : calls.length + 1, ...(attempt > 1 ? { attempt } : {}) };
|
|
2107
|
+
if (at >= 0) calls[at] = call;
|
|
2108
|
+
else calls.push(call);
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
// A tool call's content goes beside the run, masked (capture.ts), and the
|
|
2112
|
+
// event keeps only that it was recorded.
|
|
2113
|
+
let rest: EventExtra | undefined = extra;
|
|
2114
|
+
if (extra?.io) {
|
|
2115
|
+
const { io, ...others } = extra;
|
|
2116
|
+
rest = others;
|
|
2117
|
+
const size = (io.input?.length ?? 0) + (io.output?.length ?? 0);
|
|
2118
|
+
if (runId && extra.call && capture === "full" && capturedChars + size > STEP_CAPTURE_LIMIT && !captureFull) {
|
|
2119
|
+
captureFull = true;
|
|
2120
|
+
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` });
|
|
2121
|
+
}
|
|
2122
|
+
if (runId && extra.call && capture === "full" && !captureFull && recordCallIo(path.resolve(agentDir, "..", ".."), runId, extra.call, text, io, redact)) {
|
|
2123
|
+
capturedChars += size;
|
|
2124
|
+
rest = { ...others, captured: true };
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
step.events.push({ t, type, text: redact(text), ...rest });
|
|
1958
2128
|
// A person in the loop: the question, its answer, a message delivered —
|
|
1959
2129
|
// onto the step, so the run page, the API and history show them.
|
|
1960
2130
|
if (extra?.operator) {
|
|
@@ -1988,7 +2158,26 @@ async function runStep(
|
|
|
1988
2158
|
step.status = "completed";
|
|
1989
2159
|
// A stub answer may open with `cost: 0.25` — a pretend price, so the
|
|
1990
2160
|
// parts of orchestration that watch money (budget:) are testable too.
|
|
1991
|
-
|
|
2161
|
+
let answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
|
|
2162
|
+
// `call: <tool> <input> => <output>` lines: a pretend tool call, sent
|
|
2163
|
+
// through the same push a real step's events take — with its model call
|
|
2164
|
+
// and its content — so what the runner records of a step is testable.
|
|
2165
|
+
answer = answer
|
|
2166
|
+
.split("\n")
|
|
2167
|
+
.filter((line) => {
|
|
2168
|
+
const m = /^call: (\S+) (.*?) => (.*)$/.exec(line);
|
|
2169
|
+
if (!m) return true;
|
|
2170
|
+
const id = `toolu_stub${(step.calls?.length ?? 0) + 1}`;
|
|
2171
|
+
// Twice under one id, as the SDK sends a turn's blocks: one row.
|
|
2172
|
+
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 };
|
|
2173
|
+
push("info", "", { turn });
|
|
2174
|
+
push("info", "", { turn: { ...turn, stop: "tool_use", tools: 1 } });
|
|
2175
|
+
push("tool", m[1]!, { call: id, ...(capture ? { io: { input: m[2]!, inputChars: m[2]!.length } } : {}) });
|
|
2176
|
+
push("tool", m[1]!, { call: id, ms: 3, io: { output: m[3]!, outputChars: m[3]!.length } });
|
|
2177
|
+
return false;
|
|
2178
|
+
})
|
|
2179
|
+
.join("\n")
|
|
2180
|
+
.trim();
|
|
1992
2181
|
const priced = answer.match(/^cost:\s*([\d.]+)\n?/);
|
|
1993
2182
|
const body = (priced ? answer.slice(priced[0].length) : answer).trim();
|
|
1994
2183
|
// A `===` line inside an answer separates earlier turns from the final
|
|
@@ -2027,7 +2216,7 @@ async function runStep(
|
|
|
2027
2216
|
// step: the generic HTTP tool still works without the typed ones.
|
|
2028
2217
|
for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant))) push("error", `openapi: ${w}`);
|
|
2029
2218
|
const {
|
|
2030
|
-
front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools,
|
|
2219
|
+
front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools,
|
|
2031
2220
|
secretEnv, secretScopes, missingSecrets, missingTools, runtime,
|
|
2032
2221
|
unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames,
|
|
2033
2222
|
apiSpecs, scriptSpecs, brokenTools, size: agentSize,
|
|
@@ -2035,6 +2224,7 @@ async function runStep(
|
|
|
2035
2224
|
searchChoice, fetchChoice, actionChoices, web, language, region,
|
|
2036
2225
|
fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools,
|
|
2037
2226
|
ask,
|
|
2227
|
+
peers,
|
|
2038
2228
|
translator, fallbackTranslator,
|
|
2039
2229
|
outward,
|
|
2040
2230
|
} = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
@@ -2043,6 +2233,9 @@ async function runStep(
|
|
|
2043
2233
|
// Sent again on every turn; with turns and the tools' result sizes, what
|
|
2044
2234
|
// a token report reads to say where a step's input went.
|
|
2045
2235
|
step.systemPromptChars = systemPrompt.length;
|
|
2236
|
+
// And each layer's size and hash, so a later reader can say which part
|
|
2237
|
+
// of the prompt changed between this step and the agent's last.
|
|
2238
|
+
step.promptLayers = promptLayers;
|
|
2046
2239
|
// A retry that moved the step up a class (an evicted or OOM-killed
|
|
2047
2240
|
// attempt) wins over the agent's own `size:` for the attempts after.
|
|
2048
2241
|
const size = step.sizeUp ?? agentSize;
|
|
@@ -2062,11 +2255,7 @@ async function runStep(
|
|
|
2062
2255
|
// Populate before the first push: everything after this point may quote a
|
|
2063
2256
|
// credential. Short values are skipped — a two-character secret would
|
|
2064
2257
|
// redact half the English language, and anything that short is not one.
|
|
2065
|
-
redactions =
|
|
2066
|
-
.filter(([, value]) => typeof value === "string" && value.length >= 8)
|
|
2067
|
-
.map(([name, value]) => [value, name] as [string, string])
|
|
2068
|
-
// Longest first, so a value containing another is replaced whole.
|
|
2069
|
-
.sort((a, b) => b[0].length - a[0].length);
|
|
2258
|
+
redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
|
|
2070
2259
|
|
|
2071
2260
|
// One line for the clock: which zone this step works to, and which level
|
|
2072
2261
|
// set it. "Why is this dated yesterday" should be answerable from the run.
|
|
@@ -2314,7 +2503,14 @@ async function runStep(
|
|
|
2314
2503
|
...mcpNames,
|
|
2315
2504
|
...(consults.length ? ["foldrun_agents"] : []),
|
|
2316
2505
|
...(ask ? ["foldrun_ask"] : []),
|
|
2506
|
+
...(peers ? ["foldrun_peers"] : []),
|
|
2317
2507
|
];
|
|
2508
|
+
if (peers) {
|
|
2509
|
+
// The colleagues' contract, in the prompt the step reads: who runs
|
|
2510
|
+
// beside it this time (none, when it runs alone), and that a message
|
|
2511
|
+
// is a request, never a hand-over.
|
|
2512
|
+
prompt += `\n\n<colleagues>\n${peersPromptLine(siblings)}\n</colleagues>`;
|
|
2513
|
+
}
|
|
2318
2514
|
|
|
2319
2515
|
// Colleagues this agent may DELEGATE to: each with its own context and
|
|
2320
2516
|
// its own tools — resolved exactly as its own step's would be, then cut
|
|
@@ -2483,7 +2679,24 @@ async function runStep(
|
|
|
2483
2679
|
],
|
|
2484
2680
|
});
|
|
2485
2681
|
grant.network = { level: network.level, hosts: network.hosts };
|
|
2486
|
-
|
|
2682
|
+
// network_enforce: true at the account or the workspace asks the proxy
|
|
2683
|
+
// to refuse; the proxy adds the install's own switch and has the last
|
|
2684
|
+
// word (egress-proxy.ts), so false here never loosens anything.
|
|
2685
|
+
grant.enforce = networkEnforceWanted(
|
|
2686
|
+
readAgentsMd(accountDir(tenant))?.data?.network_enforce,
|
|
2687
|
+
readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce,
|
|
2688
|
+
);
|
|
2689
|
+
const lease = await platform.egress.lease({
|
|
2690
|
+
tenant,
|
|
2691
|
+
runId: runId ?? "adhoc",
|
|
2692
|
+
grant,
|
|
2693
|
+
test: testRun,
|
|
2694
|
+
...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
|
|
2695
|
+
// tools: [peers] — the roster the proxy serves, and the inboxes a
|
|
2696
|
+
// colleague's message may be addressed to. Only when granted: a
|
|
2697
|
+
// step without the tools has no reason to know its siblings.
|
|
2698
|
+
...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
|
|
2699
|
+
});
|
|
2487
2700
|
// The forward door: every program in the sandbox sends through the
|
|
2488
2701
|
// proxy, which holds the site list and fills bound secrets. A proxy
|
|
2489
2702
|
// without the door (an older platform) leaves programs going direct.
|
|
@@ -2556,6 +2769,23 @@ async function runStep(
|
|
|
2556
2769
|
// carrying TEST_MODE_WITHHELD would only fail where it should have
|
|
2557
2770
|
// been sunk or refused.
|
|
2558
2771
|
const apiValues = testRun && lease ? placeholders : sandboxSecrets;
|
|
2772
|
+
// A declared secret named like the step's own model variables
|
|
2773
|
+
// (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, …) was spread over them below
|
|
2774
|
+
// and broke, or redirected, the step's model calls (FOL-19). It crosses
|
|
2775
|
+
// as FOLDRUN_DECLARED_<NAME> instead: the model keeps its settings, and
|
|
2776
|
+
// the driver gives the agent's API and script tools the value under its
|
|
2777
|
+
// own name (run-container.ts) — never the model's key in its place.
|
|
2778
|
+
const modelNamed = Object.keys(sandboxSecrets).filter(isModelEnvName);
|
|
2779
|
+
if (modelNamed.length) {
|
|
2780
|
+
push(
|
|
2781
|
+
"info",
|
|
2782
|
+
`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; ` +
|
|
2783
|
+
"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:",
|
|
2784
|
+
);
|
|
2785
|
+
}
|
|
2786
|
+
const sandboxEnvSecrets = Object.fromEntries(
|
|
2787
|
+
Object.entries(sandboxSecrets).map(([name, value]) => [isModelEnvName(name) ? `${DECLARED_ENV_PREFIX}${name}` : name, value]),
|
|
2788
|
+
);
|
|
2559
2789
|
const substitutedApis = apiSpecs.map((api) => ({
|
|
2560
2790
|
...api,
|
|
2561
2791
|
headers: Object.fromEntries(
|
|
@@ -2612,15 +2842,19 @@ async function runStep(
|
|
|
2612
2842
|
// field. A tool's own `runtime:` is part of what this step needs.
|
|
2613
2843
|
runtime: runtime.spec,
|
|
2614
2844
|
consults,
|
|
2845
|
+
...(peers ? { peers: { self: step.agent, siblings } } : {}),
|
|
2615
2846
|
...(subagents.length ? { subagents } : {}),
|
|
2616
2847
|
timeoutSec: step.timeout,
|
|
2617
2848
|
budgetUsd: stepBudgetUsd,
|
|
2618
2849
|
budgetNote: stepBudgetNote,
|
|
2619
2850
|
price: tokenPrice,
|
|
2620
2851
|
verify: step.verify,
|
|
2852
|
+
...(judgeModel ? { judgeModel } : {}),
|
|
2853
|
+
...(judgeEffort ? { judgeEffort } : {}),
|
|
2621
2854
|
output: step.output,
|
|
2622
2855
|
schema: resolvedSchema ?? undefined,
|
|
2623
2856
|
maxTurns: step.maxTurns,
|
|
2857
|
+
capture,
|
|
2624
2858
|
...limitOpts,
|
|
2625
2859
|
...hookOpts,
|
|
2626
2860
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
@@ -2667,12 +2901,15 @@ async function runStep(
|
|
|
2667
2901
|
FOLDRUN_AGENT: step.agent,
|
|
2668
2902
|
FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
|
|
2669
2903
|
...(testRun ? testModeEnv() : {}),
|
|
2670
|
-
...
|
|
2904
|
+
...sandboxEnvSecrets,
|
|
2671
2905
|
}).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
2672
2906
|
),
|
|
2673
2907
|
emit: pushWatching,
|
|
2674
2908
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
2675
2909
|
runId,
|
|
2910
|
+
// A step still waiting for room has no sandbox to destroy; the
|
|
2911
|
+
// executor asks this before it creates one (pod-room.ts).
|
|
2912
|
+
...(stopRequested ? { stopRequested } : {}),
|
|
2676
2913
|
size,
|
|
2677
2914
|
// The pod's network, for the executor's own wall (the k8s tier's
|
|
2678
2915
|
// network policy): a filtered pod reaches the proxy and DNS only.
|
|
@@ -2950,6 +3187,13 @@ async function runStep(
|
|
|
2950
3187
|
// On this machine nobody can write in and there is no proxy: the
|
|
2951
3188
|
// question goes to the terminal when there is one, else unanswered.
|
|
2952
3189
|
const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
|
|
3190
|
+
// Colleagues in the same group reach each other over the run's own
|
|
3191
|
+
// bus here — no proxy, so no person can write in, but a sibling can.
|
|
3192
|
+
// Every grouped step gets the inbox (a pod reads the proxy's inbox
|
|
3193
|
+
// whatever its tools); only tools: [peers] gets the tools to send.
|
|
3194
|
+
const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
|
|
3195
|
+
const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
|
|
3196
|
+
const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
|
|
2953
3197
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
2954
3198
|
// finally — once, however many attempts follow.
|
|
2955
3199
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
@@ -2973,6 +3217,9 @@ async function runStep(
|
|
|
2973
3217
|
workspaceRoot,
|
|
2974
3218
|
libraryRoot: libraryDir(tenant),
|
|
2975
3219
|
...(subagents.length ? { subagents } : {}),
|
|
3220
|
+
// The colleagues' inbox, read after every tool call — the same hook
|
|
3221
|
+
// a pod uses for a person's messages.
|
|
3222
|
+
...(localInbox ? { inbox: localInbox } : {}),
|
|
2976
3223
|
prompt,
|
|
2977
3224
|
model,
|
|
2978
3225
|
effort,
|
|
@@ -2987,6 +3234,7 @@ async function runStep(
|
|
|
2987
3234
|
...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
|
|
2988
3235
|
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
2989
3236
|
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
3237
|
+
...(peerTools ? { foldrun_peers: peerTools.server } : {}),
|
|
2990
3238
|
...(installTools ? { foldrun_install: installTools.server } : {}),
|
|
2991
3239
|
...mcpServers,
|
|
2992
3240
|
},
|
|
@@ -3001,9 +3249,12 @@ async function runStep(
|
|
|
3001
3249
|
budgetNote: stepBudgetNote,
|
|
3002
3250
|
price: tokenPrice,
|
|
3003
3251
|
verify: step.verify,
|
|
3252
|
+
...(judgeModel ? { judgeModel } : {}),
|
|
3253
|
+
...(judgeEffort ? { judgeEffort } : {}),
|
|
3004
3254
|
output: step.output,
|
|
3005
3255
|
schema: resolvedSchema ?? undefined,
|
|
3006
3256
|
maxTurns: step.maxTurns,
|
|
3257
|
+
capture,
|
|
3007
3258
|
...limitOpts,
|
|
3008
3259
|
...hookOpts,
|
|
3009
3260
|
stopRequested,
|
|
@@ -3203,7 +3454,7 @@ export function copyTreeBytes(from: string, to: string) {
|
|
|
3203
3454
|
function openApiSources(agentDir: string, tenant: string): string[] {
|
|
3204
3455
|
const out = new Set<string>();
|
|
3205
3456
|
try {
|
|
3206
|
-
const { data } =
|
|
3457
|
+
const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
|
|
3207
3458
|
const front = data as { apis?: unknown; tools?: unknown };
|
|
3208
3459
|
for (const api of parseApis(front.apis)) if (api.openapi?.startsWith("https://")) out.add(api.openapi);
|
|
3209
3460
|
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
@@ -3266,7 +3517,7 @@ const CREDENTIAL_POLL_MS = 2_000;
|
|
|
3266
3517
|
* step started one second before the 09:07:52Z refresh) matched none of the
|
|
3267
3518
|
* older two, so the step failed instead of waiting for the new token. */
|
|
3268
3519
|
export function isAuthRefusal(text: string): boolean {
|
|
3269
|
-
return
|
|
3520
|
+
return AUTH_REFUSAL.test(text);
|
|
3270
3521
|
}
|
|
3271
3522
|
|
|
3272
3523
|
/**
|
|
@@ -3828,10 +4079,30 @@ function driveRunInner(
|
|
|
3828
4079
|
*
|
|
3829
4080
|
* The flag is one-way: nothing here clears it, so the read-modify-write can
|
|
3830
4081
|
* only ever turn a stop on, never off.
|
|
4082
|
+
*
|
|
4083
|
+
* And once it is on, this copy may not undo what stopRun wrote: the run
|
|
4084
|
+
* stays failed, and steps that had not started stay skipped. Before
|
|
4085
|
+
* 21 Oct 2026 only the flag came across, so the next save put the record
|
|
4086
|
+
* back to `running` with its steps pending; a second stop then answered
|
|
4087
|
+
* "failed" instead of "already failed", and the group kept starting pods
|
|
4088
|
+
* (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
|
|
4089
|
+
* as it is: its sandbox is gone, it ends on its own, and records how.
|
|
3831
4090
|
*/
|
|
3832
4091
|
const save = () => {
|
|
3833
|
-
|
|
3834
|
-
|
|
4092
|
+
const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
|
|
4093
|
+
if (disk?.stopRequested) run.stopRequested = true;
|
|
4094
|
+
if (run.stopRequested) {
|
|
4095
|
+
if (run.status !== "completed" && run.status !== "failed") {
|
|
4096
|
+
run.status = "failed";
|
|
4097
|
+
run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
|
|
4098
|
+
}
|
|
4099
|
+
run.parkedAt = null;
|
|
4100
|
+
for (const s of run.steps) {
|
|
4101
|
+
if (s.status === "pending" || s.status === "awaiting-approval") {
|
|
4102
|
+
s.status = "skipped";
|
|
4103
|
+
s.skipReason ??= "run stopped";
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
3835
4106
|
}
|
|
3836
4107
|
writeRun(tenant, workspace, run);
|
|
3837
4108
|
};
|
|
@@ -3890,6 +4161,12 @@ function driveRunInner(
|
|
|
3890
4161
|
try {
|
|
3891
4162
|
// Entering the loop is what makes a run live, whatever state it was
|
|
3892
4163
|
// saved in: `queued` from the queue, `awaiting-approval` from a park.
|
|
4164
|
+
// The first pick-up (FOL-54 step 4): startedAt to this is the time it
|
|
4165
|
+
// queued; and the files it runs are the workspace's latest revision
|
|
4166
|
+
// as it starts — a deploy's revision is its commit, and an edit through
|
|
4167
|
+
// /source makes one too. A re-run comes through here as well.
|
|
4168
|
+
run.drivenAt ??= new Date().toISOString();
|
|
4169
|
+
if (run.revision === undefined) run.revision = latestRevisionId(tenant, workspace);
|
|
3893
4170
|
if (run.status !== "running") {
|
|
3894
4171
|
run.status = "running";
|
|
3895
4172
|
run.parkedAt = null;
|
|
@@ -4043,13 +4320,20 @@ function driveRunInner(
|
|
|
4043
4320
|
for (let i = gi - 1; i >= 0; i--) if (groupData[i] !== undefined) return groupData[i];
|
|
4044
4321
|
return undefined;
|
|
4045
4322
|
};
|
|
4046
|
-
|
|
4323
|
+
// A rescue's result already stands on the step it took over, so the
|
|
4324
|
+
// three handoffs below read the flow's steps only: counting the rescue
|
|
4325
|
+
// too handed the next group the same output twice, or a stale one
|
|
4326
|
+
// from an evaluator loop's earlier cycle.
|
|
4327
|
+
const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !isRescueRecord(s));
|
|
4328
|
+
const dataOf = (all: StepRecord[]): unknown => {
|
|
4329
|
+
const group = flowStepsOf(all);
|
|
4047
4330
|
const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
|
|
4048
4331
|
if (carrying.length === 0) return undefined;
|
|
4049
4332
|
if (carrying.length === 1 && !carrying[0].item) return carrying[0].data;
|
|
4050
4333
|
return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
|
|
4051
4334
|
};
|
|
4052
|
-
const resultsOf = (
|
|
4335
|
+
const resultsOf = (all: StepRecord[]): string | null => {
|
|
4336
|
+
const group = flowStepsOf(all);
|
|
4053
4337
|
const results = group
|
|
4054
4338
|
.filter((s) => s.status === "completed" && s.result)
|
|
4055
4339
|
.map((s) =>
|
|
@@ -4063,8 +4347,8 @@ function driveRunInner(
|
|
|
4063
4347
|
};
|
|
4064
4348
|
// What each step concluded — its final turn — for the handoffs that
|
|
4065
4349
|
// should read a conclusion rather than a transcript (`each: lines`).
|
|
4066
|
-
const conclusionsOf = (
|
|
4067
|
-
const out =
|
|
4350
|
+
const conclusionsOf = (all: StepRecord[]): string | null => {
|
|
4351
|
+
const out = flowStepsOf(all)
|
|
4068
4352
|
.filter((s) => s.status === "completed" && (s.conclusion || s.result))
|
|
4069
4353
|
.map((s) => s.conclusion || s.result!);
|
|
4070
4354
|
return out.length ? out.join("\n") : null;
|
|
@@ -4451,7 +4735,7 @@ function driveRunInner(
|
|
|
4451
4735
|
save();
|
|
4452
4736
|
if (freshGroup.every((s) => s.status !== "pending")) {
|
|
4453
4737
|
// everything in this group was rejected
|
|
4454
|
-
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
|
|
4738
|
+
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
|
|
4455
4739
|
run.status = "failed";
|
|
4456
4740
|
break;
|
|
4457
4741
|
}
|
|
@@ -4465,7 +4749,18 @@ function driveRunInner(
|
|
|
4465
4749
|
// itself mid-turn at its share, so a fan-out of twenty cannot end
|
|
4466
4750
|
// twenty steps over — the shares sum to what was left. Between
|
|
4467
4751
|
// groups the check above still refuses to start the next one.
|
|
4468
|
-
|
|
4752
|
+
// A rescue record is the on-fail of a step in this group, not a step
|
|
4753
|
+
// of the flow: one left pending by a drive that died is picked up by
|
|
4754
|
+
// rescueStep below, never launched alongside the group (21 Oct 2026:
|
|
4755
|
+
// a resumed run launched 77 leftover fixers at once).
|
|
4756
|
+
const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
|
|
4757
|
+
// tools: [peers] — what each step of the group is told about the
|
|
4758
|
+
// others, and the bus they share when they run in this process.
|
|
4759
|
+
const peerBus = localPeerBus();
|
|
4760
|
+
const siblingsOf = (step: StepRecord): PeerInfo[] =>
|
|
4761
|
+
launching
|
|
4762
|
+
.filter((s) => s !== step)
|
|
4763
|
+
.map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
|
|
4469
4764
|
// An agent's own `budget:` — the most it may spend in one run — is
|
|
4470
4765
|
// read at launch, so a cap raised mid-run applies to the next step.
|
|
4471
4766
|
const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
|
|
@@ -4502,15 +4797,48 @@ function driveRunInner(
|
|
|
4502
4797
|
if (!pool) pools.set(key, (pool = slotPool(step.parallel)));
|
|
4503
4798
|
return pool;
|
|
4504
4799
|
};
|
|
4800
|
+
// One instance of each agent goes first so its siblings read the
|
|
4801
|
+
// prompt cache it wrote — see warmGate. The claim is the callback's
|
|
4802
|
+
// first statement, before any await; the wait comes before the
|
|
4803
|
+
// pool, so nothing holds a slot while it waits.
|
|
4804
|
+
const gate = warmGate(launching);
|
|
4505
4805
|
await Promise.all(
|
|
4506
4806
|
launching
|
|
4507
4807
|
.map(async (step) => {
|
|
4808
|
+
const lead = gate.claim(step);
|
|
4809
|
+
if (lead) {
|
|
4810
|
+
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` });
|
|
4811
|
+
save();
|
|
4812
|
+
await lead;
|
|
4813
|
+
}
|
|
4508
4814
|
const pool = poolFor(step);
|
|
4509
4815
|
if (pool) await pool.acquire();
|
|
4510
4816
|
try {
|
|
4511
|
-
|
|
4817
|
+
// A stop that arrived while this copy waited — for the
|
|
4818
|
+
// lead's reply or for a slot — must not start it: the
|
|
4819
|
+
// between-groups check runs only once this group is over,
|
|
4820
|
+
// and save() is what carries the flag in from the record.
|
|
4821
|
+
{
|
|
4822
|
+
save();
|
|
4823
|
+
if (run.stopRequested) {
|
|
4824
|
+
step.status = "skipped";
|
|
4825
|
+
step.skipReason = "run stopped";
|
|
4826
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
|
|
4827
|
+
save();
|
|
4828
|
+
gate.release(step);
|
|
4829
|
+
return;
|
|
4830
|
+
}
|
|
4831
|
+
}
|
|
4832
|
+
await gate.run(step, () => launchStep(step));
|
|
4512
4833
|
} finally {
|
|
4513
4834
|
pool?.release();
|
|
4835
|
+
// Every attempt is over: off its siblings' rosters, so a
|
|
4836
|
+
// message to it is not reported delivered and never read.
|
|
4837
|
+
// Here, not per attempt — a step between retries is still
|
|
4838
|
+
// one its colleagues can reach.
|
|
4839
|
+
const index = run.steps.indexOf(step);
|
|
4840
|
+
peerBus.leave(index);
|
|
4841
|
+
await platform.egress.stepEnded?.({ tenant, runId: run.id, step: index }).catch(() => undefined);
|
|
4514
4842
|
}
|
|
4515
4843
|
}),
|
|
4516
4844
|
);
|
|
@@ -4574,6 +4902,8 @@ function driveRunInner(
|
|
|
4574
4902
|
},
|
|
4575
4903
|
flowTimezone,
|
|
4576
4904
|
run.steps.indexOf(step),
|
|
4905
|
+
siblingsOf(step),
|
|
4906
|
+
peerBus,
|
|
4577
4907
|
).finally(() => reserved.delete(step));
|
|
4578
4908
|
// runStep mutates step.status; read it through a widened local
|
|
4579
4909
|
// so TS doesn't keep the "running" narrowing from above.
|
|
@@ -4668,6 +4998,13 @@ function driveRunInner(
|
|
|
4668
4998
|
// runs out of cycles below, which used to fail the run without ever
|
|
4669
4999
|
// reaching the step's on-fail (medium-desk's claim-trimmer was
|
|
4670
5000
|
// written for exactly that case and could never run).
|
|
5001
|
+
// A rescue hears a stop the way a group step does: its model loop and
|
|
5002
|
+
// its executor's wait for room both poll this (no sandbox exists yet
|
|
5003
|
+
// for a stop to destroy while it waits).
|
|
5004
|
+
const stopPoll = () => {
|
|
5005
|
+
save();
|
|
5006
|
+
return run.stopRequested === true;
|
|
5007
|
+
};
|
|
4671
5008
|
const rescueStep = async (step: StepRecord): Promise<void> => {
|
|
4672
5009
|
if (!step.onFail) return;
|
|
4673
5010
|
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
@@ -4679,6 +5016,38 @@ function driveRunInner(
|
|
|
4679
5016
|
});
|
|
4680
5017
|
return;
|
|
4681
5018
|
}
|
|
5019
|
+
// One rescue per failure, across drives. A worker that died
|
|
5020
|
+
// mid-rescue left an unfinished record behind it; the drive that
|
|
5021
|
+
// resumes picks that one up instead of splicing another — a crash
|
|
5022
|
+
// loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
|
|
5023
|
+
// ~80). The leftover carries the same instruction, built from the
|
|
5024
|
+
// same failure, and a running one may still hold a sandbox to
|
|
5025
|
+
// re-attach to, so it is THE rescue; any further unfinished copies
|
|
5026
|
+
// are superseded. A FINISHED rescue is history, never reused: it
|
|
5027
|
+
// answered an earlier failure — an evaluator loop's previous cycle
|
|
5028
|
+
// — not this one.
|
|
5029
|
+
const leftovers: StepRecord[] = [];
|
|
5030
|
+
for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
|
|
5031
|
+
const r = run.steps[j];
|
|
5032
|
+
if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
|
|
5033
|
+
leftovers.push(r);
|
|
5034
|
+
}
|
|
5035
|
+
}
|
|
5036
|
+
const resumed = leftovers[0];
|
|
5037
|
+
for (const r of leftovers.slice(1)) {
|
|
5038
|
+
r.status = "skipped";
|
|
5039
|
+
r.skipReason = "superseded on resume — one rescue per failure";
|
|
5040
|
+
}
|
|
5041
|
+
if (resumed) {
|
|
5042
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
|
|
5043
|
+
resumed.rescueOf ??= step.agent;
|
|
5044
|
+
resumed.attempts = Math.max(1, resumed.attempts ?? 0);
|
|
5045
|
+
resumed.status = "running";
|
|
5046
|
+
save();
|
|
5047
|
+
await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
|
|
5048
|
+
settleRescue(step, resumed);
|
|
5049
|
+
return;
|
|
5050
|
+
}
|
|
4682
5051
|
step.events.push({
|
|
4683
5052
|
t: new Date().toISOString(),
|
|
4684
5053
|
type: "info",
|
|
@@ -4695,6 +5064,7 @@ function driveRunInner(
|
|
|
4695
5064
|
const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
|
|
4696
5065
|
const rescue: StepRecord = {
|
|
4697
5066
|
agent: step.onFail,
|
|
5067
|
+
rescueOf: step.agent,
|
|
4698
5068
|
instruction:
|
|
4699
5069
|
`${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
|
|
4700
5070
|
`Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
|
|
@@ -4725,7 +5095,11 @@ function driveRunInner(
|
|
|
4725
5095
|
rescue.attempts = 1;
|
|
4726
5096
|
rescue.status = "running";
|
|
4727
5097
|
save();
|
|
4728
|
-
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true,
|
|
5098
|
+
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
|
|
5099
|
+
settleRescue(step, rescue);
|
|
5100
|
+
};
|
|
5101
|
+
// What a rescue's end means for the step it took over.
|
|
5102
|
+
const settleRescue = (step: StepRecord, rescue: StepRecord): void => {
|
|
4729
5103
|
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
4730
5104
|
const rescueOutcome: string = rescue.status;
|
|
4731
5105
|
if (rescueOutcome === "completed") {
|
|
@@ -4757,7 +5131,10 @@ function driveRunInner(
|
|
|
4757
5131
|
// here: the person chose to start after it. Counting it stopped
|
|
4758
5132
|
// fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
|
|
4759
5133
|
// a second, before the one step it was asked to run.
|
|
4760
|
-
|
|
5134
|
+
// A rescue record's own failure is already on the step it took over
|
|
5135
|
+
// ("also failed"); counted here it would fail a group whose step a
|
|
5136
|
+
// later rescue completed.
|
|
5137
|
+
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
|
|
4761
5138
|
if (requiredFailed) {
|
|
4762
5139
|
run.status = "failed";
|
|
4763
5140
|
break;
|
|
@@ -4833,6 +5210,10 @@ function driveRunInner(
|
|
|
4833
5210
|
});
|
|
4834
5211
|
for (const s of [...ordered[gi - 1], ...freshGroup]) {
|
|
4835
5212
|
if (s.status === "skipped" && s.each) continue; // an expanded template stays expanded
|
|
5213
|
+
// A rescue answered its own failure and stays as it ended: reset
|
|
5214
|
+
// to pending it read as an interrupted rescue, and the next
|
|
5215
|
+
// cycle's failure was handed the old one's instructions.
|
|
5216
|
+
if (isRescueRecord(s)) continue;
|
|
4836
5217
|
s.status = "pending";
|
|
4837
5218
|
s.result = null;
|
|
4838
5219
|
}
|
|
@@ -5073,7 +5454,14 @@ export interface Reconciliation {
|
|
|
5073
5454
|
* outlives any process. A run whose person already answered is not waiting,
|
|
5074
5455
|
* and is picked back up — see below.
|
|
5075
5456
|
*/
|
|
5076
|
-
|
|
5457
|
+
/** Told when a run reconcile closed, or resumed and saw to its end — so the
|
|
5458
|
+
* platform settles, notifies and chains it as it would a run its worker
|
|
5459
|
+
* drove (a run closed here had spent tokens, and nobody was charged or
|
|
5460
|
+
* told). Not told when a resumed run parks again: whatever resumes it next
|
|
5461
|
+
* goes through the worker. */
|
|
5462
|
+
export type ReconcileEnd = (tenant: string, workspace: string, runId: string) => void | Promise<void>;
|
|
5463
|
+
|
|
5464
|
+
export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[] {
|
|
5077
5465
|
const closed: Reconciliation[] = [];
|
|
5078
5466
|
|
|
5079
5467
|
for (const workspace of listWorkspaces(tenant)) {
|
|
@@ -5108,7 +5496,12 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
|
|
|
5108
5496
|
// Park at any *later* gate rather than poll: reconcile runs inside
|
|
5109
5497
|
// long-lived server processes, where a 24h file-poll is exactly the
|
|
5110
5498
|
// slot-burning the queue exists to avoid.
|
|
5111
|
-
void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
|
|
5499
|
+
void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
|
|
5500
|
+
.then(() => {
|
|
5501
|
+
const after = readRun(tenant, workspace.name, run.id);
|
|
5502
|
+
if (onEnd && after && (after.status === "completed" || after.status === "failed")) return onEnd(tenant, workspace.name, run.id);
|
|
5503
|
+
})
|
|
5504
|
+
.catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after resume:`, err));
|
|
5112
5505
|
closed.push({
|
|
5113
5506
|
runId: run.id,
|
|
5114
5507
|
tenant,
|
|
@@ -5153,6 +5546,11 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
|
|
|
5153
5546
|
// a sandbox we cannot reach must not stop the reconcile
|
|
5154
5547
|
}
|
|
5155
5548
|
closed.push({ runId: run.id, tenant, workspace: workspace.name, action: "closed", interrupted });
|
|
5549
|
+
if (onEnd) {
|
|
5550
|
+
void Promise.resolve()
|
|
5551
|
+
.then(() => onEnd(tenant, workspace.name, run.id))
|
|
5552
|
+
.catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after close:`, err));
|
|
5553
|
+
}
|
|
5156
5554
|
}
|
|
5157
5555
|
}
|
|
5158
5556
|
|
|
@@ -5166,8 +5564,8 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
|
|
|
5166
5564
|
* one hard-coded account left every other account's interrupted runs marked
|
|
5167
5565
|
* `running` forever, with no process left to finish them.
|
|
5168
5566
|
*/
|
|
5169
|
-
export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string
|
|
5170
|
-
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
|
|
5567
|
+
export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[] {
|
|
5568
|
+
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live, onEnd));
|
|
5171
5569
|
}
|
|
5172
5570
|
|
|
5173
5571
|
/**
|