@foldrun/core 2.3.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 +26 -18
- 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/model-env.d.ts +14 -2
- package/dist/src/model-env.js +34 -9
- 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 +26 -18
- 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/model-env.ts +42 -10
- 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/dist/src/runner.js
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
// group receives every prior result, labeled per agent. Optional steps ("2?")
|
|
3
3
|
// fail without failing the flow. A direct agent run is a one-step ad-hoc
|
|
4
4
|
// flow, so there is a single execution path.
|
|
5
|
+
import { latestRevisionId } from "./history.js";
|
|
6
|
+
import { recordCallIo, redactionsFor } from "./capture.js";
|
|
7
|
+
import { AUTH_REFUSAL } from "./issues.js";
|
|
5
8
|
import { hookNames } from "./hooks.js";
|
|
6
9
|
import fs from "node:fs";
|
|
7
10
|
import os from "node:os";
|
|
8
11
|
import path from "node:path";
|
|
9
|
-
import matter from "
|
|
12
|
+
import matter from "./matter.js";
|
|
10
13
|
import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
|
|
11
14
|
import { cascadeLimits, toolOwners } from "./limits.js";
|
|
12
15
|
import { resolveAgentPath } from "./confine.js";
|
|
@@ -14,7 +17,7 @@ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js
|
|
|
14
17
|
import { eventUrl } from "./webhook.js";
|
|
15
18
|
import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
|
|
16
19
|
import { hostSafeEnv, platformApiUrl } from "./host-env.js";
|
|
17
|
-
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
|
|
20
|
+
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.js";
|
|
18
21
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
|
|
19
22
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
|
|
20
23
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
|
|
@@ -29,15 +32,16 @@ const isolatedRun = () => {
|
|
|
29
32
|
};
|
|
30
33
|
import { gatherConsults, buildConsultTools } from "./agent-tools.js";
|
|
31
34
|
import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools } from "./subagents.js";
|
|
32
|
-
import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.js";
|
|
35
|
+
import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
|
|
33
36
|
import { sendPlainNotification } from "./notify.js";
|
|
34
37
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
|
|
35
38
|
import { refNames } from "./refs.js";
|
|
36
39
|
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
37
40
|
import { canReadBack } from "./tool-output.js";
|
|
41
|
+
import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
|
|
38
42
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
|
|
39
43
|
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
|
|
40
|
-
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
|
|
44
|
+
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.js";
|
|
41
45
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
42
46
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
|
|
43
47
|
import { attachOperations, prefetchOpenApi } from "./openapi.js";
|
|
@@ -155,6 +159,105 @@ export function slotPool(size) {
|
|
|
155
159
|
},
|
|
156
160
|
};
|
|
157
161
|
}
|
|
162
|
+
/** Steps whose model has begun its first response — the step's executor
|
|
163
|
+
* says so the moment the stream starts (EventExtra.responding). Memory
|
|
164
|
+
* only: it is scheduling, not something the run record needs. */
|
|
165
|
+
const respondingSteps = new WeakSet();
|
|
166
|
+
export function markResponding(step) {
|
|
167
|
+
respondingSteps.add(step);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Is this step's prompt in the cache yet? An entry is readable from the
|
|
171
|
+
* moment the response to the request that wrote it begins streaming
|
|
172
|
+
* (Anthropic's prompt-caching docs), which is what `responding` marks.
|
|
173
|
+
* The first text, tool call or recorded model call says the same a little
|
|
174
|
+
* later, for an executor that cannot see the stream (the stub, an older
|
|
175
|
+
* driver). Resolves on the first of those, or when the step is over
|
|
176
|
+
* without one (a refusal before the first turn, a failed start).
|
|
177
|
+
*/
|
|
178
|
+
export function untilFirstReply(step, done, pollMs = 250) {
|
|
179
|
+
const replied = () => respondingSteps.has(step) || step.events.some((e) => e.type === "text" || e.type === "tool") || (step.calls?.length ?? 0) > 0;
|
|
180
|
+
return new Promise((resolve) => {
|
|
181
|
+
let finished = false;
|
|
182
|
+
const stop = () => {
|
|
183
|
+
finished = true;
|
|
184
|
+
resolve();
|
|
185
|
+
};
|
|
186
|
+
done.then(stop, stop);
|
|
187
|
+
const tick = () => {
|
|
188
|
+
if (finished)
|
|
189
|
+
return;
|
|
190
|
+
if (replied())
|
|
191
|
+
return stop();
|
|
192
|
+
setTimeout(tick, pollMs).unref?.();
|
|
193
|
+
};
|
|
194
|
+
tick();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Steps of one agent launched together share a prompt prefix — tools and
|
|
199
|
+
* system prompt — and that is what the model's cache keeps. The cache is
|
|
200
|
+
* written by the first request that carries a prefix and readable only
|
|
201
|
+
* once that response has begun, so N copies started at once all write it
|
|
202
|
+
* and none reads: a fan-out of twenty paid twenty cold prefixes. The gate
|
|
203
|
+
* lets the first instance of each agent go alone and holds its siblings
|
|
204
|
+
* until its first response has begun (or it ended); then they all start
|
|
205
|
+
* and read what it wrote — Claude Code holds a workflow's fan-out the same
|
|
206
|
+
* way. Not a fixed delay: a sandbox takes seconds to start before its
|
|
207
|
+
* first model call, so a timer from launch would release the siblings
|
|
208
|
+
* before there was anything to read. Keyed by agent and model, since a
|
|
209
|
+
* cache is per model.
|
|
210
|
+
*/
|
|
211
|
+
export function warmGate(launching, pollMs = 250) {
|
|
212
|
+
const key = (s) => `${s.agent}\u0000${s.model ?? ""}`;
|
|
213
|
+
const counts = new Map();
|
|
214
|
+
for (const s of launching)
|
|
215
|
+
counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
|
|
216
|
+
const leads = new Map();
|
|
217
|
+
const isLead = new WeakSet();
|
|
218
|
+
return {
|
|
219
|
+
/** Claim the step's place — synchronously, before anything awaits, or
|
|
220
|
+
* the copies that reach their first await together would all find no
|
|
221
|
+
* lead and all go (a `parallel:` pool's acquire was exactly that).
|
|
222
|
+
* The first of its agent leads and gets null; a later one gets the
|
|
223
|
+
* lead's gate to await before it starts; a lone step gets null too. */
|
|
224
|
+
claim(step) {
|
|
225
|
+
const k = key(step);
|
|
226
|
+
if ((counts.get(k) ?? 0) < 2)
|
|
227
|
+
return null;
|
|
228
|
+
const lead = leads.get(k);
|
|
229
|
+
if (lead)
|
|
230
|
+
return lead.opened;
|
|
231
|
+
let open;
|
|
232
|
+
const opened = new Promise((r) => (open = r));
|
|
233
|
+
leads.set(k, { opened, open });
|
|
234
|
+
isLead.add(step);
|
|
235
|
+
return null;
|
|
236
|
+
},
|
|
237
|
+
/** A lead that will not run after all (the run was stopped while it
|
|
238
|
+
* waited for a pool slot) still opens its gate — or its siblings,
|
|
239
|
+
* which also wake to the stop, would wait on it forever. */
|
|
240
|
+
release(step) {
|
|
241
|
+
if (isLead.has(step))
|
|
242
|
+
leads.get(key(step)).open();
|
|
243
|
+
},
|
|
244
|
+
/** Run the step; a lead opens its agent's gate on its first reply (or
|
|
245
|
+
* its end), whatever else happens to it. */
|
|
246
|
+
async run(step, launch) {
|
|
247
|
+
if (!isLead.has(step))
|
|
248
|
+
return launch();
|
|
249
|
+
const { open } = leads.get(key(step));
|
|
250
|
+
const done = launch();
|
|
251
|
+
try {
|
|
252
|
+
await untilFirstReply(step, done, pollMs);
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
open();
|
|
256
|
+
}
|
|
257
|
+
await done;
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
158
261
|
const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" && v.trim() ? [v.trim()] : [];
|
|
159
262
|
// gray-matter throws on invalid YAML. Skills authored for another client
|
|
160
263
|
// sometimes ship YAML that that client's parser happened to accept — most
|
|
@@ -162,12 +265,9 @@ const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" &
|
|
|
162
265
|
// The Agent Skills client guide recommends quoting such values and retrying
|
|
163
266
|
// rather than dropping the skill, so a cross-client skill still loads here.
|
|
164
267
|
function safeMatter(raw) {
|
|
165
|
-
// gray-matter caches
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
// the throw deterministic, so the fallback below fires every time rather than
|
|
169
|
-
// once — otherwise the same skill loads on one run and vanishes on the next.
|
|
170
|
-
matter.clearCache();
|
|
268
|
+
// gray-matter caches a *failed* parse as a hollow success; ./matter.ts
|
|
269
|
+
// parses every call, so the throw below is deterministic and the fallback
|
|
270
|
+
// fires every time (it used to clear gray-matter's cache here for that).
|
|
171
271
|
try {
|
|
172
272
|
return matter(raw);
|
|
173
273
|
}
|
|
@@ -351,6 +451,9 @@ export function workspaceRuntimePlan(tenant, workspace) {
|
|
|
351
451
|
}
|
|
352
452
|
return [...byFp.values()];
|
|
353
453
|
}
|
|
454
|
+
/** The most tool content one step records (FOL-54 step 4): about 60 calls
|
|
455
|
+
* at the per-part cap; past it, metadata only. */
|
|
456
|
+
const STEP_CAPTURE_LIMIT = 4_000_000;
|
|
354
457
|
function workspaceFrontmatter(agentDir, tenant) {
|
|
355
458
|
return {
|
|
356
459
|
...(readAgentsMd(accountDir(tenant))?.data ?? {}),
|
|
@@ -651,7 +754,11 @@ grantsOnly = false) {
|
|
|
651
754
|
// workspace a secret should resolve against is two levels up.
|
|
652
755
|
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
653
756
|
const { data: front, content: body } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
|
|
654
|
-
|
|
757
|
+
// The prompt's sections, by name and by how often they change. Added in
|
|
758
|
+
// the order they are computed; assembled stable first, so the model's
|
|
759
|
+
// cache keeps the agent's half across steps and runs (prompt-layers.ts).
|
|
760
|
+
const parts = new PromptLayers();
|
|
761
|
+
parts.add("persona", resolveDocLinks(body.trim(), path.resolve(agentDir, "..", "..")));
|
|
655
762
|
// Where the agent is standing. Every path below this line is relative to the
|
|
656
763
|
// agent's own directory, and a run showed why that has to be said rather
|
|
657
764
|
// than implied: told to read `../../knowledge/index.md`, the model asked for
|
|
@@ -672,12 +779,12 @@ grantsOnly = false) {
|
|
|
672
779
|
const languageSet = language.from !== "default" && language.from !== "env";
|
|
673
780
|
const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
|
|
674
781
|
if (languageSet || regionSet) {
|
|
675
|
-
parts.
|
|
782
|
+
parts.add("locale", `# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
|
|
676
783
|
`Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
|
|
677
784
|
`use these currency, units and calendar conventions in anything you produce. ` +
|
|
678
785
|
`Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`);
|
|
679
786
|
}
|
|
680
|
-
parts.
|
|
787
|
+
parts.add("where", `# Where you are\n\n` +
|
|
681
788
|
`Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
|
|
682
789
|
`\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
|
|
683
790
|
`\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
|
|
@@ -720,7 +827,7 @@ grantsOnly = false) {
|
|
|
720
827
|
// background the rest of the prompt is read against, not an afterthought.
|
|
721
828
|
const sharedContext = sharedInstructions(agentDir, tenant);
|
|
722
829
|
if (sharedContext)
|
|
723
|
-
parts.
|
|
830
|
+
parts.add("agents.md", sharedContext);
|
|
724
831
|
// Skills follow the open Agent Skills format (agentskills.io): a folder
|
|
725
832
|
// with SKILL.md, optionally bundling its own scripts/ and references/. The
|
|
726
833
|
// flat `skills/<name>.md` form is also accepted for small skills.
|
|
@@ -754,7 +861,7 @@ grantsOnly = false) {
|
|
|
754
861
|
`Full instructions: \`${s.path}\`` +
|
|
755
862
|
(bundles.length ? ` (also bundles ${bundles.join(", ")})` : ""));
|
|
756
863
|
});
|
|
757
|
-
parts.
|
|
864
|
+
parts.add("skills", `# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
|
|
758
865
|
`read its file for the full instructions before proceeding:\n\n${lines.join("\n")}` +
|
|
759
866
|
(withheld ? `\n\n(${withheld} further skills exist but do not apply to this run.)` : ""));
|
|
760
867
|
}
|
|
@@ -773,10 +880,11 @@ grantsOnly = false) {
|
|
|
773
880
|
: "";
|
|
774
881
|
const projectMem = workspaceMemoryIndex(tenant, workspace);
|
|
775
882
|
const shared = libraryMemoryIndex(tenant);
|
|
776
|
-
|
|
883
|
+
// Dynamic: agents write memory, so the index moves between runs.
|
|
884
|
+
parts.add("memory", `# Memory — what you have learned\n\n${memoryIndex ? `Your own (files under memory/):\n\n${memoryIndex}` : "You have no memories of your own yet."}` +
|
|
777
885
|
(projectMem ? `\n\nShared across this workspace:\n\n${projectMem}` : "") +
|
|
778
886
|
(shared ? `\n\nShared across every workspace:\n\n${shared}` : "") +
|
|
779
|
-
howTo);
|
|
887
|
+
howTo, { dynamic: true });
|
|
780
888
|
}
|
|
781
889
|
// Knowledge — reference material the agent was given. Same three scopes and
|
|
782
890
|
// the same nearest-wins reading order as memory, but never written back:
|
|
@@ -791,7 +899,7 @@ grantsOnly = false) {
|
|
|
791
899
|
projectKnowledge && `Shared across this workspace:\n\n${projectKnowledge}`,
|
|
792
900
|
sharedKnowledge && `Shared across every workspace:\n\n${sharedKnowledge}`,
|
|
793
901
|
].filter(Boolean);
|
|
794
|
-
parts.
|
|
902
|
+
parts.add("knowledge", `# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
|
|
795
903
|
`on it rather than guessing at its contents. This is read-only: it is maintained by ` +
|
|
796
904
|
`people, not by you — if you learn something new, write it to memory/ instead.\n\n` +
|
|
797
905
|
sections.join("\n\n"));
|
|
@@ -822,7 +930,7 @@ grantsOnly = false) {
|
|
|
822
930
|
shared.length ? `Shared across this workspace:\n${shared.join("\n")}` : null,
|
|
823
931
|
global.length ? `From the account library (read-only):\n${global.join("\n")}` : null,
|
|
824
932
|
].filter(Boolean);
|
|
825
|
-
parts.
|
|
933
|
+
parts.add("scripts", `# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
|
|
826
934
|
`Read a script before running it if you are unsure what it does. Credentials your ` +
|
|
827
935
|
`scripts need are already in the environment as the secrets listed below.`);
|
|
828
936
|
}
|
|
@@ -837,7 +945,8 @@ grantsOnly = false) {
|
|
|
837
945
|
// file to size it against, not before.
|
|
838
946
|
const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
|
|
839
947
|
if (state.length) {
|
|
840
|
-
|
|
948
|
+
// Dynamic: runs add and remove state files.
|
|
949
|
+
parts.add("state", `# State — what you carry between runs\n\n` +
|
|
841
950
|
`What this workspace keeps across runs: a count, a cursor, where you got to. ` +
|
|
842
951
|
`Read and write these directly, and update them before you finish so the next ` +
|
|
843
952
|
`run starts where you left off.\n\n${state.join("\n")}\n\n` +
|
|
@@ -845,7 +954,7 @@ grantsOnly = false) {
|
|
|
845
954
|
`A count reads as well in a sentence as in JSON, and a person edits these in the ` +
|
|
846
955
|
`same editor as everything else — data formats are allowed here, not preferred.\n\n` +
|
|
847
956
|
`This is not memory. A fact you learned goes in memory/, where it is indexed and ` +
|
|
848
|
-
`its provenance recorded — state is the bookmark, not the lesson
|
|
957
|
+
`its provenance recorded — state is the bookmark, not the lesson.`, { dynamic: true });
|
|
849
958
|
}
|
|
850
959
|
// APIs — the agent's own definitions, plus workspace tools it granted by
|
|
851
960
|
// name. Library tools are defined once and reused.
|
|
@@ -928,7 +1037,7 @@ grantsOnly = false) {
|
|
|
928
1037
|
const keepIn = { dir: agentDir, readable: () => readsFiles };
|
|
929
1038
|
const apiTools = buildApiTools(tenant, apis, workspace, undefined, keepIn);
|
|
930
1039
|
if (apiTools.promptLines.length) {
|
|
931
|
-
parts.
|
|
1040
|
+
parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
|
|
932
1041
|
}
|
|
933
1042
|
// Secrets an agent declared become env vars for its scripts and bash.
|
|
934
1043
|
//
|
|
@@ -1079,6 +1188,10 @@ grantsOnly = false) {
|
|
|
1079
1188
|
name: spec.name,
|
|
1080
1189
|
maxTokensParam: preset?.maxTokensParam,
|
|
1081
1190
|
reasoningEffort: preset?.reasoningEffort,
|
|
1191
|
+
// The agent's prompts share an opening; the key says so to an
|
|
1192
|
+
// endpoint that routes its cache by one. Only where the preset
|
|
1193
|
+
// says the parameter is understood — elsewhere it is a 400.
|
|
1194
|
+
cacheKey: preset?.promptCacheKey ? promptCacheKey(tenant, workspace, path.basename(agentDir)) : undefined,
|
|
1082
1195
|
});
|
|
1083
1196
|
// What the run is actually pointed at, in one line: the endpoint, the
|
|
1084
1197
|
// tiers this gateway renames, and whether the credential landed.
|
|
@@ -1261,10 +1374,12 @@ grantsOnly = false) {
|
|
|
1261
1374
|
FOLDRUN_WORKSPACE: workspace,
|
|
1262
1375
|
...clockEnv,
|
|
1263
1376
|
}, libraryDir(tenant, "scripts"), runtime.interpreters, exec, keepIn.readable);
|
|
1264
|
-
|
|
1265
|
-
|
|
1377
|
+
// Dynamic: the date moves daily, and would otherwise invalidate every
|
|
1378
|
+
// layer behind it once a day.
|
|
1379
|
+
parts.add("clock", `# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
|
|
1380
|
+
`write or check — it is the workspace's calendar, not the container's clock.`, { dynamic: true });
|
|
1266
1381
|
if (scriptTools.promptLines.length) {
|
|
1267
|
-
parts.
|
|
1382
|
+
parts.add("script-tools", `# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
|
|
1268
1383
|
`\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`);
|
|
1269
1384
|
}
|
|
1270
1385
|
// The web tool's own description lists every action, so an agent whose
|
|
@@ -1273,15 +1388,15 @@ grantsOnly = false) {
|
|
|
1273
1388
|
// reddit-desk validator step on 6 Oct 2026. Say it before the first call.
|
|
1274
1389
|
if (builtins.length) {
|
|
1275
1390
|
const named = builtins.map((b) => b.split("="));
|
|
1276
|
-
parts.
|
|
1391
|
+
parts.add("web", `# Web search and fetch on this run\n\n` +
|
|
1277
1392
|
named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
|
|
1278
1393
|
`\n\nThe web tool's other actions work as usual.`);
|
|
1279
1394
|
}
|
|
1280
1395
|
if (declared.length) {
|
|
1281
|
-
parts.
|
|
1396
|
+
parts.add("secrets", `# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
|
|
1282
1397
|
`Never print, echo, or write them to a file.`);
|
|
1283
1398
|
}
|
|
1284
|
-
parts.
|
|
1399
|
+
parts.add("rules", "Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
|
|
1285
1400
|
"what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
|
|
1286
1401
|
"for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
|
|
1287
1402
|
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool. " +
|
|
@@ -1298,7 +1413,7 @@ grantsOnly = false) {
|
|
|
1298
1413
|
// run, and an agent that opened with "I'll start by cloning the repo" made
|
|
1299
1414
|
// all three useless. Asking here rather than in each agent.md means every
|
|
1300
1415
|
// agent obeys it, including the ones written before the summary existed.
|
|
1301
|
-
parts.
|
|
1416
|
+
parts.add("rules", "Open your reply with one sentence saying what you concluded or produced — the outcome, " +
|
|
1302
1417
|
"not your plan. Everything else follows it. That sentence is what a person sees in the " +
|
|
1303
1418
|
"run list and in the notification, without opening the run.");
|
|
1304
1419
|
// One list. `tools:` grants anything: a built-in group, an exact SDK tool
|
|
@@ -1340,6 +1455,7 @@ grantsOnly = false) {
|
|
|
1340
1455
|
let wantHistory = false;
|
|
1341
1456
|
let wantDesks = false;
|
|
1342
1457
|
let wantAsk = false;
|
|
1458
|
+
let wantPeers = false;
|
|
1343
1459
|
for (const ref of toolRefs(front)) {
|
|
1344
1460
|
const toolName = ref.name;
|
|
1345
1461
|
if (ref.mode === "ask") {
|
|
@@ -1374,7 +1490,7 @@ grantsOnly = false) {
|
|
|
1374
1490
|
if (available[toolName])
|
|
1375
1491
|
shadowed.push(toolName);
|
|
1376
1492
|
}
|
|
1377
|
-
else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
|
|
1493
|
+
else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask" || toolName === "peers") {
|
|
1378
1494
|
// The platform's own groups, served in-process — built below, once
|
|
1379
1495
|
// the roots and the records are known.
|
|
1380
1496
|
if (toolName === "search")
|
|
@@ -1383,6 +1499,8 @@ grantsOnly = false) {
|
|
|
1383
1499
|
wantHistory = true;
|
|
1384
1500
|
else if (toolName === "ask")
|
|
1385
1501
|
wantAsk = true;
|
|
1502
|
+
else if (toolName === "peers")
|
|
1503
|
+
wantPeers = true;
|
|
1386
1504
|
else
|
|
1387
1505
|
wantDesks = true;
|
|
1388
1506
|
if (available[toolName])
|
|
@@ -1444,10 +1562,17 @@ grantsOnly = false) {
|
|
|
1444
1562
|
const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
|
|
1445
1563
|
if (ask) {
|
|
1446
1564
|
allowed.push(ASK_TOOL);
|
|
1447
|
-
parts.
|
|
1565
|
+
parts.add("ask", `# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
|
|
1448
1566
|
}
|
|
1567
|
+
// tools: [peers] — peers_list and peers_message, to the other steps of
|
|
1568
|
+
// this step's parallel group. Who they are is known only when the group
|
|
1569
|
+
// launches, so the grant is here and the roster, the server and the
|
|
1570
|
+
// prompt's line are the step's (runStep).
|
|
1571
|
+
const peers = wantPeers;
|
|
1572
|
+
if (peers)
|
|
1573
|
+
allowed.push(...PEER_TOOLS);
|
|
1449
1574
|
if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
|
|
1450
|
-
parts.
|
|
1575
|
+
parts.add("finding", `# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
|
|
1451
1576
|
}
|
|
1452
1577
|
// An MCP server declares its tools when it connects, so they can't be listed
|
|
1453
1578
|
// ahead of time. Server-level specs cover them: granting the server grants
|
|
@@ -1469,13 +1594,16 @@ grantsOnly = false) {
|
|
|
1469
1594
|
denied.push(INSTALL_TOOL);
|
|
1470
1595
|
const finalAllowed = allowed.filter((t) => !denied.includes(t));
|
|
1471
1596
|
readsFiles = canReadBack(finalAllowed);
|
|
1597
|
+
const prompt = parts.assemble();
|
|
1472
1598
|
return {
|
|
1473
1599
|
front,
|
|
1474
1600
|
clockEnv,
|
|
1475
1601
|
/** The zone, where it came from, and anything unreadable on the way —
|
|
1476
1602
|
* reported by the step, not here, so it lands in the run's trace. */
|
|
1477
1603
|
clock,
|
|
1478
|
-
systemPrompt:
|
|
1604
|
+
systemPrompt: prompt.text,
|
|
1605
|
+
/** What the record keeps of each layer of that prompt. */
|
|
1606
|
+
promptLayers: prompt.layers,
|
|
1479
1607
|
allowed: finalAllowed,
|
|
1480
1608
|
disabled,
|
|
1481
1609
|
unknownTools,
|
|
@@ -1499,6 +1627,7 @@ grantsOnly = false) {
|
|
|
1499
1627
|
historyTools,
|
|
1500
1628
|
deskTools,
|
|
1501
1629
|
ask,
|
|
1630
|
+
peers,
|
|
1502
1631
|
// `spec` is the MERGED declaration — the agent's own block or its
|
|
1503
1632
|
// workspace's, plus every granted tool's. The isolated path builds its
|
|
1504
1633
|
// runtime from this; passing the agent's frontmatter alone silently drops
|
|
@@ -1730,7 +1859,13 @@ flowTimezone,
|
|
|
1730
1859
|
/** The step's index in run.steps, carried on its egress lease so a
|
|
1731
1860
|
* person's message to this step reaches this step's own inbox and not a
|
|
1732
1861
|
* sibling's running in the same group. Undefined outside a flow. */
|
|
1733
|
-
stepIndex
|
|
1862
|
+
stepIndex,
|
|
1863
|
+
/** The other steps launched with this one (its parallel group), for
|
|
1864
|
+
* tools: [peers]. Empty when it runs alone. */
|
|
1865
|
+
siblings = [],
|
|
1866
|
+
/** The in-process colleagues' bus for this group (localPeerBus), when
|
|
1867
|
+
* the step runs in this process rather than a pod. */
|
|
1868
|
+
peerBus) {
|
|
1734
1869
|
// Secret values are injected into scripts as environment variables and
|
|
1735
1870
|
// substituted into API headers, so a model that reads one back — from a
|
|
1736
1871
|
// script that prints it, an API error that echoes it, or its own `env` —
|
|
@@ -1776,9 +1911,56 @@ stepIndex) {
|
|
|
1776
1911
|
return value;
|
|
1777
1912
|
}
|
|
1778
1913
|
};
|
|
1914
|
+
// What this workspace records of each step (FOL-54 step 4): `capture:` in
|
|
1915
|
+
// AGENTS.md — every model call always; each tool call's input and output
|
|
1916
|
+
// unless it says `metadata`.
|
|
1917
|
+
const capture = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
|
|
1918
|
+
// A step that pours out content stops being recorded past this — its
|
|
1919
|
+
// calls keep their names, sizes and times (FOL-54 step 4).
|
|
1920
|
+
let capturedChars = 0;
|
|
1921
|
+
let captureFull = false;
|
|
1779
1922
|
const push = (type, text, extra) => {
|
|
1780
1923
|
const t = new Date().toISOString();
|
|
1781
|
-
|
|
1924
|
+
// The model has begun its first response: held only in memory, for
|
|
1925
|
+
// the fan-out gate (warmGate). Not a line in the trace.
|
|
1926
|
+
if (extra?.responding) {
|
|
1927
|
+
markResponding(step);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
// A model call is a span on the step, not a line in its trace.
|
|
1931
|
+
// Numbered here, across attempts: a retry's calls follow the first
|
|
1932
|
+
// attempt's, and an id-less turn of one attempt never overwrites
|
|
1933
|
+
// another's. No save of its own — a turn's blocks arrive as several
|
|
1934
|
+
// messages, and the next event saves them all.
|
|
1935
|
+
if (extra?.turn) {
|
|
1936
|
+
const calls = (step.calls ??= []);
|
|
1937
|
+
// The loop counts attempts from 1; a call of the first carries none.
|
|
1938
|
+
const attempt = Math.max(1, step.attempts ?? 1);
|
|
1939
|
+
const at = calls.findIndex((c) => c.id === extra.turn.id && (c.attempt ?? 1) === attempt);
|
|
1940
|
+
const call = { ...extra.turn, n: at >= 0 ? calls[at].n : calls.length + 1, ...(attempt > 1 ? { attempt } : {}) };
|
|
1941
|
+
if (at >= 0)
|
|
1942
|
+
calls[at] = call;
|
|
1943
|
+
else
|
|
1944
|
+
calls.push(call);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
// A tool call's content goes beside the run, masked (capture.ts), and the
|
|
1948
|
+
// event keeps only that it was recorded.
|
|
1949
|
+
let rest = extra;
|
|
1950
|
+
if (extra?.io) {
|
|
1951
|
+
const { io, ...others } = extra;
|
|
1952
|
+
rest = others;
|
|
1953
|
+
const size = (io.input?.length ?? 0) + (io.output?.length ?? 0);
|
|
1954
|
+
if (runId && extra.call && capture === "full" && capturedChars + size > STEP_CAPTURE_LIMIT && !captureFull) {
|
|
1955
|
+
captureFull = true;
|
|
1956
|
+
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` });
|
|
1957
|
+
}
|
|
1958
|
+
if (runId && extra.call && capture === "full" && !captureFull && recordCallIo(path.resolve(agentDir, "..", ".."), runId, extra.call, text, io, redact)) {
|
|
1959
|
+
capturedChars += size;
|
|
1960
|
+
rest = { ...others, captured: true };
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
step.events.push({ t, type, text: redact(text), ...rest });
|
|
1782
1964
|
// A person in the loop: the question, its answer, a message delivered —
|
|
1783
1965
|
// onto the step, so the run page, the API and history show them.
|
|
1784
1966
|
if (extra?.operator) {
|
|
@@ -1810,7 +1992,27 @@ stepIndex) {
|
|
|
1810
1992
|
step.status = "completed";
|
|
1811
1993
|
// A stub answer may open with `cost: 0.25` — a pretend price, so the
|
|
1812
1994
|
// parts of orchestration that watch money (budget:) are testable too.
|
|
1813
|
-
|
|
1995
|
+
let answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
|
|
1996
|
+
// `call: <tool> <input> => <output>` lines: a pretend tool call, sent
|
|
1997
|
+
// through the same push a real step's events take — with its model call
|
|
1998
|
+
// and its content — so what the runner records of a step is testable.
|
|
1999
|
+
answer = answer
|
|
2000
|
+
.split("\n")
|
|
2001
|
+
.filter((line) => {
|
|
2002
|
+
const m = /^call: (\S+) (.*?) => (.*)$/.exec(line);
|
|
2003
|
+
if (!m)
|
|
2004
|
+
return true;
|
|
2005
|
+
const id = `toolu_stub${(step.calls?.length ?? 0) + 1}`;
|
|
2006
|
+
// Twice under one id, as the SDK sends a turn's blocks: one row.
|
|
2007
|
+
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 };
|
|
2008
|
+
push("info", "", { turn });
|
|
2009
|
+
push("info", "", { turn: { ...turn, stop: "tool_use", tools: 1 } });
|
|
2010
|
+
push("tool", m[1], { call: id, ...(capture ? { io: { input: m[2], inputChars: m[2].length } } : {}) });
|
|
2011
|
+
push("tool", m[1], { call: id, ms: 3, io: { output: m[3], outputChars: m[3].length } });
|
|
2012
|
+
return false;
|
|
2013
|
+
})
|
|
2014
|
+
.join("\n")
|
|
2015
|
+
.trim();
|
|
1814
2016
|
const priced = answer.match(/^cost:\s*([\d.]+)\n?/);
|
|
1815
2017
|
const body = (priced ? answer.slice(priced[0].length) : answer).trim();
|
|
1816
2018
|
// A `===` line inside an answer separates earlier turns from the final
|
|
@@ -1849,12 +2051,15 @@ stepIndex) {
|
|
|
1849
2051
|
// step: the generic HTTP tool still works without the typed ones.
|
|
1850
2052
|
for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
|
|
1851
2053
|
push("error", `openapi: ${w}`);
|
|
1852
|
-
const { front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
2054
|
+
const { front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, peers, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
1853
2055
|
disposeRuntime = runtime.dispose;
|
|
1854
2056
|
canActOutward = outward;
|
|
1855
2057
|
// Sent again on every turn; with turns and the tools' result sizes, what
|
|
1856
2058
|
// a token report reads to say where a step's input went.
|
|
1857
2059
|
step.systemPromptChars = systemPrompt.length;
|
|
2060
|
+
// And each layer's size and hash, so a later reader can say which part
|
|
2061
|
+
// of the prompt changed between this step and the agent's last.
|
|
2062
|
+
step.promptLayers = promptLayers;
|
|
1858
2063
|
// A retry that moved the step up a class (an evicted or OOM-killed
|
|
1859
2064
|
// attempt) wins over the agent's own `size:` for the attempts after.
|
|
1860
2065
|
const size = step.sizeUp ?? agentSize;
|
|
@@ -1872,11 +2077,7 @@ stepIndex) {
|
|
|
1872
2077
|
// Populate before the first push: everything after this point may quote a
|
|
1873
2078
|
// credential. Short values are skipped — a two-character secret would
|
|
1874
2079
|
// redact half the English language, and anything that short is not one.
|
|
1875
|
-
redactions =
|
|
1876
|
-
.filter(([, value]) => typeof value === "string" && value.length >= 8)
|
|
1877
|
-
.map(([name, value]) => [value, name])
|
|
1878
|
-
// Longest first, so a value containing another is replaced whole.
|
|
1879
|
-
.sort((a, b) => b[0].length - a[0].length);
|
|
2080
|
+
redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
|
|
1880
2081
|
// One line for the clock: which zone this step works to, and which level
|
|
1881
2082
|
// set it. "Why is this dated yesterday" should be answerable from the run.
|
|
1882
2083
|
push("info", `clock: ${clock.timezone} (${clock.source}) — today is ${clockEnv.FOLDRUN_DATE}`);
|
|
@@ -2115,7 +2316,14 @@ stepIndex) {
|
|
|
2115
2316
|
...mcpNames,
|
|
2116
2317
|
...(consults.length ? ["foldrun_agents"] : []),
|
|
2117
2318
|
...(ask ? ["foldrun_ask"] : []),
|
|
2319
|
+
...(peers ? ["foldrun_peers"] : []),
|
|
2118
2320
|
];
|
|
2321
|
+
if (peers) {
|
|
2322
|
+
// The colleagues' contract, in the prompt the step reads: who runs
|
|
2323
|
+
// beside it this time (none, when it runs alone), and that a message
|
|
2324
|
+
// is a request, never a hand-over.
|
|
2325
|
+
prompt += `\n\n<colleagues>\n${peersPromptLine(siblings)}\n</colleagues>`;
|
|
2326
|
+
}
|
|
2119
2327
|
// Colleagues this agent may DELEGATE to: each with its own context and
|
|
2120
2328
|
// its own tools — resolved exactly as its own step's would be, then cut
|
|
2121
2329
|
// to what THIS step holds (subagents.ts). Built host-side, like consults.
|
|
@@ -2286,7 +2494,21 @@ stepIndex) {
|
|
|
2286
2494
|
],
|
|
2287
2495
|
});
|
|
2288
2496
|
grant.network = { level: network.level, hosts: network.hosts };
|
|
2289
|
-
|
|
2497
|
+
// network_enforce: true at the account or the workspace asks the proxy
|
|
2498
|
+
// to refuse; the proxy adds the install's own switch and has the last
|
|
2499
|
+
// word (egress-proxy.ts), so false here never loosens anything.
|
|
2500
|
+
grant.enforce = networkEnforceWanted(readAgentsMd(accountDir(tenant))?.data?.network_enforce, readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce);
|
|
2501
|
+
const lease = await platform.egress.lease({
|
|
2502
|
+
tenant,
|
|
2503
|
+
runId: runId ?? "adhoc",
|
|
2504
|
+
grant,
|
|
2505
|
+
test: testRun,
|
|
2506
|
+
...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
|
|
2507
|
+
// tools: [peers] — the roster the proxy serves, and the inboxes a
|
|
2508
|
+
// colleague's message may be addressed to. Only when granted: a
|
|
2509
|
+
// step without the tools has no reason to know its siblings.
|
|
2510
|
+
...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
|
|
2511
|
+
});
|
|
2290
2512
|
// The forward door: every program in the sandbox sends through the
|
|
2291
2513
|
// proxy, which holds the site list and fills bound secrets. A proxy
|
|
2292
2514
|
// without the door (an older platform) leaves programs going direct.
|
|
@@ -2357,6 +2579,18 @@ stepIndex) {
|
|
|
2357
2579
|
// carrying TEST_MODE_WITHHELD would only fail where it should have
|
|
2358
2580
|
// been sunk or refused.
|
|
2359
2581
|
const apiValues = testRun && lease ? placeholders : sandboxSecrets;
|
|
2582
|
+
// A declared secret named like the step's own model variables
|
|
2583
|
+
// (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, …) was spread over them below
|
|
2584
|
+
// and broke, or redirected, the step's model calls (FOL-19). It crosses
|
|
2585
|
+
// as FOLDRUN_DECLARED_<NAME> instead: the model keeps its settings, and
|
|
2586
|
+
// the driver gives the agent's API and script tools the value under its
|
|
2587
|
+
// own name (run-container.ts) — never the model's key in its place.
|
|
2588
|
+
const modelNamed = Object.keys(sandboxSecrets).filter(isModelEnvName);
|
|
2589
|
+
if (modelNamed.length) {
|
|
2590
|
+
push("info", `secrets: ${modelNamed.join(", ")} kept away from the model's own settings — ${modelNamed.length === 1 ? "that name is" : "those names are"} what the step's model call uses; ` +
|
|
2591
|
+
"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:");
|
|
2592
|
+
}
|
|
2593
|
+
const sandboxEnvSecrets = Object.fromEntries(Object.entries(sandboxSecrets).map(([name, value]) => [isModelEnvName(name) ? `${DECLARED_ENV_PREFIX}${name}` : name, value]));
|
|
2360
2594
|
const substitutedApis = apiSpecs.map((api) => ({
|
|
2361
2595
|
...api,
|
|
2362
2596
|
headers: Object.fromEntries(Object.entries(api.headers).map(([k, v]) => [
|
|
@@ -2410,6 +2644,7 @@ stepIndex) {
|
|
|
2410
2644
|
// field. A tool's own `runtime:` is part of what this step needs.
|
|
2411
2645
|
runtime: runtime.spec,
|
|
2412
2646
|
consults,
|
|
2647
|
+
...(peers ? { peers: { self: step.agent, siblings } } : {}),
|
|
2413
2648
|
...(subagents.length ? { subagents } : {}),
|
|
2414
2649
|
timeoutSec: step.timeout,
|
|
2415
2650
|
budgetUsd: stepBudgetUsd,
|
|
@@ -2419,6 +2654,7 @@ stepIndex) {
|
|
|
2419
2654
|
output: step.output,
|
|
2420
2655
|
schema: resolvedSchema ?? undefined,
|
|
2421
2656
|
maxTurns: step.maxTurns,
|
|
2657
|
+
capture,
|
|
2422
2658
|
...limitOpts,
|
|
2423
2659
|
...hookOpts,
|
|
2424
2660
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
@@ -2464,7 +2700,7 @@ stepIndex) {
|
|
|
2464
2700
|
FOLDRUN_AGENT: step.agent,
|
|
2465
2701
|
FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
|
|
2466
2702
|
...(testRun ? testModeEnv() : {}),
|
|
2467
|
-
...
|
|
2703
|
+
...sandboxEnvSecrets,
|
|
2468
2704
|
}).filter((entry) => typeof entry[1] === "string")),
|
|
2469
2705
|
emit: pushWatching,
|
|
2470
2706
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
@@ -2742,6 +2978,13 @@ stepIndex) {
|
|
|
2742
2978
|
// On this machine nobody can write in and there is no proxy: the
|
|
2743
2979
|
// question goes to the terminal when there is one, else unanswered.
|
|
2744
2980
|
const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
|
|
2981
|
+
// Colleagues in the same group reach each other over the run's own
|
|
2982
|
+
// bus here — no proxy, so no person can write in, but a sibling can.
|
|
2983
|
+
// Every grouped step gets the inbox (a pod reads the proxy's inbox
|
|
2984
|
+
// whatever its tools); only tools: [peers] gets the tools to send.
|
|
2985
|
+
const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
|
|
2986
|
+
const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
|
|
2987
|
+
const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
|
|
2745
2988
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
2746
2989
|
// finally — once, however many attempts follow.
|
|
2747
2990
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
@@ -2765,6 +3008,9 @@ stepIndex) {
|
|
|
2765
3008
|
workspaceRoot,
|
|
2766
3009
|
libraryRoot: libraryDir(tenant),
|
|
2767
3010
|
...(subagents.length ? { subagents } : {}),
|
|
3011
|
+
// The colleagues' inbox, read after every tool call — the same hook
|
|
3012
|
+
// a pod uses for a person's messages.
|
|
3013
|
+
...(localInbox ? { inbox: localInbox } : {}),
|
|
2768
3014
|
prompt,
|
|
2769
3015
|
model,
|
|
2770
3016
|
effort,
|
|
@@ -2779,6 +3025,7 @@ stepIndex) {
|
|
|
2779
3025
|
...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
|
|
2780
3026
|
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
2781
3027
|
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
3028
|
+
...(peerTools ? { foldrun_peers: peerTools.server } : {}),
|
|
2782
3029
|
...(installTools ? { foldrun_install: installTools.server } : {}),
|
|
2783
3030
|
...mcpServers,
|
|
2784
3031
|
},
|
|
@@ -2796,6 +3043,7 @@ stepIndex) {
|
|
|
2796
3043
|
output: step.output,
|
|
2797
3044
|
schema: resolvedSchema ?? undefined,
|
|
2798
3045
|
maxTurns: step.maxTurns,
|
|
3046
|
+
capture,
|
|
2799
3047
|
...limitOpts,
|
|
2800
3048
|
...hookOpts,
|
|
2801
3049
|
stopRequested,
|
|
@@ -3065,7 +3313,7 @@ const CREDENTIAL_POLL_MS = 2_000;
|
|
|
3065
3313
|
* step started one second before the 09:07:52Z refresh) matched none of the
|
|
3066
3314
|
* older two, so the step failed instead of waiting for the new token. */
|
|
3067
3315
|
export function isAuthRefusal(text) {
|
|
3068
|
-
return
|
|
3316
|
+
return AUTH_REFUSAL.test(text);
|
|
3069
3317
|
}
|
|
3070
3318
|
/**
|
|
3071
3319
|
* Wait, briefly, for the mounted credential to become one we have not
|
|
@@ -3649,6 +3897,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3649
3897
|
try {
|
|
3650
3898
|
// Entering the loop is what makes a run live, whatever state it was
|
|
3651
3899
|
// saved in: `queued` from the queue, `awaiting-approval` from a park.
|
|
3900
|
+
// The first pick-up (FOL-54 step 4): startedAt to this is the time it
|
|
3901
|
+
// queued; and the files it runs are the workspace's latest revision
|
|
3902
|
+
// as it starts — a deploy's revision is its commit, and an edit through
|
|
3903
|
+
// /source makes one too. A re-run comes through here as well.
|
|
3904
|
+
run.drivenAt ??= new Date().toISOString();
|
|
3905
|
+
if (run.revision === undefined)
|
|
3906
|
+
run.revision = latestRevisionId(tenant, workspace);
|
|
3652
3907
|
if (run.status !== "running") {
|
|
3653
3908
|
run.status = "running";
|
|
3654
3909
|
run.parkedAt = null;
|
|
@@ -4218,6 +4473,12 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4218
4473
|
// twenty steps over — the shares sum to what was left. Between
|
|
4219
4474
|
// groups the check above still refuses to start the next one.
|
|
4220
4475
|
const launching = freshGroup.filter((s) => s.status === "pending");
|
|
4476
|
+
// tools: [peers] — what each step of the group is told about the
|
|
4477
|
+
// others, and the bus they share when they run in this process.
|
|
4478
|
+
const peerBus = localPeerBus();
|
|
4479
|
+
const siblingsOf = (step) => launching
|
|
4480
|
+
.filter((s) => s !== step)
|
|
4481
|
+
.map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
|
|
4221
4482
|
// An agent's own `budget:` — the most it may spend in one run — is
|
|
4222
4483
|
// read at launch, so a cap raised mid-run applies to the next step.
|
|
4223
4484
|
const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
|
|
@@ -4249,16 +4510,49 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4249
4510
|
pools.set(key, (pool = slotPool(step.parallel)));
|
|
4250
4511
|
return pool;
|
|
4251
4512
|
};
|
|
4513
|
+
// One instance of each agent goes first so its siblings read the
|
|
4514
|
+
// prompt cache it wrote — see warmGate. The claim is the callback's
|
|
4515
|
+
// first statement, before any await; the wait comes before the
|
|
4516
|
+
// pool, so nothing holds a slot while it waits.
|
|
4517
|
+
const gate = warmGate(launching);
|
|
4252
4518
|
await Promise.all(launching
|
|
4253
4519
|
.map(async (step) => {
|
|
4520
|
+
const lead = gate.claim(step);
|
|
4521
|
+
if (lead) {
|
|
4522
|
+
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` });
|
|
4523
|
+
save();
|
|
4524
|
+
await lead;
|
|
4525
|
+
}
|
|
4254
4526
|
const pool = poolFor(step);
|
|
4255
4527
|
if (pool)
|
|
4256
4528
|
await pool.acquire();
|
|
4257
4529
|
try {
|
|
4258
|
-
|
|
4530
|
+
// A stop that arrived while this copy waited — for the
|
|
4531
|
+
// lead's reply or for a slot — must not start it: the
|
|
4532
|
+
// between-groups check runs only once this group is over,
|
|
4533
|
+
// and save() is what carries the flag in from the record.
|
|
4534
|
+
if (lead || pool) {
|
|
4535
|
+
save();
|
|
4536
|
+
if (run.stopRequested) {
|
|
4537
|
+
step.status = "skipped";
|
|
4538
|
+
step.skipReason = "run stopped";
|
|
4539
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
|
|
4540
|
+
save();
|
|
4541
|
+
gate.release(step);
|
|
4542
|
+
return;
|
|
4543
|
+
}
|
|
4544
|
+
}
|
|
4545
|
+
await gate.run(step, () => launchStep(step));
|
|
4259
4546
|
}
|
|
4260
4547
|
finally {
|
|
4261
4548
|
pool?.release();
|
|
4549
|
+
// Every attempt is over: off its siblings' rosters, so a
|
|
4550
|
+
// message to it is not reported delivered and never read.
|
|
4551
|
+
// Here, not per attempt — a step between retries is still
|
|
4552
|
+
// one its colleagues can reach.
|
|
4553
|
+
const index = run.steps.indexOf(step);
|
|
4554
|
+
peerBus.leave(index);
|
|
4555
|
+
await platform.egress.stepEnded?.({ tenant, runId: run.id, step: index }).catch(() => undefined);
|
|
4262
4556
|
}
|
|
4263
4557
|
}));
|
|
4264
4558
|
async function launchStep(step) {
|
|
@@ -4304,7 +4598,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4304
4598
|
() => {
|
|
4305
4599
|
save();
|
|
4306
4600
|
return run.stopRequested === true;
|
|
4307
|
-
}, flowTimezone, run.steps.indexOf(step)).finally(() => reserved.delete(step));
|
|
4601
|
+
}, flowTimezone, run.steps.indexOf(step), siblingsOf(step), peerBus).finally(() => reserved.delete(step));
|
|
4308
4602
|
// runStep mutates step.status; read it through a widened local
|
|
4309
4603
|
// so TS doesn't keep the "running" narrowing from above.
|
|
4310
4604
|
const outcome = step.status;
|
|
@@ -4767,26 +5061,7 @@ function lastActivity(run) {
|
|
|
4767
5061
|
}
|
|
4768
5062
|
return latest;
|
|
4769
5063
|
}
|
|
4770
|
-
|
|
4771
|
-
* Close out runs abandoned by a process that is no longer here.
|
|
4772
|
-
*
|
|
4773
|
-
* Runs execute in the server process, and nothing writes a terminal status
|
|
4774
|
-
* when that process goes away — so a deploy, a crash or a Ctrl-C during a run
|
|
4775
|
-
* leaves `status: "running"` on disk forever. The run history then contains
|
|
4776
|
-
* entries that are not merely stale but false: they claim to be in progress,
|
|
4777
|
-
* the dashboard renders them as live, and no one can tell them apart from a
|
|
4778
|
-
* step that is genuinely still thinking.
|
|
4779
|
-
*
|
|
4780
|
-
* Called at startup, before anything reads run history. A run still being
|
|
4781
|
-
* worked on by a live process is protected by the idle window, not by a lock —
|
|
4782
|
-
* so this is safe to call while runs are in flight, and deliberately
|
|
4783
|
-
* conservative about what it declares dead.
|
|
4784
|
-
*
|
|
4785
|
-
* A run genuinely waiting for a person is left alone: that state legitimately
|
|
4786
|
-
* outlives any process. A run whose person already answered is not waiting,
|
|
4787
|
-
* and is picked back up — see below.
|
|
4788
|
-
*/
|
|
4789
|
-
export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
5064
|
+
export function reconcileRuns(tenant, now = Date.now(), live, onEnd) {
|
|
4790
5065
|
const closed = [];
|
|
4791
5066
|
for (const workspace of listWorkspaces(tenant)) {
|
|
4792
5067
|
// Live runs only, read whole — every other status is passed over below,
|
|
@@ -4823,7 +5098,13 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
|
4823
5098
|
// Park at any *later* gate rather than poll: reconcile runs inside
|
|
4824
5099
|
// long-lived server processes, where a 24h file-poll is exactly the
|
|
4825
5100
|
// slot-burning the queue exists to avoid.
|
|
4826
|
-
void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
|
|
5101
|
+
void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
|
|
5102
|
+
.then(() => {
|
|
5103
|
+
const after = readRun(tenant, workspace.name, run.id);
|
|
5104
|
+
if (onEnd && after && (after.status === "completed" || after.status === "failed"))
|
|
5105
|
+
return onEnd(tenant, workspace.name, run.id);
|
|
5106
|
+
})
|
|
5107
|
+
.catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after resume:`, err));
|
|
4827
5108
|
closed.push({
|
|
4828
5109
|
runId: run.id,
|
|
4829
5110
|
tenant,
|
|
@@ -4870,6 +5151,11 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
|
4870
5151
|
// a sandbox we cannot reach must not stop the reconcile
|
|
4871
5152
|
}
|
|
4872
5153
|
closed.push({ runId: run.id, tenant, workspace: workspace.name, action: "closed", interrupted });
|
|
5154
|
+
if (onEnd) {
|
|
5155
|
+
void Promise.resolve()
|
|
5156
|
+
.then(() => onEnd(tenant, workspace.name, run.id))
|
|
5157
|
+
.catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after close:`, err));
|
|
5158
|
+
}
|
|
4873
5159
|
}
|
|
4874
5160
|
}
|
|
4875
5161
|
return closed;
|
|
@@ -4881,8 +5167,8 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
|
|
|
4881
5167
|
* one hard-coded account left every other account's interrupted runs marked
|
|
4882
5168
|
* `running` forever, with no process left to finish them.
|
|
4883
5169
|
*/
|
|
4884
|
-
export function reconcileAllRuns(now = Date.now(), live) {
|
|
4885
|
-
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
|
|
5170
|
+
export function reconcileAllRuns(now = Date.now(), live, onEnd) {
|
|
5171
|
+
return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live, onEnd));
|
|
4886
5172
|
}
|
|
4887
5173
|
/**
|
|
4888
5174
|
* Wait for a run to stop being in progress.
|