@foldrun/core 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- package/dist/src/agent-import.js +1 -1
- package/dist/src/agent-tools.js +1 -1
- package/dist/src/agents-md.js +1 -1
- 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 +1 -0
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +170 -11
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +5 -1
- package/dist/src/evals.js +20 -6
- package/dist/src/frontmatter-fields.js +9 -0
- package/dist/src/frontmatter-schema.js +3 -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/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 +8 -0
- package/dist/src/matter.js +28 -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 -0
- package/dist/src/network.js +25 -1
- 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 +1 -1
- 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 +1 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +16 -2
- package/dist/src/run-container.js +57 -5
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +356 -70
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +19 -1
- package/dist/src/scan.js +60 -7
- package/dist/src/starter.js +4 -2
- package/dist/src/step-exec.d.ts +29 -1
- package/dist/src/step-exec.js +106 -6
- package/dist/src/store.d.ts +130 -3
- package/dist/src/store.js +145 -6
- 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 +1 -1
- package/src/agent-tools.ts +1 -1
- package/src/agents-md.ts +1 -1
- 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 +1 -0
- package/src/deploy.ts +184 -10
- package/src/egress.ts +12 -2
- package/src/evals.ts +19 -6
- package/src/frontmatter-fields.ts +9 -0
- package/src/frontmatter-schema.ts +3 -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/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +33 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +25 -1
- 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 +1 -1
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +1 -1
- package/src/providers.ts +7 -2
- package/src/run-container.ts +66 -7
- package/src/runner.ts +363 -46
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +68 -7
- package/src/starter.ts +4 -2
- package/src/step-exec.ts +147 -9
- package/src/store.ts +254 -8
- 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 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,7 @@ 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";
|
|
21
24
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
22
25
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
|
|
23
26
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
|
|
@@ -33,7 +36,7 @@ const isolatedRun = () => {
|
|
|
33
36
|
};
|
|
34
37
|
import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
|
|
35
38
|
import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools, type SubagentSpec } from "./subagents.ts";
|
|
36
|
-
import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.ts";
|
|
39
|
+
import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine, type PeerInfo } from "./operator.ts";
|
|
37
40
|
import { sendPlainNotification } from "./notify.ts";
|
|
38
41
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.ts";
|
|
39
42
|
import { refNames } from "./refs.ts";
|
|
@@ -83,9 +86,10 @@ import {
|
|
|
83
86
|
runCost,
|
|
84
87
|
} from "./store.ts";
|
|
85
88
|
import { canReadBack } from "./tool-output.ts";
|
|
89
|
+
import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
|
|
86
90
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
|
|
87
91
|
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
|
|
88
|
-
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
92
|
+
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
89
93
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
|
|
90
94
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
|
|
91
95
|
import { attachOperations, prefetchOpenApi } from "./openapi.ts";
|
|
@@ -210,6 +214,101 @@ export function slotPool(size: number): { acquire: () => Promise<void>; release:
|
|
|
210
214
|
};
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
/** Steps whose model has begun its first response — the step's executor
|
|
218
|
+
* says so the moment the stream starts (EventExtra.responding). Memory
|
|
219
|
+
* only: it is scheduling, not something the run record needs. */
|
|
220
|
+
const respondingSteps = new WeakSet<StepRecord>();
|
|
221
|
+
export function markResponding(step: StepRecord): void {
|
|
222
|
+
respondingSteps.add(step);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Is this step's prompt in the cache yet? An entry is readable from the
|
|
227
|
+
* moment the response to the request that wrote it begins streaming
|
|
228
|
+
* (Anthropic's prompt-caching docs), which is what `responding` marks.
|
|
229
|
+
* The first text, tool call or recorded model call says the same a little
|
|
230
|
+
* later, for an executor that cannot see the stream (the stub, an older
|
|
231
|
+
* driver). Resolves on the first of those, or when the step is over
|
|
232
|
+
* without one (a refusal before the first turn, a failed start).
|
|
233
|
+
*/
|
|
234
|
+
export function untilFirstReply(step: StepRecord, done: Promise<unknown>, pollMs = 250): Promise<void> {
|
|
235
|
+
const replied = () =>
|
|
236
|
+
respondingSteps.has(step) || step.events.some((e) => e.type === "text" || e.type === "tool") || (step.calls?.length ?? 0) > 0;
|
|
237
|
+
return new Promise<void>((resolve) => {
|
|
238
|
+
let finished = false;
|
|
239
|
+
const stop = () => {
|
|
240
|
+
finished = true;
|
|
241
|
+
resolve();
|
|
242
|
+
};
|
|
243
|
+
done.then(stop, stop);
|
|
244
|
+
const tick = () => {
|
|
245
|
+
if (finished) return;
|
|
246
|
+
if (replied()) return stop();
|
|
247
|
+
setTimeout(tick, pollMs).unref?.();
|
|
248
|
+
};
|
|
249
|
+
tick();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Steps of one agent launched together share a prompt prefix — tools and
|
|
255
|
+
* system prompt — and that is what the model's cache keeps. The cache is
|
|
256
|
+
* written by the first request that carries a prefix and readable only
|
|
257
|
+
* once that response has begun, so N copies started at once all write it
|
|
258
|
+
* and none reads: a fan-out of twenty paid twenty cold prefixes. The gate
|
|
259
|
+
* lets the first instance of each agent go alone and holds its siblings
|
|
260
|
+
* until its first response has begun (or it ended); then they all start
|
|
261
|
+
* and read what it wrote — Claude Code holds a workflow's fan-out the same
|
|
262
|
+
* way. Not a fixed delay: a sandbox takes seconds to start before its
|
|
263
|
+
* first model call, so a timer from launch would release the siblings
|
|
264
|
+
* before there was anything to read. Keyed by agent and model, since a
|
|
265
|
+
* cache is per model.
|
|
266
|
+
*/
|
|
267
|
+
export function warmGate(launching: StepRecord[], pollMs = 250) {
|
|
268
|
+
const key = (s: StepRecord) => `${s.agent}\u0000${s.model ?? ""}`;
|
|
269
|
+
const counts = new Map<string, number>();
|
|
270
|
+
for (const s of launching) counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
|
|
271
|
+
const leads = new Map<string, { opened: Promise<void>; open: () => void }>();
|
|
272
|
+
const isLead = new WeakSet<StepRecord>();
|
|
273
|
+
return {
|
|
274
|
+
/** Claim the step's place — synchronously, before anything awaits, or
|
|
275
|
+
* the copies that reach their first await together would all find no
|
|
276
|
+
* lead and all go (a `parallel:` pool's acquire was exactly that).
|
|
277
|
+
* The first of its agent leads and gets null; a later one gets the
|
|
278
|
+
* lead's gate to await before it starts; a lone step gets null too. */
|
|
279
|
+
claim(step: StepRecord): Promise<void> | null {
|
|
280
|
+
const k = key(step);
|
|
281
|
+
if ((counts.get(k) ?? 0) < 2) return null;
|
|
282
|
+
const lead = leads.get(k);
|
|
283
|
+
if (lead) return lead.opened;
|
|
284
|
+
let open!: () => void;
|
|
285
|
+
const opened = new Promise<void>((r) => (open = r));
|
|
286
|
+
leads.set(k, { opened, open });
|
|
287
|
+
isLead.add(step);
|
|
288
|
+
return null;
|
|
289
|
+
},
|
|
290
|
+
/** A lead that will not run after all (the run was stopped while it
|
|
291
|
+
* waited for a pool slot) still opens its gate — or its siblings,
|
|
292
|
+
* which also wake to the stop, would wait on it forever. */
|
|
293
|
+
release(step: StepRecord): void {
|
|
294
|
+
if (isLead.has(step)) leads.get(key(step))!.open();
|
|
295
|
+
},
|
|
296
|
+
/** Run the step; a lead opens its agent's gate on its first reply (or
|
|
297
|
+
* its end), whatever else happens to it. */
|
|
298
|
+
async run(step: StepRecord, launch: () => Promise<void>): Promise<void> {
|
|
299
|
+
if (!isLead.has(step)) return launch();
|
|
300
|
+
const { open } = leads.get(key(step))!;
|
|
301
|
+
const done = launch();
|
|
302
|
+
try {
|
|
303
|
+
await untilFirstReply(step, done, pollMs);
|
|
304
|
+
} finally {
|
|
305
|
+
open();
|
|
306
|
+
}
|
|
307
|
+
await done;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
213
312
|
export interface DiscoveredSkill {
|
|
214
313
|
name: string;
|
|
215
314
|
description: string;
|
|
@@ -235,12 +334,9 @@ const toTags = (v: unknown): string[] =>
|
|
|
235
334
|
// The Agent Skills client guide recommends quoting such values and retrying
|
|
236
335
|
// rather than dropping the skill, so a cross-client skill still loads here.
|
|
237
336
|
function safeMatter(raw: string): { data: Record<string, unknown> } {
|
|
238
|
-
// gray-matter caches
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// the throw deterministic, so the fallback below fires every time rather than
|
|
242
|
-
// once — otherwise the same skill loads on one run and vanishes on the next.
|
|
243
|
-
(matter as unknown as { clearCache: () => void }).clearCache();
|
|
337
|
+
// gray-matter caches a *failed* parse as a hollow success; ./matter.ts
|
|
338
|
+
// parses every call, so the throw below is deterministic and the fallback
|
|
339
|
+
// fires every time (it used to clear gray-matter's cache here for that).
|
|
244
340
|
try {
|
|
245
341
|
return matter(raw) as { data: Record<string, unknown> };
|
|
246
342
|
} catch {
|
|
@@ -427,6 +523,10 @@ export function workspaceRuntimePlan(
|
|
|
427
523
|
return [...byFp.values()];
|
|
428
524
|
}
|
|
429
525
|
|
|
526
|
+
/** The most tool content one step records (FOL-54 step 4): about 60 calls
|
|
527
|
+
* at the per-part cap; past it, metadata only. */
|
|
528
|
+
const STEP_CAPTURE_LIMIT = 4_000_000;
|
|
529
|
+
|
|
430
530
|
function workspaceFrontmatter(agentDir: string, tenant: string): Record<string, unknown> {
|
|
431
531
|
return {
|
|
432
532
|
...(readAgentsMd(accountDir(tenant))?.data ?? {}),
|
|
@@ -758,7 +858,11 @@ export function agentContext(
|
|
|
758
858
|
const { data: front, content: body } = matter(
|
|
759
859
|
fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"),
|
|
760
860
|
);
|
|
761
|
-
|
|
861
|
+
// The prompt's sections, by name and by how often they change. Added in
|
|
862
|
+
// the order they are computed; assembled stable first, so the model's
|
|
863
|
+
// cache keeps the agent's half across steps and runs (prompt-layers.ts).
|
|
864
|
+
const parts = new PromptLayers();
|
|
865
|
+
parts.add("persona", resolveDocLinks(body.trim(), path.resolve(agentDir, "..", "..")));
|
|
762
866
|
|
|
763
867
|
// Where the agent is standing. Every path below this line is relative to the
|
|
764
868
|
// agent's own directory, and a run showed why that has to be said rather
|
|
@@ -780,14 +884,14 @@ export function agentContext(
|
|
|
780
884
|
const languageSet = language.from !== "default" && language.from !== "env";
|
|
781
885
|
const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
|
|
782
886
|
if (languageSet || regionSet) {
|
|
783
|
-
parts.
|
|
887
|
+
parts.add("locale",
|
|
784
888
|
`# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
|
|
785
889
|
`Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
|
|
786
890
|
`use these currency, units and calendar conventions in anything you produce. ` +
|
|
787
891
|
`Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`,
|
|
788
892
|
);
|
|
789
893
|
}
|
|
790
|
-
parts.
|
|
894
|
+
parts.add("where",
|
|
791
895
|
`# Where you are\n\n` +
|
|
792
896
|
`Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
|
|
793
897
|
`\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
|
|
@@ -832,7 +936,7 @@ export function agentContext(
|
|
|
832
936
|
// Shared context before anything derived — an account or workspace rule is
|
|
833
937
|
// background the rest of the prompt is read against, not an afterthought.
|
|
834
938
|
const sharedContext = sharedInstructions(agentDir, tenant);
|
|
835
|
-
if (sharedContext) parts.
|
|
939
|
+
if (sharedContext) parts.add("agents.md", sharedContext);
|
|
836
940
|
|
|
837
941
|
// Skills follow the open Agent Skills format (agentskills.io): a folder
|
|
838
942
|
// with SKILL.md, optionally bundling its own scripts/ and references/. The
|
|
@@ -871,7 +975,7 @@ export function agentContext(
|
|
|
871
975
|
(bundles.length ? ` (also bundles ${bundles.join(", ")})` : "")
|
|
872
976
|
);
|
|
873
977
|
});
|
|
874
|
-
parts.
|
|
978
|
+
parts.add("skills",
|
|
875
979
|
`# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
|
|
876
980
|
`read its file for the full instructions before proceeding:\n\n${lines.join("\n")}` +
|
|
877
981
|
(withheld ? `\n\n(${withheld} further skills exist but do not apply to this run.)` : ""),
|
|
@@ -893,11 +997,13 @@ export function agentContext(
|
|
|
893
997
|
: "";
|
|
894
998
|
const projectMem = workspaceMemoryIndex(tenant, workspace);
|
|
895
999
|
const shared = libraryMemoryIndex(tenant);
|
|
896
|
-
|
|
1000
|
+
// Dynamic: agents write memory, so the index moves between runs.
|
|
1001
|
+
parts.add("memory",
|
|
897
1002
|
`# 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
1003
|
(projectMem ? `\n\nShared across this workspace:\n\n${projectMem}` : "") +
|
|
899
1004
|
(shared ? `\n\nShared across every workspace:\n\n${shared}` : "") +
|
|
900
1005
|
howTo,
|
|
1006
|
+
{ dynamic: true },
|
|
901
1007
|
);
|
|
902
1008
|
}
|
|
903
1009
|
|
|
@@ -914,7 +1020,7 @@ export function agentContext(
|
|
|
914
1020
|
projectKnowledge && `Shared across this workspace:\n\n${projectKnowledge}`,
|
|
915
1021
|
sharedKnowledge && `Shared across every workspace:\n\n${sharedKnowledge}`,
|
|
916
1022
|
].filter(Boolean);
|
|
917
|
-
parts.
|
|
1023
|
+
parts.add("knowledge",
|
|
918
1024
|
`# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
|
|
919
1025
|
`on it rather than guessing at its contents. This is read-only: it is maintained by ` +
|
|
920
1026
|
`people, not by you — if you learn something new, write it to memory/ instead.\n\n` +
|
|
@@ -947,7 +1053,7 @@ export function agentContext(
|
|
|
947
1053
|
shared.length ? `Shared across this workspace:\n${shared.join("\n")}` : null,
|
|
948
1054
|
global.length ? `From the account library (read-only):\n${global.join("\n")}` : null,
|
|
949
1055
|
].filter(Boolean);
|
|
950
|
-
parts.
|
|
1056
|
+
parts.add("scripts",
|
|
951
1057
|
`# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
|
|
952
1058
|
`Read a script before running it if you are unsure what it does. Credentials your ` +
|
|
953
1059
|
`scripts need are already in the environment as the secrets listed below.`,
|
|
@@ -965,7 +1071,8 @@ export function agentContext(
|
|
|
965
1071
|
// file to size it against, not before.
|
|
966
1072
|
const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
|
|
967
1073
|
if (state.length) {
|
|
968
|
-
|
|
1074
|
+
// Dynamic: runs add and remove state files.
|
|
1075
|
+
parts.add("state",
|
|
969
1076
|
`# State — what you carry between runs\n\n` +
|
|
970
1077
|
`What this workspace keeps across runs: a count, a cursor, where you got to. ` +
|
|
971
1078
|
`Read and write these directly, and update them before you finish so the next ` +
|
|
@@ -975,6 +1082,7 @@ export function agentContext(
|
|
|
975
1082
|
`same editor as everything else — data formats are allowed here, not preferred.\n\n` +
|
|
976
1083
|
`This is not memory. A fact you learned goes in memory/, where it is indexed and ` +
|
|
977
1084
|
`its provenance recorded — state is the bookmark, not the lesson.`,
|
|
1085
|
+
{ dynamic: true },
|
|
978
1086
|
);
|
|
979
1087
|
}
|
|
980
1088
|
|
|
@@ -1057,7 +1165,7 @@ export function agentContext(
|
|
|
1057
1165
|
const keepIn = { dir: agentDir, readable: () => readsFiles };
|
|
1058
1166
|
const apiTools = buildApiTools(tenant, apis, workspace, undefined, keepIn);
|
|
1059
1167
|
if (apiTools.promptLines.length) {
|
|
1060
|
-
parts.
|
|
1168
|
+
parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
|
|
1061
1169
|
}
|
|
1062
1170
|
|
|
1063
1171
|
// Secrets an agent declared become env vars for its scripts and bash.
|
|
@@ -1214,6 +1322,10 @@ export function agentContext(
|
|
|
1214
1322
|
name: spec.name,
|
|
1215
1323
|
maxTokensParam: preset?.maxTokensParam,
|
|
1216
1324
|
reasoningEffort: preset?.reasoningEffort,
|
|
1325
|
+
// The agent's prompts share an opening; the key says so to an
|
|
1326
|
+
// endpoint that routes its cache by one. Only where the preset
|
|
1327
|
+
// says the parameter is understood — elsewhere it is a 400.
|
|
1328
|
+
cacheKey: preset?.promptCacheKey ? promptCacheKey(tenant, workspace, path.basename(agentDir)) : undefined,
|
|
1217
1329
|
});
|
|
1218
1330
|
// What the run is actually pointed at, in one line: the endpoint, the
|
|
1219
1331
|
// tiers this gateway renames, and whether the credential landed.
|
|
@@ -1404,12 +1516,15 @@ export function agentContext(
|
|
|
1404
1516
|
exec,
|
|
1405
1517
|
keepIn.readable,
|
|
1406
1518
|
);
|
|
1407
|
-
|
|
1519
|
+
// Dynamic: the date moves daily, and would otherwise invalidate every
|
|
1520
|
+
// layer behind it once a day.
|
|
1521
|
+
parts.add("clock",
|
|
1408
1522
|
`# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
|
|
1409
1523
|
`write or check — it is the workspace's calendar, not the container's clock.`,
|
|
1524
|
+
{ dynamic: true },
|
|
1410
1525
|
);
|
|
1411
1526
|
if (scriptTools.promptLines.length) {
|
|
1412
|
-
parts.
|
|
1527
|
+
parts.add("script-tools",
|
|
1413
1528
|
`# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
|
|
1414
1529
|
`\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`,
|
|
1415
1530
|
);
|
|
@@ -1420,20 +1535,20 @@ export function agentContext(
|
|
|
1420
1535
|
// reddit-desk validator step on 6 Oct 2026. Say it before the first call.
|
|
1421
1536
|
if (builtins.length) {
|
|
1422
1537
|
const named = builtins.map((b) => b.split("="));
|
|
1423
|
-
parts.
|
|
1538
|
+
parts.add("web",
|
|
1424
1539
|
`# Web search and fetch on this run\n\n` +
|
|
1425
1540
|
named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
|
|
1426
1541
|
`\n\nThe web tool's other actions work as usual.`,
|
|
1427
1542
|
);
|
|
1428
1543
|
}
|
|
1429
1544
|
if (declared.length) {
|
|
1430
|
-
parts.
|
|
1545
|
+
parts.add("secrets",
|
|
1431
1546
|
`# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
|
|
1432
1547
|
`Never print, echo, or write them to a file.`,
|
|
1433
1548
|
);
|
|
1434
1549
|
}
|
|
1435
1550
|
|
|
1436
|
-
parts.
|
|
1551
|
+
parts.add("rules",
|
|
1437
1552
|
"Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
|
|
1438
1553
|
"what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
|
|
1439
1554
|
"for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
|
|
@@ -1453,7 +1568,7 @@ export function agentContext(
|
|
|
1453
1568
|
// run, and an agent that opened with "I'll start by cloning the repo" made
|
|
1454
1569
|
// all three useless. Asking here rather than in each agent.md means every
|
|
1455
1570
|
// agent obeys it, including the ones written before the summary existed.
|
|
1456
|
-
parts.
|
|
1571
|
+
parts.add("rules",
|
|
1457
1572
|
"Open your reply with one sentence saying what you concluded or produced — the outcome, " +
|
|
1458
1573
|
"not your plan. Everything else follows it. That sentence is what a person sees in the " +
|
|
1459
1574
|
"run list and in the notification, without opening the run.",
|
|
@@ -1500,6 +1615,7 @@ export function agentContext(
|
|
|
1500
1615
|
let wantHistory = false;
|
|
1501
1616
|
let wantDesks = false;
|
|
1502
1617
|
let wantAsk = false;
|
|
1618
|
+
let wantPeers = false;
|
|
1503
1619
|
|
|
1504
1620
|
for (const ref of toolRefs(front)) {
|
|
1505
1621
|
const toolName = ref.name;
|
|
@@ -1532,12 +1648,13 @@ export function agentContext(
|
|
|
1532
1648
|
} else if (TOOL_MAP[toolName]) {
|
|
1533
1649
|
allowed.push(...TOOL_MAP[toolName]);
|
|
1534
1650
|
if (available[toolName]) shadowed.push(toolName);
|
|
1535
|
-
} else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
|
|
1651
|
+
} else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask" || toolName === "peers") {
|
|
1536
1652
|
// The platform's own groups, served in-process — built below, once
|
|
1537
1653
|
// the roots and the records are known.
|
|
1538
1654
|
if (toolName === "search") wantSearch = true;
|
|
1539
1655
|
else if (toolName === "history") wantHistory = true;
|
|
1540
1656
|
else if (toolName === "ask") wantAsk = true;
|
|
1657
|
+
else if (toolName === "peers") wantPeers = true;
|
|
1541
1658
|
else wantDesks = true;
|
|
1542
1659
|
if (available[toolName]) shadowed.push(toolName);
|
|
1543
1660
|
} else if (toolName === "install") {
|
|
@@ -1593,10 +1710,16 @@ export function agentContext(
|
|
|
1593
1710
|
const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
|
|
1594
1711
|
if (ask) {
|
|
1595
1712
|
allowed.push(ASK_TOOL);
|
|
1596
|
-
parts.
|
|
1713
|
+
parts.add("ask", `# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
|
|
1597
1714
|
}
|
|
1715
|
+
// tools: [peers] — peers_list and peers_message, to the other steps of
|
|
1716
|
+
// this step's parallel group. Who they are is known only when the group
|
|
1717
|
+
// launches, so the grant is here and the roster, the server and the
|
|
1718
|
+
// prompt's line are the step's (runStep).
|
|
1719
|
+
const peers = wantPeers;
|
|
1720
|
+
if (peers) allowed.push(...PEER_TOOLS);
|
|
1598
1721
|
if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
|
|
1599
|
-
parts.
|
|
1722
|
+
parts.add("finding", `# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
|
|
1600
1723
|
}
|
|
1601
1724
|
// An MCP server declares its tools when it connects, so they can't be listed
|
|
1602
1725
|
// ahead of time. Server-level specs cover them: granting the server grants
|
|
@@ -1618,6 +1741,7 @@ export function agentContext(
|
|
|
1618
1741
|
if (denied.includes("install")) denied.push(INSTALL_TOOL);
|
|
1619
1742
|
const finalAllowed = allowed.filter((t) => !denied.includes(t));
|
|
1620
1743
|
readsFiles = canReadBack(finalAllowed);
|
|
1744
|
+
const prompt = parts.assemble();
|
|
1621
1745
|
|
|
1622
1746
|
return {
|
|
1623
1747
|
front,
|
|
@@ -1625,7 +1749,9 @@ export function agentContext(
|
|
|
1625
1749
|
/** The zone, where it came from, and anything unreadable on the way —
|
|
1626
1750
|
* reported by the step, not here, so it lands in the run's trace. */
|
|
1627
1751
|
clock,
|
|
1628
|
-
systemPrompt:
|
|
1752
|
+
systemPrompt: prompt.text,
|
|
1753
|
+
/** What the record keeps of each layer of that prompt. */
|
|
1754
|
+
promptLayers: prompt.layers,
|
|
1629
1755
|
allowed: finalAllowed,
|
|
1630
1756
|
disabled,
|
|
1631
1757
|
unknownTools,
|
|
@@ -1649,6 +1775,7 @@ export function agentContext(
|
|
|
1649
1775
|
historyTools,
|
|
1650
1776
|
deskTools,
|
|
1651
1777
|
ask,
|
|
1778
|
+
peers,
|
|
1652
1779
|
// `spec` is the MERGED declaration — the agent's own block or its
|
|
1653
1780
|
// workspace's, plus every granted tool's. The isolated path builds its
|
|
1654
1781
|
// runtime from this; passing the agent's frontmatter alone silently drops
|
|
@@ -1906,6 +2033,12 @@ async function runStep(
|
|
|
1906
2033
|
* person's message to this step reaches this step's own inbox and not a
|
|
1907
2034
|
* sibling's running in the same group. Undefined outside a flow. */
|
|
1908
2035
|
stepIndex?: number,
|
|
2036
|
+
/** The other steps launched with this one (its parallel group), for
|
|
2037
|
+
* tools: [peers]. Empty when it runs alone. */
|
|
2038
|
+
siblings: PeerInfo[] = [],
|
|
2039
|
+
/** The in-process colleagues' bus for this group (localPeerBus), when
|
|
2040
|
+
* the step runs in this process rather than a pod. */
|
|
2041
|
+
peerBus?: ReturnType<typeof localPeerBus>,
|
|
1909
2042
|
) {
|
|
1910
2043
|
// Secret values are injected into scripts as environment variables and
|
|
1911
2044
|
// substituted into API headers, so a model that reads one back — from a
|
|
@@ -1952,9 +2085,54 @@ async function runStep(
|
|
|
1952
2085
|
}
|
|
1953
2086
|
};
|
|
1954
2087
|
|
|
2088
|
+
// What this workspace records of each step (FOL-54 step 4): `capture:` in
|
|
2089
|
+
// AGENTS.md — every model call always; each tool call's input and output
|
|
2090
|
+
// unless it says `metadata`.
|
|
2091
|
+
const capture: "metadata" | "full" = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
|
|
2092
|
+
// A step that pours out content stops being recorded past this — its
|
|
2093
|
+
// calls keep their names, sizes and times (FOL-54 step 4).
|
|
2094
|
+
let capturedChars = 0;
|
|
2095
|
+
let captureFull = false;
|
|
1955
2096
|
const push = (type: StepRecord["events"][number]["type"], text: string, extra?: EventExtra) => {
|
|
1956
2097
|
const t = new Date().toISOString();
|
|
1957
|
-
|
|
2098
|
+
// The model has begun its first response: held only in memory, for
|
|
2099
|
+
// the fan-out gate (warmGate). Not a line in the trace.
|
|
2100
|
+
if (extra?.responding) {
|
|
2101
|
+
markResponding(step);
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
// A model call is a span on the step, not a line in its trace.
|
|
2105
|
+
// Numbered here, across attempts: a retry's calls follow the first
|
|
2106
|
+
// attempt's, and an id-less turn of one attempt never overwrites
|
|
2107
|
+
// another's. No save of its own — a turn's blocks arrive as several
|
|
2108
|
+
// messages, and the next event saves them all.
|
|
2109
|
+
if (extra?.turn) {
|
|
2110
|
+
const calls = (step.calls ??= []);
|
|
2111
|
+
// The loop counts attempts from 1; a call of the first carries none.
|
|
2112
|
+
const attempt = Math.max(1, step.attempts ?? 1);
|
|
2113
|
+
const at = calls.findIndex((c) => c.id === extra.turn!.id && (c.attempt ?? 1) === attempt);
|
|
2114
|
+
const call = { ...extra.turn, n: at >= 0 ? calls[at]!.n : calls.length + 1, ...(attempt > 1 ? { attempt } : {}) };
|
|
2115
|
+
if (at >= 0) calls[at] = call;
|
|
2116
|
+
else calls.push(call);
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
// A tool call's content goes beside the run, masked (capture.ts), and the
|
|
2120
|
+
// event keeps only that it was recorded.
|
|
2121
|
+
let rest: EventExtra | undefined = extra;
|
|
2122
|
+
if (extra?.io) {
|
|
2123
|
+
const { io, ...others } = extra;
|
|
2124
|
+
rest = others;
|
|
2125
|
+
const size = (io.input?.length ?? 0) + (io.output?.length ?? 0);
|
|
2126
|
+
if (runId && extra.call && capture === "full" && capturedChars + size > STEP_CAPTURE_LIMIT && !captureFull) {
|
|
2127
|
+
captureFull = true;
|
|
2128
|
+
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` });
|
|
2129
|
+
}
|
|
2130
|
+
if (runId && extra.call && capture === "full" && !captureFull && recordCallIo(path.resolve(agentDir, "..", ".."), runId, extra.call, text, io, redact)) {
|
|
2131
|
+
capturedChars += size;
|
|
2132
|
+
rest = { ...others, captured: true };
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
step.events.push({ t, type, text: redact(text), ...rest });
|
|
1958
2136
|
// A person in the loop: the question, its answer, a message delivered —
|
|
1959
2137
|
// onto the step, so the run page, the API and history show them.
|
|
1960
2138
|
if (extra?.operator) {
|
|
@@ -1988,7 +2166,26 @@ async function runStep(
|
|
|
1988
2166
|
step.status = "completed";
|
|
1989
2167
|
// A stub answer may open with `cost: 0.25` — a pretend price, so the
|
|
1990
2168
|
// parts of orchestration that watch money (budget:) are testable too.
|
|
1991
|
-
|
|
2169
|
+
let answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
|
|
2170
|
+
// `call: <tool> <input> => <output>` lines: a pretend tool call, sent
|
|
2171
|
+
// through the same push a real step's events take — with its model call
|
|
2172
|
+
// and its content — so what the runner records of a step is testable.
|
|
2173
|
+
answer = answer
|
|
2174
|
+
.split("\n")
|
|
2175
|
+
.filter((line) => {
|
|
2176
|
+
const m = /^call: (\S+) (.*?) => (.*)$/.exec(line);
|
|
2177
|
+
if (!m) return true;
|
|
2178
|
+
const id = `toolu_stub${(step.calls?.length ?? 0) + 1}`;
|
|
2179
|
+
// Twice under one id, as the SDK sends a turn's blocks: one row.
|
|
2180
|
+
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 };
|
|
2181
|
+
push("info", "", { turn });
|
|
2182
|
+
push("info", "", { turn: { ...turn, stop: "tool_use", tools: 1 } });
|
|
2183
|
+
push("tool", m[1]!, { call: id, ...(capture ? { io: { input: m[2]!, inputChars: m[2]!.length } } : {}) });
|
|
2184
|
+
push("tool", m[1]!, { call: id, ms: 3, io: { output: m[3]!, outputChars: m[3]!.length } });
|
|
2185
|
+
return false;
|
|
2186
|
+
})
|
|
2187
|
+
.join("\n")
|
|
2188
|
+
.trim();
|
|
1992
2189
|
const priced = answer.match(/^cost:\s*([\d.]+)\n?/);
|
|
1993
2190
|
const body = (priced ? answer.slice(priced[0].length) : answer).trim();
|
|
1994
2191
|
// A `===` line inside an answer separates earlier turns from the final
|
|
@@ -2027,7 +2224,7 @@ async function runStep(
|
|
|
2027
2224
|
// step: the generic HTTP tool still works without the typed ones.
|
|
2028
2225
|
for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant))) push("error", `openapi: ${w}`);
|
|
2029
2226
|
const {
|
|
2030
|
-
front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools,
|
|
2227
|
+
front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools,
|
|
2031
2228
|
secretEnv, secretScopes, missingSecrets, missingTools, runtime,
|
|
2032
2229
|
unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames,
|
|
2033
2230
|
apiSpecs, scriptSpecs, brokenTools, size: agentSize,
|
|
@@ -2035,6 +2232,7 @@ async function runStep(
|
|
|
2035
2232
|
searchChoice, fetchChoice, actionChoices, web, language, region,
|
|
2036
2233
|
fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools,
|
|
2037
2234
|
ask,
|
|
2235
|
+
peers,
|
|
2038
2236
|
translator, fallbackTranslator,
|
|
2039
2237
|
outward,
|
|
2040
2238
|
} = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
@@ -2043,6 +2241,9 @@ async function runStep(
|
|
|
2043
2241
|
// Sent again on every turn; with turns and the tools' result sizes, what
|
|
2044
2242
|
// a token report reads to say where a step's input went.
|
|
2045
2243
|
step.systemPromptChars = systemPrompt.length;
|
|
2244
|
+
// And each layer's size and hash, so a later reader can say which part
|
|
2245
|
+
// of the prompt changed between this step and the agent's last.
|
|
2246
|
+
step.promptLayers = promptLayers;
|
|
2046
2247
|
// A retry that moved the step up a class (an evicted or OOM-killed
|
|
2047
2248
|
// attempt) wins over the agent's own `size:` for the attempts after.
|
|
2048
2249
|
const size = step.sizeUp ?? agentSize;
|
|
@@ -2062,11 +2263,7 @@ async function runStep(
|
|
|
2062
2263
|
// Populate before the first push: everything after this point may quote a
|
|
2063
2264
|
// credential. Short values are skipped — a two-character secret would
|
|
2064
2265
|
// 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);
|
|
2266
|
+
redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
|
|
2070
2267
|
|
|
2071
2268
|
// One line for the clock: which zone this step works to, and which level
|
|
2072
2269
|
// set it. "Why is this dated yesterday" should be answerable from the run.
|
|
@@ -2314,7 +2511,14 @@ async function runStep(
|
|
|
2314
2511
|
...mcpNames,
|
|
2315
2512
|
...(consults.length ? ["foldrun_agents"] : []),
|
|
2316
2513
|
...(ask ? ["foldrun_ask"] : []),
|
|
2514
|
+
...(peers ? ["foldrun_peers"] : []),
|
|
2317
2515
|
];
|
|
2516
|
+
if (peers) {
|
|
2517
|
+
// The colleagues' contract, in the prompt the step reads: who runs
|
|
2518
|
+
// beside it this time (none, when it runs alone), and that a message
|
|
2519
|
+
// is a request, never a hand-over.
|
|
2520
|
+
prompt += `\n\n<colleagues>\n${peersPromptLine(siblings)}\n</colleagues>`;
|
|
2521
|
+
}
|
|
2318
2522
|
|
|
2319
2523
|
// Colleagues this agent may DELEGATE to: each with its own context and
|
|
2320
2524
|
// its own tools — resolved exactly as its own step's would be, then cut
|
|
@@ -2483,7 +2687,24 @@ async function runStep(
|
|
|
2483
2687
|
],
|
|
2484
2688
|
});
|
|
2485
2689
|
grant.network = { level: network.level, hosts: network.hosts };
|
|
2486
|
-
|
|
2690
|
+
// network_enforce: true at the account or the workspace asks the proxy
|
|
2691
|
+
// to refuse; the proxy adds the install's own switch and has the last
|
|
2692
|
+
// word (egress-proxy.ts), so false here never loosens anything.
|
|
2693
|
+
grant.enforce = networkEnforceWanted(
|
|
2694
|
+
readAgentsMd(accountDir(tenant))?.data?.network_enforce,
|
|
2695
|
+
readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce,
|
|
2696
|
+
);
|
|
2697
|
+
const lease = await platform.egress.lease({
|
|
2698
|
+
tenant,
|
|
2699
|
+
runId: runId ?? "adhoc",
|
|
2700
|
+
grant,
|
|
2701
|
+
test: testRun,
|
|
2702
|
+
...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
|
|
2703
|
+
// tools: [peers] — the roster the proxy serves, and the inboxes a
|
|
2704
|
+
// colleague's message may be addressed to. Only when granted: a
|
|
2705
|
+
// step without the tools has no reason to know its siblings.
|
|
2706
|
+
...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
|
|
2707
|
+
});
|
|
2487
2708
|
// The forward door: every program in the sandbox sends through the
|
|
2488
2709
|
// proxy, which holds the site list and fills bound secrets. A proxy
|
|
2489
2710
|
// without the door (an older platform) leaves programs going direct.
|
|
@@ -2556,6 +2777,23 @@ async function runStep(
|
|
|
2556
2777
|
// carrying TEST_MODE_WITHHELD would only fail where it should have
|
|
2557
2778
|
// been sunk or refused.
|
|
2558
2779
|
const apiValues = testRun && lease ? placeholders : sandboxSecrets;
|
|
2780
|
+
// A declared secret named like the step's own model variables
|
|
2781
|
+
// (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, …) was spread over them below
|
|
2782
|
+
// and broke, or redirected, the step's model calls (FOL-19). It crosses
|
|
2783
|
+
// as FOLDRUN_DECLARED_<NAME> instead: the model keeps its settings, and
|
|
2784
|
+
// the driver gives the agent's API and script tools the value under its
|
|
2785
|
+
// own name (run-container.ts) — never the model's key in its place.
|
|
2786
|
+
const modelNamed = Object.keys(sandboxSecrets).filter(isModelEnvName);
|
|
2787
|
+
if (modelNamed.length) {
|
|
2788
|
+
push(
|
|
2789
|
+
"info",
|
|
2790
|
+
`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; ` +
|
|
2791
|
+
"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:",
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
const sandboxEnvSecrets = Object.fromEntries(
|
|
2795
|
+
Object.entries(sandboxSecrets).map(([name, value]) => [isModelEnvName(name) ? `${DECLARED_ENV_PREFIX}${name}` : name, value]),
|
|
2796
|
+
);
|
|
2559
2797
|
const substitutedApis = apiSpecs.map((api) => ({
|
|
2560
2798
|
...api,
|
|
2561
2799
|
headers: Object.fromEntries(
|
|
@@ -2612,6 +2850,7 @@ async function runStep(
|
|
|
2612
2850
|
// field. A tool's own `runtime:` is part of what this step needs.
|
|
2613
2851
|
runtime: runtime.spec,
|
|
2614
2852
|
consults,
|
|
2853
|
+
...(peers ? { peers: { self: step.agent, siblings } } : {}),
|
|
2615
2854
|
...(subagents.length ? { subagents } : {}),
|
|
2616
2855
|
timeoutSec: step.timeout,
|
|
2617
2856
|
budgetUsd: stepBudgetUsd,
|
|
@@ -2621,6 +2860,7 @@ async function runStep(
|
|
|
2621
2860
|
output: step.output,
|
|
2622
2861
|
schema: resolvedSchema ?? undefined,
|
|
2623
2862
|
maxTurns: step.maxTurns,
|
|
2863
|
+
capture,
|
|
2624
2864
|
...limitOpts,
|
|
2625
2865
|
...hookOpts,
|
|
2626
2866
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
@@ -2667,7 +2907,7 @@ async function runStep(
|
|
|
2667
2907
|
FOLDRUN_AGENT: step.agent,
|
|
2668
2908
|
FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
|
|
2669
2909
|
...(testRun ? testModeEnv() : {}),
|
|
2670
|
-
...
|
|
2910
|
+
...sandboxEnvSecrets,
|
|
2671
2911
|
}).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
2672
2912
|
),
|
|
2673
2913
|
emit: pushWatching,
|
|
@@ -2950,6 +3190,13 @@ async function runStep(
|
|
|
2950
3190
|
// On this machine nobody can write in and there is no proxy: the
|
|
2951
3191
|
// question goes to the terminal when there is one, else unanswered.
|
|
2952
3192
|
const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
|
|
3193
|
+
// Colleagues in the same group reach each other over the run's own
|
|
3194
|
+
// bus here — no proxy, so no person can write in, but a sibling can.
|
|
3195
|
+
// Every grouped step gets the inbox (a pod reads the proxy's inbox
|
|
3196
|
+
// whatever its tools); only tools: [peers] gets the tools to send.
|
|
3197
|
+
const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
|
|
3198
|
+
const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
|
|
3199
|
+
const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
|
|
2953
3200
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
2954
3201
|
// finally — once, however many attempts follow.
|
|
2955
3202
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
@@ -2973,6 +3220,9 @@ async function runStep(
|
|
|
2973
3220
|
workspaceRoot,
|
|
2974
3221
|
libraryRoot: libraryDir(tenant),
|
|
2975
3222
|
...(subagents.length ? { subagents } : {}),
|
|
3223
|
+
// The colleagues' inbox, read after every tool call — the same hook
|
|
3224
|
+
// a pod uses for a person's messages.
|
|
3225
|
+
...(localInbox ? { inbox: localInbox } : {}),
|
|
2976
3226
|
prompt,
|
|
2977
3227
|
model,
|
|
2978
3228
|
effort,
|
|
@@ -2987,6 +3237,7 @@ async function runStep(
|
|
|
2987
3237
|
...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
|
|
2988
3238
|
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
2989
3239
|
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
3240
|
+
...(peerTools ? { foldrun_peers: peerTools.server } : {}),
|
|
2990
3241
|
...(installTools ? { foldrun_install: installTools.server } : {}),
|
|
2991
3242
|
...mcpServers,
|
|
2992
3243
|
},
|
|
@@ -3004,6 +3255,7 @@ async function runStep(
|
|
|
3004
3255
|
output: step.output,
|
|
3005
3256
|
schema: resolvedSchema ?? undefined,
|
|
3006
3257
|
maxTurns: step.maxTurns,
|
|
3258
|
+
capture,
|
|
3007
3259
|
...limitOpts,
|
|
3008
3260
|
...hookOpts,
|
|
3009
3261
|
stopRequested,
|
|
@@ -3266,7 +3518,7 @@ const CREDENTIAL_POLL_MS = 2_000;
|
|
|
3266
3518
|
* step started one second before the 09:07:52Z refresh) matched none of the
|
|
3267
3519
|
* older two, so the step failed instead of waiting for the new token. */
|
|
3268
3520
|
export function isAuthRefusal(text: string): boolean {
|
|
3269
|
-
return
|
|
3521
|
+
return AUTH_REFUSAL.test(text);
|
|
3270
3522
|
}
|
|
3271
3523
|
|
|
3272
3524
|
/**
|
|
@@ -3890,6 +4142,12 @@ function driveRunInner(
|
|
|
3890
4142
|
try {
|
|
3891
4143
|
// Entering the loop is what makes a run live, whatever state it was
|
|
3892
4144
|
// saved in: `queued` from the queue, `awaiting-approval` from a park.
|
|
4145
|
+
// The first pick-up (FOL-54 step 4): startedAt to this is the time it
|
|
4146
|
+
// queued; and the files it runs are the workspace's latest revision
|
|
4147
|
+
// as it starts — a deploy's revision is its commit, and an edit through
|
|
4148
|
+
// /source makes one too. A re-run comes through here as well.
|
|
4149
|
+
run.drivenAt ??= new Date().toISOString();
|
|
4150
|
+
if (run.revision === undefined) run.revision = latestRevisionId(tenant, workspace);
|
|
3893
4151
|
if (run.status !== "running") {
|
|
3894
4152
|
run.status = "running";
|
|
3895
4153
|
run.parkedAt = null;
|
|
@@ -4466,6 +4724,13 @@ function driveRunInner(
|
|
|
4466
4724
|
// twenty steps over — the shares sum to what was left. Between
|
|
4467
4725
|
// groups the check above still refuses to start the next one.
|
|
4468
4726
|
const launching = freshGroup.filter((s) => s.status === "pending");
|
|
4727
|
+
// tools: [peers] — what each step of the group is told about the
|
|
4728
|
+
// others, and the bus they share when they run in this process.
|
|
4729
|
+
const peerBus = localPeerBus();
|
|
4730
|
+
const siblingsOf = (step: StepRecord): PeerInfo[] =>
|
|
4731
|
+
launching
|
|
4732
|
+
.filter((s) => s !== step)
|
|
4733
|
+
.map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
|
|
4469
4734
|
// An agent's own `budget:` — the most it may spend in one run — is
|
|
4470
4735
|
// read at launch, so a cap raised mid-run applies to the next step.
|
|
4471
4736
|
const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
|
|
@@ -4502,15 +4767,48 @@ function driveRunInner(
|
|
|
4502
4767
|
if (!pool) pools.set(key, (pool = slotPool(step.parallel)));
|
|
4503
4768
|
return pool;
|
|
4504
4769
|
};
|
|
4770
|
+
// One instance of each agent goes first so its siblings read the
|
|
4771
|
+
// prompt cache it wrote — see warmGate. The claim is the callback's
|
|
4772
|
+
// first statement, before any await; the wait comes before the
|
|
4773
|
+
// pool, so nothing holds a slot while it waits.
|
|
4774
|
+
const gate = warmGate(launching);
|
|
4505
4775
|
await Promise.all(
|
|
4506
4776
|
launching
|
|
4507
4777
|
.map(async (step) => {
|
|
4778
|
+
const lead = gate.claim(step);
|
|
4779
|
+
if (lead) {
|
|
4780
|
+
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` });
|
|
4781
|
+
save();
|
|
4782
|
+
await lead;
|
|
4783
|
+
}
|
|
4508
4784
|
const pool = poolFor(step);
|
|
4509
4785
|
if (pool) await pool.acquire();
|
|
4510
4786
|
try {
|
|
4511
|
-
|
|
4787
|
+
// A stop that arrived while this copy waited — for the
|
|
4788
|
+
// lead's reply or for a slot — must not start it: the
|
|
4789
|
+
// between-groups check runs only once this group is over,
|
|
4790
|
+
// and save() is what carries the flag in from the record.
|
|
4791
|
+
if (lead || pool) {
|
|
4792
|
+
save();
|
|
4793
|
+
if (run.stopRequested) {
|
|
4794
|
+
step.status = "skipped";
|
|
4795
|
+
step.skipReason = "run stopped";
|
|
4796
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
|
|
4797
|
+
save();
|
|
4798
|
+
gate.release(step);
|
|
4799
|
+
return;
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
await gate.run(step, () => launchStep(step));
|
|
4512
4803
|
} finally {
|
|
4513
4804
|
pool?.release();
|
|
4805
|
+
// Every attempt is over: off its siblings' rosters, so a
|
|
4806
|
+
// message to it is not reported delivered and never read.
|
|
4807
|
+
// Here, not per attempt — a step between retries is still
|
|
4808
|
+
// one its colleagues can reach.
|
|
4809
|
+
const index = run.steps.indexOf(step);
|
|
4810
|
+
peerBus.leave(index);
|
|
4811
|
+
await platform.egress.stepEnded?.({ tenant, runId: run.id, step: index }).catch(() => undefined);
|
|
4514
4812
|
}
|
|
4515
4813
|
}),
|
|
4516
4814
|
);
|
|
@@ -4574,6 +4872,8 @@ function driveRunInner(
|
|
|
4574
4872
|
},
|
|
4575
4873
|
flowTimezone,
|
|
4576
4874
|
run.steps.indexOf(step),
|
|
4875
|
+
siblingsOf(step),
|
|
4876
|
+
peerBus,
|
|
4577
4877
|
).finally(() => reserved.delete(step));
|
|
4578
4878
|
// runStep mutates step.status; read it through a widened local
|
|
4579
4879
|
// so TS doesn't keep the "running" narrowing from above.
|
|
@@ -5073,7 +5373,14 @@ export interface Reconciliation {
|
|
|
5073
5373
|
* outlives any process. A run whose person already answered is not waiting,
|
|
5074
5374
|
* and is picked back up — see below.
|
|
5075
5375
|
*/
|
|
5076
|
-
|
|
5376
|
+
/** Told when a run reconcile closed, or resumed and saw to its end — so the
|
|
5377
|
+
* platform settles, notifies and chains it as it would a run its worker
|
|
5378
|
+
* drove (a run closed here had spent tokens, and nobody was charged or
|
|
5379
|
+
* told). Not told when a resumed run parks again: whatever resumes it next
|
|
5380
|
+
* goes through the worker. */
|
|
5381
|
+
export type ReconcileEnd = (tenant: string, workspace: string, runId: string) => void | Promise<void>;
|
|
5382
|
+
|
|
5383
|
+
export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[] {
|
|
5077
5384
|
const closed: Reconciliation[] = [];
|
|
5078
5385
|
|
|
5079
5386
|
for (const workspace of listWorkspaces(tenant)) {
|
|
@@ -5108,7 +5415,12 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
|
|
|
5108
5415
|
// Park at any *later* gate rather than poll: reconcile runs inside
|
|
5109
5416
|
// long-lived server processes, where a 24h file-poll is exactly the
|
|
5110
5417
|
// slot-burning the queue exists to avoid.
|
|
5111
|
-
void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
|
|
5418
|
+
void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
|
|
5419
|
+
.then(() => {
|
|
5420
|
+
const after = readRun(tenant, workspace.name, run.id);
|
|
5421
|
+
if (onEnd && after && (after.status === "completed" || after.status === "failed")) return onEnd(tenant, workspace.name, run.id);
|
|
5422
|
+
})
|
|
5423
|
+
.catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after resume:`, err));
|
|
5112
5424
|
closed.push({
|
|
5113
5425
|
runId: run.id,
|
|
5114
5426
|
tenant,
|
|
@@ -5153,6 +5465,11 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
|
|
|
5153
5465
|
// a sandbox we cannot reach must not stop the reconcile
|
|
5154
5466
|
}
|
|
5155
5467
|
closed.push({ runId: run.id, tenant, workspace: workspace.name, action: "closed", interrupted });
|
|
5468
|
+
if (onEnd) {
|
|
5469
|
+
void Promise.resolve()
|
|
5470
|
+
.then(() => onEnd(tenant, workspace.name, run.id))
|
|
5471
|
+
.catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after close:`, err));
|
|
5472
|
+
}
|
|
5156
5473
|
}
|
|
5157
5474
|
}
|
|
5158
5475
|
|
|
@@ -5166,8 +5483,8 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
|
|
|
5166
5483
|
* one hard-coded account left every other account's interrupted runs marked
|
|
5167
5484
|
* `running` forever, with no process left to finish them.
|
|
5168
5485
|
*/
|
|
5169
|
-
export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string
|
|
5170
|
-
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
|
|
5486
|
+
export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[] {
|
|
5487
|
+
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live, onEnd));
|
|
5171
5488
|
}
|
|
5172
5489
|
|
|
5173
5490
|
/**
|