@foldrun/core 0.3.0 → 0.4.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/README.md +27 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +102 -2
- package/dist/src/platform.js +18 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +57 -4
- package/dist/src/run-container.js +147 -41
- package/dist/src/runner.d.ts +39 -3
- package/dist/src/runner.js +422 -73
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +3 -0
- package/dist/src/step-exec.d.ts +47 -1
- package/dist/src/step-exec.js +155 -60
- package/dist/src/store.d.ts +276 -3
- package/dist/src/store.js +552 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +4 -4
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +107 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +175 -36
- package/src/runner.ts +435 -65
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +3 -0
- package/src/step-exec.ts +128 -13
- package/src/storage.ts +8 -2
- package/src/store.ts +746 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/dist/src/runner.js
CHANGED
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import matter from "gray-matter";
|
|
8
|
-
import { knownPrice, executeStep, extractJson, stepCeiling } from "./step-exec.js";
|
|
8
|
+
import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
|
|
9
9
|
import { eventUrl } from "./webhook.js";
|
|
10
10
|
import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
|
|
11
|
+
import { hostSafeEnv } from "./host-env.js";
|
|
12
|
+
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
|
|
11
13
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
|
|
12
14
|
import { platform } from "./platform.js";
|
|
15
|
+
import { healthKey } from "./secret-health.js";
|
|
16
|
+
import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
|
|
13
17
|
/** Does this process run steps in a sandbox — the container core ships, or
|
|
14
18
|
* one the platform registered (a pod)? */
|
|
15
19
|
const isolatedRun = () => {
|
|
@@ -19,7 +23,7 @@ const isolatedRun = () => {
|
|
|
19
23
|
import { gatherConsults, buildConsultTools } from "./agent-tools.js";
|
|
20
24
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError } from "./tool-names.js";
|
|
21
25
|
import { refNames } from "./refs.js";
|
|
22
|
-
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef,
|
|
26
|
+
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
23
27
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
|
|
24
28
|
import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
|
|
25
29
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
@@ -34,6 +38,7 @@ import { mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
|
|
|
34
38
|
import { materializeFiles, harvestFiles } from "./storage.js";
|
|
35
39
|
import { chooseExecutor, ensureImage } from "./container.js";
|
|
36
40
|
import { stampBundle } from "./okf.js";
|
|
41
|
+
import { trimChars } from "./paths.js";
|
|
37
42
|
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
38
43
|
// in env and headers resolve server-side, so a credential reaches the server
|
|
39
44
|
// process without ever entering the model's context.
|
|
@@ -84,6 +89,34 @@ export function joinEarlierGroups(groupResults, gi, cap = CONTEXT_CHARS) {
|
|
|
84
89
|
parts.shift();
|
|
85
90
|
return parts.join("\n\n");
|
|
86
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* `parallel: N` — a pool of N slots the instances of one fan-out share.
|
|
94
|
+
* Fan-out always ran every instance at once, and still does unless the
|
|
95
|
+
* step says otherwise: twenty browser steps against one site, or one
|
|
96
|
+
* vendor's rate limit, is what the cap is for. Nothing is rented while an
|
|
97
|
+
* instance waits for a slot.
|
|
98
|
+
*/
|
|
99
|
+
export function slotPool(size) {
|
|
100
|
+
let free = Math.max(1, size);
|
|
101
|
+
const waiting = [];
|
|
102
|
+
return {
|
|
103
|
+
acquire: () => new Promise((resolve) => {
|
|
104
|
+
if (free > 0) {
|
|
105
|
+
free -= 1;
|
|
106
|
+
resolve();
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
waiting.push(resolve);
|
|
110
|
+
}),
|
|
111
|
+
release: () => {
|
|
112
|
+
const next = waiting.shift();
|
|
113
|
+
if (next)
|
|
114
|
+
next();
|
|
115
|
+
else
|
|
116
|
+
free += 1;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
87
120
|
const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" && v.trim() ? [v.trim()] : [];
|
|
88
121
|
// gray-matter throws on invalid YAML. Skills authored for another client
|
|
89
122
|
// sometimes ship YAML that that client's parser happened to accept — most
|
|
@@ -311,10 +344,9 @@ export function saysUntilMarker(result, until) {
|
|
|
311
344
|
.some((line) =>
|
|
312
345
|
// Strip markdown emphasis and punctuation from BOTH ends: "**APPROVED.**"
|
|
313
346
|
// is a decision written by someone using bold, not a sentence.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
.replace(/[.!:,*_`\s]+$/, "")
|
|
347
|
+
// trimChars, not a `[…]+$` regex: that one is quadratic on a
|
|
348
|
+
// line of tabs, and this is a line an agent wrote.
|
|
349
|
+
trimChars(trimChars(line.trim(), "#>-*_`"), ".!:,*_`").trim()
|
|
318
350
|
.toLowerCase() === marker);
|
|
319
351
|
}
|
|
320
352
|
export function resolveDocLinks(text, workspaceRoot) {
|
|
@@ -408,7 +440,10 @@ export function resolveDocLinks(text, workspaceRoot) {
|
|
|
408
440
|
map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
|
|
409
441
|
}
|
|
410
442
|
}
|
|
411
|
-
|
|
443
|
+
// `[` excluded from the name: with it allowed, a run of `[[` makes the
|
|
444
|
+
// engine re-scan to the end from every opening — quadratic on a result
|
|
445
|
+
// an agent wrote.
|
|
446
|
+
return text.replace(/\[\[([^\]\[\n]+)\]\]/g, (whole, name) => {
|
|
412
447
|
const hit = map.get(norm(name.trim()));
|
|
413
448
|
return hit ? `\`${hit}\`` : whole;
|
|
414
449
|
});
|
|
@@ -1072,6 +1107,42 @@ function repriced(catalog, model, usage, sdkCost, push) {
|
|
|
1072
1107
|
}
|
|
1073
1108
|
return known;
|
|
1074
1109
|
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Write one attempt onto the step's history and re-total the step.
|
|
1112
|
+
*
|
|
1113
|
+
* runStep reports the attempt it just ran — its cost, tokens and sandbox
|
|
1114
|
+
* seconds — by assigning the step's fields. Those become this attempt's row;
|
|
1115
|
+
* the step's fields then become the sum of every row, which is what the
|
|
1116
|
+
* run's spend, the flow's budget: and the platform's bill read. A retried
|
|
1117
|
+
* step used to carry only its last attempt's cost, so a `retry: 2` step
|
|
1118
|
+
* could spend three shares while reporting one.
|
|
1119
|
+
*/
|
|
1120
|
+
export function recordAttempt(step, n, status, startedAt, eventsBefore,
|
|
1121
|
+
/** The attempt re-attached to a sandbox a previous driver left: same
|
|
1122
|
+
* attempt, so its row is replaced rather than repeated. */
|
|
1123
|
+
reattach = false) {
|
|
1124
|
+
const tries = step.tries ?? [];
|
|
1125
|
+
const lastError = step.events
|
|
1126
|
+
.slice(eventsBefore)
|
|
1127
|
+
.filter((e) => e.type === "error")
|
|
1128
|
+
.at(-1)?.text;
|
|
1129
|
+
const row = {
|
|
1130
|
+
n,
|
|
1131
|
+
status,
|
|
1132
|
+
costUsd: step.costUsd,
|
|
1133
|
+
tokens: step.tokens ?? null,
|
|
1134
|
+
computeSecs: step.computeSecs ?? null,
|
|
1135
|
+
startedAt,
|
|
1136
|
+
finishedAt: step.finishedAt ?? new Date().toISOString(),
|
|
1137
|
+
...(status === "failed" && lastError ? { error: lastError.slice(0, 500) } : {}),
|
|
1138
|
+
};
|
|
1139
|
+
const kept = reattach && tries.at(-1)?.n === n ? tries.slice(0, -1) : tries;
|
|
1140
|
+
step.tries = [...kept, row];
|
|
1141
|
+
const totals = sumAttempts(step.tries);
|
|
1142
|
+
step.costUsd = totals.costUsd;
|
|
1143
|
+
step.tokens = totals.tokens;
|
|
1144
|
+
step.computeSecs = totals.computeSecs;
|
|
1145
|
+
}
|
|
1075
1146
|
async function runStep(agentDir, tenant, step, context, save, modelOverride, tags = [], effortOverride, runId,
|
|
1076
1147
|
/** What the previous group returned as DATA (an `output: json` step's
|
|
1077
1148
|
* value), beside the prose in `context`. Undefined when nothing did. */
|
|
@@ -1079,7 +1150,16 @@ priorData,
|
|
|
1079
1150
|
/** The most this step may spend before it is stopped mid-turn: the run's
|
|
1080
1151
|
* remaining `budget:`, shared across the steps launched with it. Null
|
|
1081
1152
|
* means the flow set no budget. */
|
|
1082
|
-
stepBudgetUsd = null
|
|
1153
|
+
stepBudgetUsd = null,
|
|
1154
|
+
/** Which line set that ceiling, for the error that names it. */
|
|
1155
|
+
stepBudgetNote,
|
|
1156
|
+
/** The run is a test run — see test-mode.ts. Read off the run record by
|
|
1157
|
+
* the caller, never from anything the sandbox could have written. */
|
|
1158
|
+
testRun = false,
|
|
1159
|
+
/** Has a person asked for the run to stop? Read on a clock by the
|
|
1160
|
+
* in-process model loop, so a stop lands mid-step and not at the next
|
|
1161
|
+
* group. The isolated path needs none: stopRun destroys the sandbox. */
|
|
1162
|
+
stopRequested) {
|
|
1083
1163
|
// Secret values are injected into scripts as environment variables and
|
|
1084
1164
|
// substituted into API headers, so a model that reads one back — from a
|
|
1085
1165
|
// script that prints it, an API error that echoes it, or its own `env` —
|
|
@@ -1103,6 +1183,22 @@ stepBudgetUsd = null) {
|
|
|
1103
1183
|
out = out.split(value).join(`[redacted:${name}]`);
|
|
1104
1184
|
return out;
|
|
1105
1185
|
};
|
|
1186
|
+
// The reply, the conclusion and an `output: json` value land on the same
|
|
1187
|
+
// record the events do, and a model that quotes a script's stdout puts
|
|
1188
|
+
// the credential in its answer rather than in a tool event. Same scrub,
|
|
1189
|
+
// same place. The JSON value goes through its text form: a secret inside
|
|
1190
|
+
// a nested field is still a secret, and the key name survives either way.
|
|
1191
|
+
const redactText = (text) => (typeof text === "string" ? redact(text) : text);
|
|
1192
|
+
const redactData = (value) => {
|
|
1193
|
+
if (value === undefined || redactions.length === 0)
|
|
1194
|
+
return value;
|
|
1195
|
+
try {
|
|
1196
|
+
return JSON.parse(redact(JSON.stringify(value)));
|
|
1197
|
+
}
|
|
1198
|
+
catch {
|
|
1199
|
+
return value;
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1106
1202
|
const push = (type, text, extra) => {
|
|
1107
1203
|
step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
|
|
1108
1204
|
save();
|
|
@@ -1140,8 +1236,15 @@ stepBudgetUsd = null) {
|
|
|
1140
1236
|
// the failure of a step that returned no JSON are testable at no cost.
|
|
1141
1237
|
if (step.output === "json") {
|
|
1142
1238
|
const extracted = extractJson(step.result);
|
|
1143
|
-
if (extracted.ok)
|
|
1239
|
+
if (extracted.ok) {
|
|
1144
1240
|
step.data = extracted.value;
|
|
1241
|
+
const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
|
|
1242
|
+
const errors = schema && looksLikeSchema(schema) ? validateSchema(step.data, schema) : [];
|
|
1243
|
+
if (errors.length) {
|
|
1244
|
+
push("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`);
|
|
1245
|
+
step.status = "failed";
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1145
1248
|
else {
|
|
1146
1249
|
push("error", `output: json — ${extracted.reason}`);
|
|
1147
1250
|
step.status = "failed";
|
|
@@ -1168,7 +1271,7 @@ stepBudgetUsd = null) {
|
|
|
1168
1271
|
// what crosses into containers, headers and env files is the token. A
|
|
1169
1272
|
// failed refresh fails the step naming the secret and the provider's
|
|
1170
1273
|
// reason, which beats a 401 three layers later.
|
|
1171
|
-
const liveSecrets = await materializeSecrets(secretEnv);
|
|
1274
|
+
const liveSecrets = await materializeSecrets(secretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
|
|
1172
1275
|
// @file values (SSH keys, certs) still carry their content at this point.
|
|
1173
1276
|
// The in-process branch materialises them to 0600 paths on this host
|
|
1174
1277
|
// below; the container branch passes the content across and the driver
|
|
@@ -1307,6 +1410,12 @@ stepBudgetUsd = null) {
|
|
|
1307
1410
|
// The confinement boundary: agents in a workspace are one team, but the
|
|
1308
1411
|
// workspace is where isolation is enforced.
|
|
1309
1412
|
const workspaceRoot = path.resolve(agentDir, "..", "..");
|
|
1413
|
+
// Where a test run's state/ and storage/ writes go, and the line each
|
|
1414
|
+
// one leaves: under the run, beside its archived outputs.
|
|
1415
|
+
const divertFor = (id) => ({
|
|
1416
|
+
to: path.join(workspaceRoot, "runs", id, TEST_WRITES_DIR),
|
|
1417
|
+
note: (rel, summary) => push("info", `test: ${summary}`, { effect: { kind: "diverted", path: rel, summary } }),
|
|
1418
|
+
});
|
|
1310
1419
|
let prompt = step.instruction
|
|
1311
1420
|
? resolveDocLinks(step.instruction, workspaceRoot)
|
|
1312
1421
|
: "Begin your run now, following your instructions.";
|
|
@@ -1323,11 +1432,18 @@ stepBudgetUsd = null) {
|
|
|
1323
1432
|
if (step.output === "json") {
|
|
1324
1433
|
// The contract the runner parses back out — stated in the prompt so
|
|
1325
1434
|
// the instruction and the parser agree by construction, like delegate:.
|
|
1435
|
+
// The schema, when there is one, is quoted whole: the model that has
|
|
1436
|
+
// to satisfy it should not have to guess it from the field names.
|
|
1437
|
+
const schema = step.schema ?? (step.schemaPath ? readSchemaFile(path.resolve(agentDir, "..", ".."), step.schemaPath) : null);
|
|
1326
1438
|
prompt +=
|
|
1327
1439
|
`\n\n<output>\nThis step returns data. End your reply with exactly one JSON value inside a ` +
|
|
1328
1440
|
"```json fenced block — the value the next step will compute on. Prose before it is " +
|
|
1329
1441
|
`welcome (lead with a one-line headline); nothing may follow the block. If the instruction ` +
|
|
1330
|
-
`names fields, use exactly those names
|
|
1442
|
+
`names fields, use exactly those names.` +
|
|
1443
|
+
(schema && looksLikeSchema(schema)
|
|
1444
|
+
? ` The value must satisfy this JSON Schema, and the step fails if it does not:\n${JSON.stringify(schema, null, 2).slice(0, 20_000)}`
|
|
1445
|
+
: "") +
|
|
1446
|
+
`\n</output>`;
|
|
1331
1447
|
}
|
|
1332
1448
|
if (step.waitFor === "event" && step.eventPayload !== undefined) {
|
|
1333
1449
|
// Not resolved for links, like the previous step's results: it is
|
|
@@ -1392,6 +1508,16 @@ stepBudgetUsd = null) {
|
|
|
1392
1508
|
// fallback), else the platform's, and only for a step riding the
|
|
1393
1509
|
// platform credential.
|
|
1394
1510
|
const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
|
|
1511
|
+
// `schema:` — inline on the step, or a file under the workspace read
|
|
1512
|
+
// now, host-side, so both executors are handed the same document. A
|
|
1513
|
+
// file that cannot be read is a failed step before any model runs:
|
|
1514
|
+
// the contract the author wrote down cannot be checked.
|
|
1515
|
+
const resolvedSchema = step.schema ?? (step.schemaPath ? readSchemaFile(workspaceRoot, step.schemaPath) : null);
|
|
1516
|
+
if (step.schemaPath && resolvedSchema === null) {
|
|
1517
|
+
throw new Error(`schema: ${step.schemaPath} — not a readable JSON or YAML schema under the workspace`);
|
|
1518
|
+
}
|
|
1519
|
+
if (step.maxTurns)
|
|
1520
|
+
push("info", `max_turns: ${step.maxTurns}`);
|
|
1395
1521
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
1396
1522
|
if (isolatedRun()) {
|
|
1397
1523
|
// The isolated path: the whole loop — model, built-in tools, scripts —
|
|
@@ -1420,12 +1546,17 @@ stepBudgetUsd = null) {
|
|
|
1420
1546
|
if (!host)
|
|
1421
1547
|
continue;
|
|
1422
1548
|
for (const v of [...Object.values(api.headers), ...Object.values(api.query)]) {
|
|
1423
|
-
for (const name of placeholderNames(v))
|
|
1424
|
-
if (name in liveSecrets)
|
|
1425
|
-
|
|
1549
|
+
for (const name of placeholderNames(v)) {
|
|
1550
|
+
if (!(name in liveSecrets))
|
|
1551
|
+
continue;
|
|
1552
|
+
addGrant(grant, name, liveSecrets[name], host);
|
|
1553
|
+
// Which vault entry this is, so the proxy's record of what the
|
|
1554
|
+
// far end said lands on the right credential.
|
|
1555
|
+
(grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", path.basename(path.resolve(agentDir, "..", "..")));
|
|
1556
|
+
}
|
|
1426
1557
|
}
|
|
1427
1558
|
}
|
|
1428
|
-
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant });
|
|
1559
|
+
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun });
|
|
1429
1560
|
// Which secrets must still be real inside the sandbox: a script tool
|
|
1430
1561
|
// reads them from its environment, and unless it says `secrets:
|
|
1431
1562
|
// proxied` it needs the value, not the name. bash is a script by
|
|
@@ -1445,14 +1576,35 @@ stepBudgetUsd = null) {
|
|
|
1445
1576
|
}
|
|
1446
1577
|
// Sandbox env for the declared secrets: the value, or its own name as
|
|
1447
1578
|
// a placeholder that the proxy fills on the way out.
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
|
|
1579
|
+
const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
|
|
1580
|
+
let sandboxSecrets = materialise ? liveSecrets : placeholders;
|
|
1581
|
+
// A test run hands the sandbox no send-capable value. The env var is
|
|
1582
|
+
// still there, reading TEST_MODE_WITHHELD, so a script that never
|
|
1583
|
+
// checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
|
|
1584
|
+
// error — and sends nothing. The script tools say whether the step
|
|
1585
|
+
// has a declared sender (everything withheld) or a declared reader
|
|
1586
|
+
// (nothing withheld); see test-mode.ts for the order.
|
|
1587
|
+
if (testRun && materialise) {
|
|
1588
|
+
const held = withholdSecrets(liveSecrets, {
|
|
1589
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
1590
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
1591
|
+
});
|
|
1592
|
+
sandboxSecrets = held.env;
|
|
1593
|
+
for (const name of held.withheld) {
|
|
1594
|
+
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
// On a test run the http tools always go through the proxy as
|
|
1598
|
+
// placeholders, even when a script made the step materialise: the
|
|
1599
|
+
// proxy is where the outward-write policy lives, and a header
|
|
1600
|
+
// carrying TEST_MODE_WITHHELD would only fail where it should have
|
|
1601
|
+
// been sunk or refused.
|
|
1602
|
+
const apiValues = testRun && lease ? placeholders : sandboxSecrets;
|
|
1451
1603
|
const substitutedApis = apiSpecs.map((api) => ({
|
|
1452
1604
|
...api,
|
|
1453
1605
|
headers: Object.fromEntries(Object.entries(api.headers).map(([k, v]) => [
|
|
1454
1606
|
k,
|
|
1455
|
-
v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) =>
|
|
1607
|
+
v.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => apiValues[name] ?? whole),
|
|
1456
1608
|
])),
|
|
1457
1609
|
}));
|
|
1458
1610
|
// The provider's own key and headers, when the step has a lease: the
|
|
@@ -1480,6 +1632,7 @@ stepBudgetUsd = null) {
|
|
|
1480
1632
|
const isolatedArgs = (modelEnv, keyName = MODEL_KEY_NAME) => ({
|
|
1481
1633
|
workspaceRoot,
|
|
1482
1634
|
libraryRoot: libraryDir(tenant),
|
|
1635
|
+
galleryRoot: platform.galleryDir() ?? undefined,
|
|
1483
1636
|
// Keys this step's dependency cache. Per-account by design — see
|
|
1484
1637
|
// run-container.ts#runtimeCacheDir for why it is not shared.
|
|
1485
1638
|
tenant,
|
|
@@ -1500,9 +1653,12 @@ stepBudgetUsd = null) {
|
|
|
1500
1653
|
consults,
|
|
1501
1654
|
timeoutSec: step.timeout,
|
|
1502
1655
|
budgetUsd: stepBudgetUsd,
|
|
1656
|
+
budgetNote: stepBudgetNote,
|
|
1503
1657
|
price: tokenPrice,
|
|
1504
1658
|
verify: step.verify,
|
|
1505
1659
|
output: step.output,
|
|
1660
|
+
schema: resolvedSchema ?? undefined,
|
|
1661
|
+
maxTurns: step.maxTurns,
|
|
1506
1662
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
1507
1663
|
// The container sees the workspace at /workspace and the library
|
|
1508
1664
|
// at /library; the roots are named host-side and moved here.
|
|
@@ -1532,12 +1688,16 @@ stepBudgetUsd = null) {
|
|
|
1532
1688
|
FOLDRUN_RUN_ID: runId,
|
|
1533
1689
|
FOLDRUN_AGENT: step.agent,
|
|
1534
1690
|
FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
|
|
1691
|
+
...(testRun ? testModeEnv() : {}),
|
|
1535
1692
|
...sandboxSecrets,
|
|
1536
1693
|
}).filter((entry) => typeof entry[1] === "string")),
|
|
1537
1694
|
emit: pushWatching,
|
|
1538
1695
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
1539
1696
|
runId,
|
|
1540
1697
|
size,
|
|
1698
|
+
// A test run's state/ and storage/ changes come out under the run.
|
|
1699
|
+
divert: testRun && runId ? divertFor(runId) : undefined,
|
|
1700
|
+
test: testRun,
|
|
1541
1701
|
// Resume: a sandbox a previous driver left running, recorded on the
|
|
1542
1702
|
// step. Consumed once — a fresh attempt after this one (a retry, a
|
|
1543
1703
|
// fallback) starts its own sandbox.
|
|
@@ -1601,21 +1761,28 @@ stepBudgetUsd = null) {
|
|
|
1601
1761
|
outcome = withEarlierTiming(outcome, first);
|
|
1602
1762
|
}
|
|
1603
1763
|
if (lease) {
|
|
1604
|
-
for (const line of await lease.drainLog())
|
|
1605
|
-
|
|
1764
|
+
for (const line of await lease.drainLog()) {
|
|
1765
|
+
if (typeof line === "string")
|
|
1766
|
+
push("info", line);
|
|
1767
|
+
else
|
|
1768
|
+
push("info", line.text, { effect: line.effect });
|
|
1769
|
+
}
|
|
1606
1770
|
await lease.release();
|
|
1607
1771
|
}
|
|
1608
|
-
|
|
1772
|
+
// Nothing a test run left in storage/public/ reached storage/, so
|
|
1773
|
+
// there is nothing to publish — and a share link is an outward act.
|
|
1774
|
+
if (!testRun)
|
|
1775
|
+
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
1609
1776
|
step.status = outcome.status;
|
|
1610
|
-
step.result = outcome.result;
|
|
1611
|
-
step.conclusion = outcome.conclusion;
|
|
1777
|
+
step.result = redactText(outcome.result);
|
|
1778
|
+
step.conclusion = redactText(outcome.conclusion);
|
|
1612
1779
|
step.sandbox = null;
|
|
1613
1780
|
// The cluster's word on why a sandbox ended without the driver saying
|
|
1614
1781
|
// so — OOMKilled, Evicted — on the record, where the retry reads it.
|
|
1615
1782
|
if (outcome.status === "failed" && outcome.reason)
|
|
1616
1783
|
push("error", `sandbox ended: ${outcome.reason}`);
|
|
1617
1784
|
if (outcome.data !== undefined)
|
|
1618
|
-
step.data = outcome.data;
|
|
1785
|
+
step.data = redactData(outcome.data);
|
|
1619
1786
|
step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
|
|
1620
1787
|
step.tokens = outcome.usage
|
|
1621
1788
|
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
@@ -1646,10 +1813,23 @@ stepBudgetUsd = null) {
|
|
|
1646
1813
|
step.reserved = { cpus: Number(lim.cpus) || 2, memGiB: gib };
|
|
1647
1814
|
}
|
|
1648
1815
|
else {
|
|
1649
|
-
|
|
1816
|
+
// Same withholding as the sandbox path — the in-process step is what
|
|
1817
|
+
// `foldrun run --test` drives on a laptop, where there is no proxy
|
|
1818
|
+
// and this is the only guard there is.
|
|
1819
|
+
const held = testRun
|
|
1820
|
+
? withholdSecrets(liveSecrets, {
|
|
1821
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
1822
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
1823
|
+
})
|
|
1824
|
+
: { env: liveSecrets, withheld: [] };
|
|
1825
|
+
for (const name of held.withheld) {
|
|
1826
|
+
push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
|
|
1827
|
+
}
|
|
1828
|
+
const stepSecrets = held.env;
|
|
1829
|
+
const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...providerEnv }, (type, text) => push(type, text));
|
|
1650
1830
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
1651
1831
|
// finally — once, however many attempts follow.
|
|
1652
|
-
const mat = materializeFileSecrets(agentDir,
|
|
1832
|
+
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
1653
1833
|
fileDir = mat.dir;
|
|
1654
1834
|
const attemptInProcess = (modelEnv) => executeStep({
|
|
1655
1835
|
agentDir,
|
|
@@ -1671,13 +1851,20 @@ stepBudgetUsd = null) {
|
|
|
1671
1851
|
...mcpServers,
|
|
1672
1852
|
},
|
|
1673
1853
|
// Declared secrets reach the agent's scripts as env vars; the model
|
|
1674
|
-
// only ever sees the variable names, not the values.
|
|
1675
|
-
|
|
1854
|
+
// only ever sees the variable names, not the values. The base is
|
|
1855
|
+
// the allowlisted host environment, not process.env: the SDK's
|
|
1856
|
+
// Bash tool runs with this, and on a platform process.env holds
|
|
1857
|
+
// every tenant's root key and the datastore credentials.
|
|
1858
|
+
env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
|
|
1676
1859
|
timeoutSec: step.timeout,
|
|
1677
1860
|
budgetUsd: stepBudgetUsd,
|
|
1861
|
+
budgetNote: stepBudgetNote,
|
|
1678
1862
|
price: tokenPrice,
|
|
1679
1863
|
verify: step.verify,
|
|
1680
1864
|
output: step.output,
|
|
1865
|
+
schema: resolvedSchema ?? undefined,
|
|
1866
|
+
maxTurns: step.maxTurns,
|
|
1867
|
+
stopRequested,
|
|
1681
1868
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
1682
1869
|
// the proof a step left names THIS run, not one that came before —
|
|
1683
1870
|
// copy-back never propagates a deletion, so a marker from an earlier
|
|
@@ -1687,23 +1874,33 @@ stepBudgetUsd = null) {
|
|
|
1687
1874
|
...(runId ? { FOLDRUN_RUN_ID: runId } : {}),
|
|
1688
1875
|
FOLDRUN_AGENT: step.agent,
|
|
1689
1876
|
FOLDRUN_WORKSPACE: path.basename(path.resolve(agentDir, "..", "..")),
|
|
1690
|
-
...
|
|
1877
|
+
...(testRun ? testModeEnv() : {}),
|
|
1878
|
+
...stepSecrets,
|
|
1691
1879
|
},
|
|
1692
1880
|
emit: pushWatching,
|
|
1693
1881
|
});
|
|
1694
1882
|
// With a translator the step's endpoint is the translator: started
|
|
1695
1883
|
// here, pointed at by the SDK's env, closed when the attempt ends.
|
|
1884
|
+
// A test run brackets each attempt with a snapshot of state/ and
|
|
1885
|
+
// storage/: the step writes them for real, because there is no
|
|
1886
|
+
// sandbox to write instead, and afterwards what changed is moved
|
|
1887
|
+
// under the run and the originals come back.
|
|
1696
1888
|
const attemptThrough = async (modelEnv, spec) => {
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
const t = await startTranslator(spec);
|
|
1889
|
+
const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
|
|
1890
|
+
const t = spec ? await startTranslator(spec) : null;
|
|
1700
1891
|
try {
|
|
1701
|
-
return await attemptInProcess({ ...modelEnv, ...t.env });
|
|
1892
|
+
return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
|
|
1702
1893
|
}
|
|
1703
1894
|
finally {
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1895
|
+
if (t) {
|
|
1896
|
+
for (const line of t.drainLog())
|
|
1897
|
+
push("info", line.startsWith("translator") ? line : `translator: ${line}`);
|
|
1898
|
+
await t.close();
|
|
1899
|
+
}
|
|
1900
|
+
if (before && runId) {
|
|
1901
|
+
const d = divertFor(runId);
|
|
1902
|
+
restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
|
|
1903
|
+
}
|
|
1707
1904
|
}
|
|
1708
1905
|
};
|
|
1709
1906
|
let outcome = await attemptThrough(providerEnv, translator);
|
|
@@ -1722,12 +1919,13 @@ stepBudgetUsd = null) {
|
|
|
1722
1919
|
push("info", `primary model supply refused (${lastRefusal.slice(0, 80)}) — retrying this step on the fallback provider`);
|
|
1723
1920
|
outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
|
|
1724
1921
|
}
|
|
1725
|
-
|
|
1922
|
+
if (!testRun)
|
|
1923
|
+
publishPublicDir(tenant, path.basename(workspaceRoot), push);
|
|
1726
1924
|
step.status = outcome.status;
|
|
1727
|
-
step.result = outcome.result;
|
|
1728
|
-
step.conclusion = outcome.conclusion;
|
|
1925
|
+
step.result = redactText(outcome.result);
|
|
1926
|
+
step.conclusion = redactText(outcome.conclusion);
|
|
1729
1927
|
if (outcome.data !== undefined)
|
|
1730
|
-
step.data = outcome.data;
|
|
1928
|
+
step.data = redactData(outcome.data);
|
|
1731
1929
|
// A consult's spend belongs to the step that asked.
|
|
1732
1930
|
const consultCost = consultTools.drainCost();
|
|
1733
1931
|
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
|
|
@@ -2033,12 +2231,35 @@ function csvItems(workspaceDir, step, take) {
|
|
|
2033
2231
|
}
|
|
2034
2232
|
return data.slice(0, take).map((r) => `${header}\n${r}`);
|
|
2035
2233
|
}
|
|
2036
|
-
|
|
2234
|
+
/**
|
|
2235
|
+
* `schema: <path>` — the file, read and parsed, or null. Agent-relative like
|
|
2236
|
+
* every other path in a flow (`../../schemas/lead.json`), confined to the
|
|
2237
|
+
* workspace: a schema is the author's document, never something a step
|
|
2238
|
+
* can point outside.
|
|
2239
|
+
*/
|
|
2240
|
+
export function readSchemaFile(workspaceRoot, rel) {
|
|
2241
|
+
const root = path.resolve(workspaceRoot);
|
|
2242
|
+
// Written agent-relative (`../../schemas/x.json`) or workspace-relative
|
|
2243
|
+
// (`schemas/x.json`); both land on the same file.
|
|
2244
|
+
const candidates = [path.resolve(root, rel.replace(/^(\.\.\/){2}/, "")), path.resolve(root, "agents", "x", rel)];
|
|
2245
|
+
for (const abs of candidates) {
|
|
2246
|
+
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
2247
|
+
continue;
|
|
2248
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
|
|
2249
|
+
continue;
|
|
2250
|
+
const parsed = parseSchemaText(fs.readFileSync(abs, "utf8"));
|
|
2251
|
+
return "problem" in parsed ? null : parsed.schema;
|
|
2252
|
+
}
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
export function createFlowRun(tenant, workspace, steps, flowName, status, tags = [], startedBy = null, opts = {}) {
|
|
2037
2256
|
const run = {
|
|
2038
2257
|
id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
2039
2258
|
flow: flowName,
|
|
2040
2259
|
tags,
|
|
2041
2260
|
status,
|
|
2261
|
+
...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
|
|
2262
|
+
...(opts.test ? { test: true } : {}),
|
|
2042
2263
|
startedAt: new Date().toISOString(),
|
|
2043
2264
|
finishedAt: null,
|
|
2044
2265
|
steps: steps.map((s) => ({
|
|
@@ -2068,6 +2289,10 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
|
|
|
2068
2289
|
preview: s.preview,
|
|
2069
2290
|
delegate: s.delegate,
|
|
2070
2291
|
output: s.output,
|
|
2292
|
+
schema: s.schema,
|
|
2293
|
+
schemaPath: s.schemaPath,
|
|
2294
|
+
parallel: s.parallel,
|
|
2295
|
+
maxTurns: s.maxTurns,
|
|
2071
2296
|
attempts: 0,
|
|
2072
2297
|
status: "pending",
|
|
2073
2298
|
events: [],
|
|
@@ -2093,8 +2318,8 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
|
|
|
2093
2318
|
}
|
|
2094
2319
|
return run;
|
|
2095
2320
|
}
|
|
2096
|
-
export function startFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], effortOverride) {
|
|
2097
|
-
const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags);
|
|
2321
|
+
export function startFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], effortOverride, startedBy = null, opts = {}) {
|
|
2322
|
+
const run = createFlowRun(tenant, workspace, steps, flowName, "running", tags, startedBy, opts);
|
|
2098
2323
|
void driveRun(tenant, workspace, run, modelOverride, tags, { effortOverride });
|
|
2099
2324
|
return run;
|
|
2100
2325
|
}
|
|
@@ -2381,6 +2606,19 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2381
2606
|
// is assembled) keeps the newest: when the total is over it, the
|
|
2382
2607
|
// oldest groups are dropped whole before anything is cut mid-text.
|
|
2383
2608
|
const contextBefore = (gi) => joinEarlierGroups(groupResults, gi);
|
|
2609
|
+
// What `when:` and `case:` read: the PREVIOUS group's result alone —
|
|
2610
|
+
// the nearest earlier group that produced one, so a routed-past group
|
|
2611
|
+
// between is looked through rather than read as silence. The prompt
|
|
2612
|
+
// gets every earlier group joined (contextBefore); routing must not,
|
|
2613
|
+
// because a verdict two groups back would still open a gate a later
|
|
2614
|
+
// group had overruled: a classifier's BUG line matched a `case: BUG`
|
|
2615
|
+
// three groups on, after the debugger had already answered it.
|
|
2616
|
+
const routingBefore = (gi) => {
|
|
2617
|
+
for (let i = gi - 1; i >= 0; i--)
|
|
2618
|
+
if (groupResults[i])
|
|
2619
|
+
return groupResults[i];
|
|
2620
|
+
return null;
|
|
2621
|
+
};
|
|
2384
2622
|
// The data half of the same handoff: what the nearest earlier group
|
|
2385
2623
|
// returned through `output: json`. Rebuilt from the record rather than
|
|
2386
2624
|
// kept only in memory, so a resumed run hands step 3 the value step 1
|
|
@@ -2444,8 +2682,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2444
2682
|
const group = ordered[gi];
|
|
2445
2683
|
context = contextBefore(gi);
|
|
2446
2684
|
ctxData = dataBefore(gi);
|
|
2447
|
-
|
|
2448
|
-
|
|
2685
|
+
// What the run has spent, not just what its tokens cost: on an
|
|
2686
|
+
// install that prices sandbox seconds, a step that called no model
|
|
2687
|
+
// still spent money, and a cap that ignored it capped nothing.
|
|
2688
|
+
if (run.budgetUsd && platform.runSpend(run) >= run.budgetUsd && group.some((s) => s.status === "pending")) {
|
|
2689
|
+
const spent = platform.runSpend(run);
|
|
2449
2690
|
const last = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
|
|
2450
2691
|
last?.events.push({
|
|
2451
2692
|
t: new Date().toISOString(),
|
|
@@ -2531,6 +2772,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2531
2772
|
t: new Date().toISOString(),
|
|
2532
2773
|
type: "info",
|
|
2533
2774
|
text: `fan-out: ${used.length} instance${used.length === 1 ? "" : "s"}` +
|
|
2775
|
+
(step.parallel && step.parallel < used.length ? `, ${step.parallel} at a time (parallel:)` : "") +
|
|
2534
2776
|
(dropped ? ` (more items existed — capped at ${cap})` : ""),
|
|
2535
2777
|
});
|
|
2536
2778
|
const at = run.steps.indexOf(step);
|
|
@@ -2545,6 +2787,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2545
2787
|
result: null,
|
|
2546
2788
|
costUsd: null,
|
|
2547
2789
|
attempts: 0,
|
|
2790
|
+
tries: undefined,
|
|
2548
2791
|
}));
|
|
2549
2792
|
run.steps.splice(at + 1, 0, ...instances);
|
|
2550
2793
|
ordered = orderedGroups();
|
|
@@ -2557,12 +2800,25 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2557
2800
|
// this is what keeps the *next* group from starting.
|
|
2558
2801
|
const stopCheck = readRun(tenant, workspace, run.id);
|
|
2559
2802
|
if (stopCheck?.stopRequested) {
|
|
2803
|
+
let skipped = 0;
|
|
2560
2804
|
for (const s of run.steps) {
|
|
2561
2805
|
if (s.status === "pending" || s.status === "running") {
|
|
2562
2806
|
s.status = "skipped";
|
|
2563
2807
|
s.skipReason = "run stopped";
|
|
2808
|
+
skipped += 1;
|
|
2564
2809
|
}
|
|
2565
2810
|
}
|
|
2811
|
+
// Say so on the trail. stopRun wrote "run stopped" onto the
|
|
2812
|
+
// record's first step, but the save() below writes this process's
|
|
2813
|
+
// copy of the record over it, so the only witness was the
|
|
2814
|
+
// stopRequested flag — and a run that "failed" with no error line
|
|
2815
|
+
// anywhere took a code read to explain (rank-desk, 2026-09-13).
|
|
2816
|
+
const witness = run.steps.findLast((s) => s.status === "completed" || s.status === "failed") ?? run.steps[0];
|
|
2817
|
+
witness?.events.push({
|
|
2818
|
+
t: new Date().toISOString(),
|
|
2819
|
+
type: "info",
|
|
2820
|
+
text: `run stopped by a person between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
|
|
2821
|
+
});
|
|
2566
2822
|
run.status = "failed";
|
|
2567
2823
|
run.stopRequested = true;
|
|
2568
2824
|
save();
|
|
@@ -2597,39 +2853,51 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2597
2853
|
break;
|
|
2598
2854
|
}
|
|
2599
2855
|
const ctx = context;
|
|
2600
|
-
//
|
|
2856
|
+
// The previous group's result — see routingBefore.
|
|
2857
|
+
const routing = routingBefore(gi);
|
|
2858
|
+
// `when:` — skip a step whose condition isn't met by the previous
|
|
2859
|
+
// group's result.
|
|
2601
2860
|
for (const step of freshGroup) {
|
|
2602
2861
|
if (step.status !== "pending" || !step.when)
|
|
2603
2862
|
continue;
|
|
2604
|
-
|
|
2863
|
+
// A marker at the start of a line, not a word anywhere in the
|
|
2864
|
+
// prose — see markerPresent. "There are no BLOCKED items" must
|
|
2865
|
+
// not open a gate keyed on BLOCKED.
|
|
2866
|
+
const met = markerPresent(routing, step.when);
|
|
2605
2867
|
if (!met) {
|
|
2606
2868
|
step.status = "skipped";
|
|
2607
2869
|
step.skipReason = `condition not met: when "${step.when}"`;
|
|
2608
2870
|
step.events.push({
|
|
2609
2871
|
t: new Date().toISOString(),
|
|
2610
2872
|
type: "info",
|
|
2611
|
-
text: `skipped — previous
|
|
2873
|
+
text: `skipped — the previous group's result has no line beginning "${step.when}" (when: reads a marker at the start of a line of the previous result, not a word in a sentence)`,
|
|
2612
2874
|
});
|
|
2613
2875
|
}
|
|
2614
2876
|
}
|
|
2615
2877
|
save();
|
|
2616
2878
|
// `case:`/`else:` — exclusive routing. Of this group's case steps,
|
|
2617
|
-
// the FIRST whose
|
|
2618
|
-
// rest are routed past. `else:` runs only when no case matched.
|
|
2879
|
+
// the FIRST whose marker leads a line of the previous results runs;
|
|
2880
|
+
// the rest are routed past. `else:` runs only when no case matched.
|
|
2619
2881
|
// (`when:` above stays independent — every matching when runs —
|
|
2620
2882
|
// which is why routing is its own vocabulary instead of a mode.)
|
|
2883
|
+
//
|
|
2884
|
+
// Same marker rule as `when:`, and it matters more here: routing is
|
|
2885
|
+
// exclusive, so a label matched out of a sentence does not merely
|
|
2886
|
+
// run an extra step, it sends the flow down the wrong branch and
|
|
2887
|
+
// skips the right one. "This is not a COMPLAINT, it is a QUESTION"
|
|
2888
|
+
// used to route to the complaints handler.
|
|
2621
2889
|
const caseSteps = freshGroup.filter((s) => s.status === "pending" && s.case);
|
|
2622
2890
|
if (caseSteps.length || freshGroup.some((s) => s.status === "pending" && s.else)) {
|
|
2623
2891
|
let matchedCase = null;
|
|
2624
2892
|
for (const step of caseSteps) {
|
|
2625
|
-
if (!matchedCase && (
|
|
2893
|
+
if (!matchedCase && markerPresent(routing, step.case)) {
|
|
2626
2894
|
matchedCase = step;
|
|
2627
2895
|
continue;
|
|
2628
2896
|
}
|
|
2629
2897
|
step.status = "skipped";
|
|
2630
2898
|
step.skipReason = matchedCase
|
|
2631
2899
|
? `routed past — "${matchedCase.case}" matched first`
|
|
2632
|
-
: `case not matched: "${step.case}"`;
|
|
2900
|
+
: `case not matched: the previous group's result has no line beginning "${step.case}"`;
|
|
2633
2901
|
step.events.push({
|
|
2634
2902
|
t: new Date().toISOString(),
|
|
2635
2903
|
type: "info",
|
|
@@ -2671,6 +2939,20 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2671
2939
|
});
|
|
2672
2940
|
}
|
|
2673
2941
|
run.status = "awaiting-approval";
|
|
2942
|
+
// approve_within: — the deadline is stamped when the gate opens,
|
|
2943
|
+
// not counted from the run's start, so "two days to answer" means
|
|
2944
|
+
// two days from being asked however long the work before it took.
|
|
2945
|
+
// Only a gate that asks a PERSON gets one: an external event has
|
|
2946
|
+
// no one to chase.
|
|
2947
|
+
const within = readFlow(tenant, workspace, run.flow)?.approveWithin ?? null;
|
|
2948
|
+
// Always assigned, never only set: a run with two gates would
|
|
2949
|
+
// otherwise carry the FIRST gate's deadline into the second, and
|
|
2950
|
+
// a deadline already in the past expires a gate the moment it
|
|
2951
|
+
// opens. Null when this gate has none.
|
|
2952
|
+
run.approveBy =
|
|
2953
|
+
within && needsApproval.some((s) => s.waitFor !== "event")
|
|
2954
|
+
? new Date(Date.now() + within * 1000).toISOString()
|
|
2955
|
+
: null;
|
|
2674
2956
|
save();
|
|
2675
2957
|
// The gate's declared preview, resolved now against what the run
|
|
2676
2958
|
// has actually produced — a glob at park time, a list of files on
|
|
@@ -2743,9 +3025,42 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2743
3025
|
// twenty steps over — the shares sum to what was left. Between
|
|
2744
3026
|
// groups the check above still refuses to start the next one.
|
|
2745
3027
|
const launching = freshGroup.filter((s) => s.status === "pending");
|
|
2746
|
-
|
|
3028
|
+
// An agent's own `budget:` — the most it may spend in one run — is
|
|
3029
|
+
// read at launch, so a cap raised mid-run applies to the next step.
|
|
3030
|
+
const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
|
|
3031
|
+
// The ceiling for one attempt of one step, read NOW: what is left of
|
|
3032
|
+
// the run's budget shared across the group, or the agent's own cap
|
|
3033
|
+
// less what its steps have cost. Computed before every attempt, not
|
|
3034
|
+
// once before the loop — a retry's share is what is left after the
|
|
3035
|
+
// failed attempt spent, and the step's own figure already carries
|
|
3036
|
+
// that spend (it is the sum of its tries).
|
|
3037
|
+
const ceilingFor = (step) => stepCeilingFor(stepCeiling(run.budgetUsd, platform.runSpend(run), launching.length), agentBudgets.get(step.agent), run.steps.filter((s) => s !== step && s.agent === step.agent).reduce((sum, s) => sum + (s.costUsd ?? 0), 0), step.agent);
|
|
3038
|
+
// parallel: — the instances of one fan-out template share a pool.
|
|
3039
|
+
// Keyed by what the instances share (agent + instruction), since
|
|
3040
|
+
// the template itself is behind them in the record as "expanded".
|
|
3041
|
+
const pools = new Map();
|
|
3042
|
+
const poolFor = (step) => {
|
|
3043
|
+
if (!step.parallel || !step.item)
|
|
3044
|
+
return null;
|
|
3045
|
+
const key = `${step.agent}\u0000${step.instruction}`;
|
|
3046
|
+
let pool = pools.get(key);
|
|
3047
|
+
if (!pool)
|
|
3048
|
+
pools.set(key, (pool = slotPool(step.parallel)));
|
|
3049
|
+
return pool;
|
|
3050
|
+
};
|
|
2747
3051
|
await Promise.all(launching
|
|
2748
3052
|
.map(async (step) => {
|
|
3053
|
+
const pool = poolFor(step);
|
|
3054
|
+
if (pool)
|
|
3055
|
+
await pool.acquire();
|
|
3056
|
+
try {
|
|
3057
|
+
await launchStep(step);
|
|
3058
|
+
}
|
|
3059
|
+
finally {
|
|
3060
|
+
pool?.release();
|
|
3061
|
+
}
|
|
3062
|
+
}));
|
|
3063
|
+
async function launchStep(step) {
|
|
2749
3064
|
const attempts = (step.retry ?? 0) + 1;
|
|
2750
3065
|
// A step re-attaching to its sandbox is still on the attempt
|
|
2751
3066
|
// that started it; a step re-run after a backoff picks up the
|
|
@@ -2753,13 +3068,45 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2753
3068
|
const interruptedInBackoff = !step.sandbox && !!step.attempts && /retrying in \d+s/.test(step.events[step.events.length - 1]?.text ?? "");
|
|
2754
3069
|
const from = step.sandbox ? Math.max(1, step.attempts ?? 1) : interruptedInBackoff ? Math.min(attempts, step.attempts + 1) : 1;
|
|
2755
3070
|
for (let attempt = from; attempt <= attempts; attempt++) {
|
|
3071
|
+
const ceiling = ceilingFor(step);
|
|
3072
|
+
// Nothing left is a refusal, not "no ceiling": a zero passed
|
|
3073
|
+
// down would read as unset, and the one step that must not
|
|
3074
|
+
// run would run with no cap at all. On a retry this is what
|
|
3075
|
+
// stops the second attempt spending a share the first one
|
|
3076
|
+
// already spent.
|
|
3077
|
+
if (ceiling.ceilingUsd === 0) {
|
|
3078
|
+
step.status = "failed";
|
|
3079
|
+
step.events.push({
|
|
3080
|
+
t: new Date().toISOString(),
|
|
3081
|
+
type: "error",
|
|
3082
|
+
text: attempt === from
|
|
3083
|
+
? `over budget — nothing left to spend before this step (${ceiling.note}); it was not started`
|
|
3084
|
+
: `over budget — nothing left to spend for attempt ${attempt} (${ceiling.note}); not retried`,
|
|
3085
|
+
});
|
|
3086
|
+
save();
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
// Re-attaching to a sandbox is the same attempt, so its row
|
|
3090
|
+
// is replaced; anything else is a new row, a loop cycle's
|
|
3091
|
+
// re-run included.
|
|
3092
|
+
const reattach = !!step.sandbox;
|
|
3093
|
+
const attemptStartedAt = new Date().toISOString();
|
|
3094
|
+
const eventsBefore = step.events.length;
|
|
2756
3095
|
step.attempts = attempt;
|
|
2757
3096
|
step.status = "running";
|
|
2758
3097
|
save();
|
|
2759
|
-
await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceilingUsd
|
|
3098
|
+
await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceiling.ceilingUsd, ceiling.note, run.test === true,
|
|
3099
|
+
// save() merges a stop written by another process into
|
|
3100
|
+
// this record; the model loop polls this on a clock.
|
|
3101
|
+
() => {
|
|
3102
|
+
save();
|
|
3103
|
+
return run.stopRequested === true;
|
|
3104
|
+
});
|
|
2760
3105
|
// runStep mutates step.status; read it through a widened local
|
|
2761
3106
|
// so TS doesn't keep the "running" narrowing from above.
|
|
2762
3107
|
const outcome = step.status;
|
|
3108
|
+
recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
|
|
3109
|
+
save();
|
|
2763
3110
|
if (outcome !== "failed" || attempt === attempts)
|
|
2764
3111
|
break;
|
|
2765
3112
|
// A stop destroys the step's sandbox, which reads here as a
|
|
@@ -2807,7 +3154,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2807
3154
|
break;
|
|
2808
3155
|
}
|
|
2809
3156
|
}
|
|
2810
|
-
}
|
|
3157
|
+
}
|
|
2811
3158
|
// on-fail: another agent takes the step over. Sequential and rare:
|
|
2812
3159
|
// the rescue is a second full attempt at the step's job with the
|
|
2813
3160
|
// failure in front of it, and its success is the step's success —
|
|
@@ -2859,7 +3206,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2859
3206
|
rescue.attempts = 1;
|
|
2860
3207
|
rescue.status = "running";
|
|
2861
3208
|
save();
|
|
2862
|
-
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData);
|
|
3209
|
+
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true);
|
|
2863
3210
|
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
2864
3211
|
const rescueOutcome = rescue.status;
|
|
2865
3212
|
if (rescueOutcome === "completed") {
|
|
@@ -2992,16 +3339,25 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
2992
3339
|
}
|
|
2993
3340
|
catch (err) {
|
|
2994
3341
|
run.status = "failed";
|
|
3342
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
3343
|
+
let told = false;
|
|
2995
3344
|
run.steps.forEach((s) => {
|
|
2996
3345
|
if (s.status === "running") {
|
|
2997
3346
|
s.status = "failed";
|
|
2998
|
-
s.events.push({
|
|
2999
|
-
|
|
3000
|
-
type: "error",
|
|
3001
|
-
text: err instanceof Error ? err.message : String(err),
|
|
3002
|
-
});
|
|
3347
|
+
s.events.push({ t: new Date().toISOString(), type: "error", text });
|
|
3348
|
+
told = true;
|
|
3003
3349
|
}
|
|
3004
3350
|
});
|
|
3351
|
+
// Thrown between groups, with nothing running: the error still has to
|
|
3352
|
+
// land on some step's trail, or the run reads as failed for no reason.
|
|
3353
|
+
if (!told) {
|
|
3354
|
+
const witness = run.steps.findLast((s) => s.status !== "pending") ?? run.steps[0];
|
|
3355
|
+
witness?.events.push({
|
|
3356
|
+
t: new Date().toISOString(),
|
|
3357
|
+
type: "error",
|
|
3358
|
+
text: `run failed between steps: ${text}`,
|
|
3359
|
+
});
|
|
3360
|
+
}
|
|
3005
3361
|
}
|
|
3006
3362
|
finally {
|
|
3007
3363
|
if (!parked) {
|
|
@@ -3012,6 +3368,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3012
3368
|
// learn, and the runs list and the notification stop having to say
|
|
3013
3369
|
// only "completed".
|
|
3014
3370
|
run.summary = runSummary(run);
|
|
3371
|
+
// A test run's headline says so wherever the headline goes — the
|
|
3372
|
+
// runs list, the history tool, a later run's recall.
|
|
3373
|
+
if (run.test && run.summary)
|
|
3374
|
+
run.summary = testHeadline(run.summary);
|
|
3015
3375
|
// Archive before the next run resets outputs/ — a failed run's
|
|
3016
3376
|
// artifacts are usually the most interesting ones, so this runs on
|
|
3017
3377
|
// failure too.
|
|
@@ -3049,17 +3409,6 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3049
3409
|
}
|
|
3050
3410
|
})();
|
|
3051
3411
|
}
|
|
3052
|
-
function readFlow(tenant, workspace, flowName) {
|
|
3053
|
-
const dir = path.join(workspaceDir(tenant, workspace), "flows");
|
|
3054
|
-
if (!fs.existsSync(dir))
|
|
3055
|
-
return null;
|
|
3056
|
-
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
3057
|
-
const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
|
|
3058
|
-
if (flow.name === flowName)
|
|
3059
|
-
return flow;
|
|
3060
|
-
}
|
|
3061
|
-
return null;
|
|
3062
|
-
}
|
|
3063
3412
|
// Splice `[[flow:other]]` steps into the parent's step list, renumbering
|
|
3064
3413
|
// groups so the nested flow's own parallelism survives. Depth-limited, and
|
|
3065
3414
|
// a cycle raises rather than expanding forever.
|