@foldrun/core 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent-import.js +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- package/dist/src/completions.js +25 -3
- package/dist/src/deploy.js +72 -7
- package/dist/src/evals.d.ts +22 -1
- package/dist/src/evals.js +43 -14
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +51 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +30 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/matter.d.ts +34 -0
- package/dist/src/matter.js +104 -0
- package/dist/src/network.d.ts +0 -1
- package/dist/src/network.js +10 -7
- package/dist/src/package.js +9 -2
- package/dist/src/provider-health.js +2 -2
- package/dist/src/run-container.d.ts +9 -0
- package/dist/src/run-container.js +4 -1
- package/dist/src/runner.js +119 -36
- package/dist/src/scan.d.ts +11 -0
- package/dist/src/scan.js +86 -8
- package/dist/src/starter.js +23 -0
- package/dist/src/step-exec.d.ts +10 -2
- package/dist/src/step-exec.js +12 -9
- package/dist/src/store.d.ts +17 -3
- package/dist/src/store.js +14 -10
- package/package.json +1 -1
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/completions.ts +26 -3
- package/src/deploy.ts +59 -7
- package/src/evals.ts +45 -13
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +74 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +30 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/judge-verdict.ts +52 -0
- package/src/matter.ts +112 -0
- package/src/network.ts +10 -6
- package/src/package.ts +8 -2
- package/src/provider-health.ts +2 -2
- package/src/run-container.ts +11 -1
- package/src/runner.ts +118 -37
- package/src/scan.ts +86 -8
- package/src/starter.ts +23 -0
- package/src/step-exec.ts +21 -8
- package/src/store.ts +23 -10
package/src/runner.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { hookNames } from "./hooks.ts";
|
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import matter from "./matter.ts";
|
|
13
|
+
import matter, { parseLenient, configMatter } from "./matter.ts";
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import {
|
|
16
16
|
knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
|
|
@@ -21,6 +21,7 @@ import { eventUrl } from "./webhook.ts";
|
|
|
21
21
|
import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
|
|
22
22
|
import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
|
|
23
23
|
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.ts";
|
|
24
|
+
import { parseJudge } from "./judge-verdict.ts";
|
|
24
25
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
25
26
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
|
|
26
27
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
|
|
@@ -70,6 +71,7 @@ import {
|
|
|
70
71
|
checkFormatVersion,
|
|
71
72
|
readRun,
|
|
72
73
|
writeRun,
|
|
74
|
+
isRescueRecord,
|
|
73
75
|
runSummary,
|
|
74
76
|
verdictOf,
|
|
75
77
|
type FlowStep,
|
|
@@ -335,28 +337,9 @@ const toTags = (v: unknown): string[] =>
|
|
|
335
337
|
// rather than dropping the skill, so a cross-client skill still loads here.
|
|
336
338
|
function safeMatter(raw: string): { data: Record<string, unknown> } {
|
|
337
339
|
// gray-matter caches a *failed* parse as a hollow success; ./matter.ts
|
|
338
|
-
// parses every call, so the
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
return matter(raw) as { data: Record<string, unknown> };
|
|
342
|
-
} catch {
|
|
343
|
-
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
344
|
-
if (fm) {
|
|
345
|
-
const fixed = fm[1].replace(
|
|
346
|
-
/^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm,
|
|
347
|
-
(_m, k: string, v: string) => `${k}: ${JSON.stringify(v)}`,
|
|
348
|
-
);
|
|
349
|
-
try {
|
|
350
|
-
// A function replacer, not a string: a fixed block containing `$&` or
|
|
351
|
-
// `$$` (a value with those in it) would otherwise be treated as a
|
|
352
|
-
// replacement pattern and splice the whole frontmatter into itself.
|
|
353
|
-
return matter(raw.replace(fm[1], () => fixed)) as { data: Record<string, unknown> };
|
|
354
|
-
} catch {
|
|
355
|
-
/* fall through */
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
return { data: {} };
|
|
359
|
-
}
|
|
340
|
+
// parses every call, so the strict read throws every time and the lenient
|
|
341
|
+
// one (shared with check, which warns about it) fires every time.
|
|
342
|
+
return { data: parseLenient(raw).data };
|
|
360
343
|
}
|
|
361
344
|
|
|
362
345
|
// Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
|
|
@@ -452,7 +435,7 @@ export function readAgentsMd(dir: string): { data: Record<string, unknown>; body
|
|
|
452
435
|
const file = path.join(dir, name);
|
|
453
436
|
if (!fs.existsSync(file)) continue;
|
|
454
437
|
try {
|
|
455
|
-
const parsed =
|
|
438
|
+
const parsed = configMatter(fs.readFileSync(file, "utf8"));
|
|
456
439
|
return { data: parsed.data as Record<string, unknown>, body: withoutAgentRules(parsed.content).trim() };
|
|
457
440
|
} catch {
|
|
458
441
|
return null;
|
|
@@ -855,7 +838,7 @@ export function agentContext(
|
|
|
855
838
|
// agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
|
|
856
839
|
// workspace a secret should resolve against is two levels up.
|
|
857
840
|
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
858
|
-
const { data: front, content: body } =
|
|
841
|
+
const { data: front, content: body } = configMatter(
|
|
859
842
|
fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"),
|
|
860
843
|
);
|
|
861
844
|
// The prompt's sections, by name and by how often they change. Added in
|
|
@@ -2089,6 +2072,15 @@ async function runStep(
|
|
|
2089
2072
|
// AGENTS.md — every model call always; each tool call's input and output
|
|
2090
2073
|
// unless it says `metadata`.
|
|
2091
2074
|
const capture: "metadata" | "full" = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
|
|
2075
|
+
// `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
|
|
2076
|
+
// grades with — field by field, the workspace's else the account's.
|
|
2077
|
+
const { judgeModel, judgeEffort } = (() => {
|
|
2078
|
+
const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
|
|
2079
|
+
const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
|
|
2080
|
+
const model = ws.model ?? account.model;
|
|
2081
|
+
const effort = resolveEffort(ws.effort ?? account.effort ?? "");
|
|
2082
|
+
return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
|
|
2083
|
+
})();
|
|
2092
2084
|
// A step that pours out content stops being recorded past this — its
|
|
2093
2085
|
// calls keep their names, sizes and times (FOL-54 step 4).
|
|
2094
2086
|
let capturedChars = 0;
|
|
@@ -2857,6 +2849,8 @@ async function runStep(
|
|
|
2857
2849
|
budgetNote: stepBudgetNote,
|
|
2858
2850
|
price: tokenPrice,
|
|
2859
2851
|
verify: step.verify,
|
|
2852
|
+
...(judgeModel ? { judgeModel } : {}),
|
|
2853
|
+
...(judgeEffort ? { judgeEffort } : {}),
|
|
2860
2854
|
output: step.output,
|
|
2861
2855
|
schema: resolvedSchema ?? undefined,
|
|
2862
2856
|
maxTurns: step.maxTurns,
|
|
@@ -2913,6 +2907,9 @@ async function runStep(
|
|
|
2913
2907
|
emit: pushWatching,
|
|
2914
2908
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
2915
2909
|
runId,
|
|
2910
|
+
// A step still waiting for room has no sandbox to destroy; the
|
|
2911
|
+
// executor asks this before it creates one (pod-room.ts).
|
|
2912
|
+
...(stopRequested ? { stopRequested } : {}),
|
|
2916
2913
|
size,
|
|
2917
2914
|
// The pod's network, for the executor's own wall (the k8s tier's
|
|
2918
2915
|
// network policy): a filtered pod reaches the proxy and DNS only.
|
|
@@ -3252,6 +3249,8 @@ async function runStep(
|
|
|
3252
3249
|
budgetNote: stepBudgetNote,
|
|
3253
3250
|
price: tokenPrice,
|
|
3254
3251
|
verify: step.verify,
|
|
3252
|
+
...(judgeModel ? { judgeModel } : {}),
|
|
3253
|
+
...(judgeEffort ? { judgeEffort } : {}),
|
|
3255
3254
|
output: step.output,
|
|
3256
3255
|
schema: resolvedSchema ?? undefined,
|
|
3257
3256
|
maxTurns: step.maxTurns,
|
|
@@ -3455,7 +3454,7 @@ export function copyTreeBytes(from: string, to: string) {
|
|
|
3455
3454
|
function openApiSources(agentDir: string, tenant: string): string[] {
|
|
3456
3455
|
const out = new Set<string>();
|
|
3457
3456
|
try {
|
|
3458
|
-
const { data } =
|
|
3457
|
+
const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
|
|
3459
3458
|
const front = data as { apis?: unknown; tools?: unknown };
|
|
3460
3459
|
for (const api of parseApis(front.apis)) if (api.openapi?.startsWith("https://")) out.add(api.openapi);
|
|
3461
3460
|
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
@@ -4080,10 +4079,30 @@ function driveRunInner(
|
|
|
4080
4079
|
*
|
|
4081
4080
|
* The flag is one-way: nothing here clears it, so the read-modify-write can
|
|
4082
4081
|
* only ever turn a stop on, never off.
|
|
4082
|
+
*
|
|
4083
|
+
* And once it is on, this copy may not undo what stopRun wrote: the run
|
|
4084
|
+
* stays failed, and steps that had not started stay skipped. Before
|
|
4085
|
+
* 21 Oct 2026 only the flag came across, so the next save put the record
|
|
4086
|
+
* back to `running` with its steps pending; a second stop then answered
|
|
4087
|
+
* "failed" instead of "already failed", and the group kept starting pods
|
|
4088
|
+
* (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
|
|
4089
|
+
* as it is: its sandbox is gone, it ends on its own, and records how.
|
|
4083
4090
|
*/
|
|
4084
4091
|
const save = () => {
|
|
4085
|
-
|
|
4086
|
-
|
|
4092
|
+
const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
|
|
4093
|
+
if (disk?.stopRequested) run.stopRequested = true;
|
|
4094
|
+
if (run.stopRequested) {
|
|
4095
|
+
if (run.status !== "completed" && run.status !== "failed") {
|
|
4096
|
+
run.status = "failed";
|
|
4097
|
+
run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
|
|
4098
|
+
}
|
|
4099
|
+
run.parkedAt = null;
|
|
4100
|
+
for (const s of run.steps) {
|
|
4101
|
+
if (s.status === "pending" || s.status === "awaiting-approval") {
|
|
4102
|
+
s.status = "skipped";
|
|
4103
|
+
s.skipReason ??= "run stopped";
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4087
4106
|
}
|
|
4088
4107
|
writeRun(tenant, workspace, run);
|
|
4089
4108
|
};
|
|
@@ -4301,13 +4320,20 @@ function driveRunInner(
|
|
|
4301
4320
|
for (let i = gi - 1; i >= 0; i--) if (groupData[i] !== undefined) return groupData[i];
|
|
4302
4321
|
return undefined;
|
|
4303
4322
|
};
|
|
4304
|
-
|
|
4323
|
+
// A rescue's result already stands on the step it took over, so the
|
|
4324
|
+
// three handoffs below read the flow's steps only: counting the rescue
|
|
4325
|
+
// too handed the next group the same output twice, or a stale one
|
|
4326
|
+
// from an evaluator loop's earlier cycle.
|
|
4327
|
+
const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !isRescueRecord(s));
|
|
4328
|
+
const dataOf = (all: StepRecord[]): unknown => {
|
|
4329
|
+
const group = flowStepsOf(all);
|
|
4305
4330
|
const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
|
|
4306
4331
|
if (carrying.length === 0) return undefined;
|
|
4307
4332
|
if (carrying.length === 1 && !carrying[0].item) return carrying[0].data;
|
|
4308
4333
|
return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
|
|
4309
4334
|
};
|
|
4310
|
-
const resultsOf = (
|
|
4335
|
+
const resultsOf = (all: StepRecord[]): string | null => {
|
|
4336
|
+
const group = flowStepsOf(all);
|
|
4311
4337
|
const results = group
|
|
4312
4338
|
.filter((s) => s.status === "completed" && s.result)
|
|
4313
4339
|
.map((s) =>
|
|
@@ -4321,8 +4347,8 @@ function driveRunInner(
|
|
|
4321
4347
|
};
|
|
4322
4348
|
// What each step concluded — its final turn — for the handoffs that
|
|
4323
4349
|
// should read a conclusion rather than a transcript (`each: lines`).
|
|
4324
|
-
const conclusionsOf = (
|
|
4325
|
-
const out =
|
|
4350
|
+
const conclusionsOf = (all: StepRecord[]): string | null => {
|
|
4351
|
+
const out = flowStepsOf(all)
|
|
4326
4352
|
.filter((s) => s.status === "completed" && (s.conclusion || s.result))
|
|
4327
4353
|
.map((s) => s.conclusion || s.result!);
|
|
4328
4354
|
return out.length ? out.join("\n") : null;
|
|
@@ -4709,7 +4735,7 @@ function driveRunInner(
|
|
|
4709
4735
|
save();
|
|
4710
4736
|
if (freshGroup.every((s) => s.status !== "pending")) {
|
|
4711
4737
|
// everything in this group was rejected
|
|
4712
|
-
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
|
|
4738
|
+
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
|
|
4713
4739
|
run.status = "failed";
|
|
4714
4740
|
break;
|
|
4715
4741
|
}
|
|
@@ -4723,7 +4749,11 @@ function driveRunInner(
|
|
|
4723
4749
|
// itself mid-turn at its share, so a fan-out of twenty cannot end
|
|
4724
4750
|
// twenty steps over — the shares sum to what was left. Between
|
|
4725
4751
|
// groups the check above still refuses to start the next one.
|
|
4726
|
-
|
|
4752
|
+
// A rescue record is the on-fail of a step in this group, not a step
|
|
4753
|
+
// of the flow: one left pending by a drive that died is picked up by
|
|
4754
|
+
// rescueStep below, never launched alongside the group (21 Oct 2026:
|
|
4755
|
+
// a resumed run launched 77 leftover fixers at once).
|
|
4756
|
+
const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
|
|
4727
4757
|
// tools: [peers] — what each step of the group is told about the
|
|
4728
4758
|
// others, and the bus they share when they run in this process.
|
|
4729
4759
|
const peerBus = localPeerBus();
|
|
@@ -4788,7 +4818,7 @@ function driveRunInner(
|
|
|
4788
4818
|
// lead's reply or for a slot — must not start it: the
|
|
4789
4819
|
// between-groups check runs only once this group is over,
|
|
4790
4820
|
// and save() is what carries the flag in from the record.
|
|
4791
|
-
|
|
4821
|
+
{
|
|
4792
4822
|
save();
|
|
4793
4823
|
if (run.stopRequested) {
|
|
4794
4824
|
step.status = "skipped";
|
|
@@ -4968,6 +4998,13 @@ function driveRunInner(
|
|
|
4968
4998
|
// runs out of cycles below, which used to fail the run without ever
|
|
4969
4999
|
// reaching the step's on-fail (medium-desk's claim-trimmer was
|
|
4970
5000
|
// written for exactly that case and could never run).
|
|
5001
|
+
// A rescue hears a stop the way a group step does: its model loop and
|
|
5002
|
+
// its executor's wait for room both poll this (no sandbox exists yet
|
|
5003
|
+
// for a stop to destroy while it waits).
|
|
5004
|
+
const stopPoll = () => {
|
|
5005
|
+
save();
|
|
5006
|
+
return run.stopRequested === true;
|
|
5007
|
+
};
|
|
4971
5008
|
const rescueStep = async (step: StepRecord): Promise<void> => {
|
|
4972
5009
|
if (!step.onFail) return;
|
|
4973
5010
|
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
@@ -4979,6 +5016,38 @@ function driveRunInner(
|
|
|
4979
5016
|
});
|
|
4980
5017
|
return;
|
|
4981
5018
|
}
|
|
5019
|
+
// One rescue per failure, across drives. A worker that died
|
|
5020
|
+
// mid-rescue left an unfinished record behind it; the drive that
|
|
5021
|
+
// resumes picks that one up instead of splicing another — a crash
|
|
5022
|
+
// loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
|
|
5023
|
+
// ~80). The leftover carries the same instruction, built from the
|
|
5024
|
+
// same failure, and a running one may still hold a sandbox to
|
|
5025
|
+
// re-attach to, so it is THE rescue; any further unfinished copies
|
|
5026
|
+
// are superseded. A FINISHED rescue is history, never reused: it
|
|
5027
|
+
// answered an earlier failure — an evaluator loop's previous cycle
|
|
5028
|
+
// — not this one.
|
|
5029
|
+
const leftovers: StepRecord[] = [];
|
|
5030
|
+
for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
|
|
5031
|
+
const r = run.steps[j];
|
|
5032
|
+
if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
|
|
5033
|
+
leftovers.push(r);
|
|
5034
|
+
}
|
|
5035
|
+
}
|
|
5036
|
+
const resumed = leftovers[0];
|
|
5037
|
+
for (const r of leftovers.slice(1)) {
|
|
5038
|
+
r.status = "skipped";
|
|
5039
|
+
r.skipReason = "superseded on resume — one rescue per failure";
|
|
5040
|
+
}
|
|
5041
|
+
if (resumed) {
|
|
5042
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
|
|
5043
|
+
resumed.rescueOf ??= step.agent;
|
|
5044
|
+
resumed.attempts = Math.max(1, resumed.attempts ?? 0);
|
|
5045
|
+
resumed.status = "running";
|
|
5046
|
+
save();
|
|
5047
|
+
await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
|
|
5048
|
+
settleRescue(step, resumed);
|
|
5049
|
+
return;
|
|
5050
|
+
}
|
|
4982
5051
|
step.events.push({
|
|
4983
5052
|
t: new Date().toISOString(),
|
|
4984
5053
|
type: "info",
|
|
@@ -4995,6 +5064,7 @@ function driveRunInner(
|
|
|
4995
5064
|
const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
|
|
4996
5065
|
const rescue: StepRecord = {
|
|
4997
5066
|
agent: step.onFail,
|
|
5067
|
+
rescueOf: step.agent,
|
|
4998
5068
|
instruction:
|
|
4999
5069
|
`${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
|
|
5000
5070
|
`Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
|
|
@@ -5025,7 +5095,11 @@ function driveRunInner(
|
|
|
5025
5095
|
rescue.attempts = 1;
|
|
5026
5096
|
rescue.status = "running";
|
|
5027
5097
|
save();
|
|
5028
|
-
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true,
|
|
5098
|
+
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
|
|
5099
|
+
settleRescue(step, rescue);
|
|
5100
|
+
};
|
|
5101
|
+
// What a rescue's end means for the step it took over.
|
|
5102
|
+
const settleRescue = (step: StepRecord, rescue: StepRecord): void => {
|
|
5029
5103
|
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
5030
5104
|
const rescueOutcome: string = rescue.status;
|
|
5031
5105
|
if (rescueOutcome === "completed") {
|
|
@@ -5057,7 +5131,10 @@ function driveRunInner(
|
|
|
5057
5131
|
// here: the person chose to start after it. Counting it stopped
|
|
5058
5132
|
// fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
|
|
5059
5133
|
// a second, before the one step it was asked to run.
|
|
5060
|
-
|
|
5134
|
+
// A rescue record's own failure is already on the step it took over
|
|
5135
|
+
// ("also failed"); counted here it would fail a group whose step a
|
|
5136
|
+
// later rescue completed.
|
|
5137
|
+
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
|
|
5061
5138
|
if (requiredFailed) {
|
|
5062
5139
|
run.status = "failed";
|
|
5063
5140
|
break;
|
|
@@ -5133,6 +5210,10 @@ function driveRunInner(
|
|
|
5133
5210
|
});
|
|
5134
5211
|
for (const s of [...ordered[gi - 1], ...freshGroup]) {
|
|
5135
5212
|
if (s.status === "skipped" && s.each) continue; // an expanded template stays expanded
|
|
5213
|
+
// A rescue answered its own failure and stays as it ended: reset
|
|
5214
|
+
// to pending it read as an interrupted rescue, and the next
|
|
5215
|
+
// cycle's failure was handed the old one's instructions.
|
|
5216
|
+
if (isRescueRecord(s)) continue;
|
|
5136
5217
|
s.status = "pending";
|
|
5137
5218
|
s.result = null;
|
|
5138
5219
|
}
|
package/src/scan.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// reported, because "why is this allowed?" is the question a reviewer asks.
|
|
28
28
|
|
|
29
29
|
import crypto from "node:crypto";
|
|
30
|
-
import matter from "./matter.ts";
|
|
30
|
+
import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, configMatter } from "./matter.ts";
|
|
31
31
|
import { refNames } from "./refs.ts";
|
|
32
32
|
import { resolveNetwork } from "./network.ts";
|
|
33
33
|
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
|
|
@@ -223,6 +223,35 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
223
223
|
"`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
|
|
224
224
|
"so the agent or flow quietly does something else. The message lists what the field takes.",
|
|
225
225
|
},
|
|
226
|
+
{
|
|
227
|
+
id: "config/conflict",
|
|
228
|
+
category: "config",
|
|
229
|
+
level: "error",
|
|
230
|
+
title: "A field set twice: at the top and inside its job block",
|
|
231
|
+
help:
|
|
232
|
+
"`limits:` at the top and `limits:` inside `guardrail:` cannot both be meant. The top one would be read and the other " +
|
|
233
|
+
"quietly ignored, so the file is refused until one is removed.",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "config/misplaced",
|
|
237
|
+
category: "config",
|
|
238
|
+
level: "error",
|
|
239
|
+
title: "A field inside the wrong job block",
|
|
240
|
+
help:
|
|
241
|
+
"Each of the four blocks (governance, guardrail, observability, evaluation) holds its own fields. A setting written in " +
|
|
242
|
+
"another block is not read — a guardrail under governance: guards nothing — so the file is refused until it is moved " +
|
|
243
|
+
"to the block the message names, or to the top. A word foldrun does not read at all is only a note.",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "config/invalid-frontmatter",
|
|
247
|
+
category: "config",
|
|
248
|
+
level: "warn",
|
|
249
|
+
title: "Frontmatter that YAML cannot read",
|
|
250
|
+
help:
|
|
251
|
+
"A value with a colon followed by a space (`description: the format (Markdown, ::: directives)`) ends the YAML. " +
|
|
252
|
+
"foldrun reads a skill like that by quoting the value, but the dashboard editor and the source API refuse to save it, " +
|
|
253
|
+
"and other tools that read the file may not. Quote the value: `description: \"…\"`.",
|
|
254
|
+
},
|
|
226
255
|
{
|
|
227
256
|
id: "config/missing-field",
|
|
228
257
|
category: "config",
|
|
@@ -601,17 +630,31 @@ export function nearestField(key: string, known: readonly string[]): string | nu
|
|
|
601
630
|
}
|
|
602
631
|
|
|
603
632
|
function keyLine(lines: string[], key: string): number | undefined {
|
|
604
|
-
const
|
|
605
|
-
const
|
|
606
|
-
|
|
633
|
+
const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
634
|
+
const top = new RegExp(`^${k}\\s*:`);
|
|
635
|
+
const i = lines.findIndex((l) => top.test(l));
|
|
636
|
+
if (i >= 0) return i + 1;
|
|
637
|
+
// A key inside one of the four job blocks: indented, under its header.
|
|
638
|
+
const inner = new RegExp(`^\\s+${k}\\s*:`);
|
|
639
|
+
let inBlock = false;
|
|
640
|
+
for (let n = 0; n < lines.length; n++) {
|
|
641
|
+
const l = lines[n]!;
|
|
642
|
+
if (/^(governance|guardrail|observability|evaluation)\s*:\s*(#.*)?$/.test(l)) inBlock = true;
|
|
643
|
+
else if (/^\S/.test(l)) inBlock = false;
|
|
644
|
+
else if (inBlock && inner.test(l)) return n + 1;
|
|
645
|
+
}
|
|
646
|
+
return undefined;
|
|
607
647
|
}
|
|
608
648
|
|
|
609
649
|
/** Read-only: gray-matter caches by content, so two files with the same
|
|
610
650
|
* text share one object, and writing to it would leak between them. */
|
|
611
|
-
function frontOf(content: string): Record<string, unknown> | null {
|
|
651
|
+
function frontOf(content: string, filePath?: string): Record<string, unknown> | null {
|
|
612
652
|
if (!/^?---\r?\n/.test(content)) return null;
|
|
613
653
|
try {
|
|
614
|
-
|
|
654
|
+
// An agent, a flow or AGENTS.md reads its job blocks as flat keys, as
|
|
655
|
+
// the runtime does; every other document is read as written.
|
|
656
|
+
const kind = filePath ? schemaKindOf(filePath) : null;
|
|
657
|
+
return (kind === "agent" || kind === "flow" || kind === "agents-md" ? configMatter(content) : matter(content)).data as Record<string, unknown>;
|
|
615
658
|
} catch {
|
|
616
659
|
return null; // a frontmatter YAML cannot read is check's finding, not ours
|
|
617
660
|
}
|
|
@@ -773,6 +816,16 @@ const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|
|
|
|
773
816
|
* since a key in a log here is a key in the workspace (seen live on
|
|
774
817
|
* 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
|
|
775
818
|
* text). */
|
|
819
|
+
/** The definition files a deploy refuses outright when their frontmatter does
|
|
820
|
+
* not parse (deploy.ts deployIssues): the workspace's own, by these paths.
|
|
821
|
+
* Everything else the platform reads frontmatter from — skills, and the
|
|
822
|
+
* account library's agents, tools and hooks — is the scanner's to report. */
|
|
823
|
+
export const STRICT_FRONTMATTER_PATH = /^(AGENTS\.md|agents\/[^/]+\/agent\.md|flows\/[^/]+\.md|tools\/[^/]+\.md|tools\/[^/]+\/tool\.md|evals\/[^/]+\.md|hooks\/[^/]+\/hook\.md|\.claude\/agents\/[^/]+\.md)$/;
|
|
824
|
+
|
|
825
|
+
/** A skill file in either layout the runtime discovers: <name>/SKILL.md, or
|
|
826
|
+
* the flat <name>.md, under skills/ or .agents/skills/, in the library too. */
|
|
827
|
+
const SKILL_FILE = /(?:^|\/)skills\/(?:[^/]+\/SKILL\.md|[^/]+\.md)$/;
|
|
828
|
+
|
|
776
829
|
const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.foldrun)\//;
|
|
777
830
|
|
|
778
831
|
/** Text files only — a PNG read as UTF-8 is noise to every rule here. */
|
|
@@ -833,9 +886,34 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
833
886
|
scanSupplyChain(f, lines, push);
|
|
834
887
|
|
|
835
888
|
const schema = schemaKindOf(f.path);
|
|
889
|
+
// Frontmatter YAML cannot read. Until 2026-10-21 nothing reported it for
|
|
890
|
+
// a skill or a library file: frontOf below reads it as "no frontmatter"
|
|
891
|
+
// and moves on, and the deploy's own refusal covers only the workspace's
|
|
892
|
+
// definition files. A vaza.ai skill with `description: … (Markdown,
|
|
893
|
+
// frontmatter, ::: directives) …` deployed clean, ran (the runtime quotes
|
|
894
|
+
// the value and retries), and was refused by the source API on its next
|
|
895
|
+
// save.
|
|
896
|
+
if ((schema || SKILL_FILE.test(f.path)) && !STRICT_FRONTMATTER_PATH.test(f.path) && /^\uFEFF?---\r?\n/.test(f.content)) {
|
|
897
|
+
let err: unknown = null;
|
|
898
|
+
try {
|
|
899
|
+
matter(f.content);
|
|
900
|
+
} catch (e) {
|
|
901
|
+
err = e;
|
|
902
|
+
}
|
|
903
|
+
if (err) {
|
|
904
|
+
const isSkill = schema === "skill" || SKILL_FILE.test(f.path);
|
|
905
|
+
const read = parseLenient(f.content);
|
|
906
|
+
const what = isSkill && !read.failed
|
|
907
|
+
? "foldrun reads this skill anyway by quoting the value, but the dashboard editor and the source API refuse to save it, and other skills clients may not load it"
|
|
908
|
+
: isSkill
|
|
909
|
+
? "the runtime reads it as empty, so the skill has no name or description and is never loaded"
|
|
910
|
+
: "the runtime cannot read this file";
|
|
911
|
+
push("config/invalid-frontmatter", f.path, frontmatterErrorLine(err), `config/invalid-frontmatter ${frontmatterErrorText(err)}`, `${frontmatterErrorText(err)} ${what === "the runtime cannot read this file" ? "The runtime cannot read this file" : what.replace(/^foldrun/, "foldrun").replace(/^the/, "The")}.`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
836
914
|
// A skill with no frontmatter at all is the clearest one that never
|
|
837
915
|
// loads: read it as empty, so its required fields are reported.
|
|
838
|
-
const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
|
|
916
|
+
const data = schema || docKind(f.path) ? (frontOf(f.content, f.path) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
|
|
839
917
|
if (!data) continue;
|
|
840
918
|
if (schema) {
|
|
841
919
|
for (const p of frontmatterProblems(schema, data, nearestField)) {
|
|
@@ -859,7 +937,7 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
859
937
|
}
|
|
860
938
|
// project.md is the older name the runtime still reads (Codex review, FOL-58).
|
|
861
939
|
const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
|
|
862
|
-
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
|
|
940
|
+
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
|
|
863
941
|
for (const m of more.values()) {
|
|
864
942
|
findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
|
|
865
943
|
}
|
package/src/starter.ts
CHANGED
|
@@ -62,6 +62,22 @@ foldrun_version: "0.1"
|
|
|
62
62
|
#
|
|
63
63
|
# The token is sent as a bearer credential. A gateway that wants it as a key
|
|
64
64
|
# header asks for that by name: headers: { x-api-key: \${THE_SECRET} }.
|
|
65
|
+
#
|
|
66
|
+
# The four jobs of running agents, each a block — or write the same keys at
|
|
67
|
+
# the top; one place or the other, never both:
|
|
68
|
+
#
|
|
69
|
+
# governance: # who answers for it, what it may spend and ship
|
|
70
|
+
# owner: you@example.com
|
|
71
|
+
# budget: 60/month
|
|
72
|
+
# deploy_block: secrets
|
|
73
|
+
# guardrail: # what agents may reach and call
|
|
74
|
+
# limits: {calls: 300, repeat: 5}
|
|
75
|
+
# network: trusted
|
|
76
|
+
# observability: # what is recorded, and who is told
|
|
77
|
+
# notify: {email: you@example.com, events: [failed]}
|
|
78
|
+
# alerts: {failure_rate: 30%}
|
|
79
|
+
# evaluation: # how work is judged
|
|
80
|
+
# judge: {model: fast}
|
|
65
81
|
---
|
|
66
82
|
|
|
67
83
|
# ${account}
|
|
@@ -492,6 +508,13 @@ runtime: # only if scripts need packages
|
|
|
492
508
|
You research a topic and report what you found...
|
|
493
509
|
\`\`\`
|
|
494
510
|
|
|
511
|
+
The fields that do one of the four jobs of running agents may be grouped
|
|
512
|
+
under it — \`governance:\` (owner, budget), \`guardrail:\` (limits, network,
|
|
513
|
+
hooks, disallowedTools, permissionMode, secrets), and in AGENTS.md also
|
|
514
|
+
\`observability:\` (alerts, telemetry, notify, capture) and \`evaluation:\`
|
|
515
|
+
(judge). A block reads exactly as the same keys at the top; a key written
|
|
516
|
+
both ways is refused (\`config/conflict\`). \`foldrun docs four-jobs\`.
|
|
517
|
+
|
|
495
518
|
Only \`name\` and the prose are required. Every other field widens or narrows
|
|
496
519
|
what the agent can reach, and the default is narrow.
|
|
497
520
|
|
package/src/step-exec.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// results. That is what lets the same function run somewhere the vault, the
|
|
9
9
|
// library and the account do not exist.
|
|
10
10
|
|
|
11
|
+
import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION } from "./judge-verdict.ts";
|
|
11
12
|
import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks, type HookDef, type HookRecord } from "./hooks.ts";
|
|
12
13
|
import fs from "node:fs";
|
|
13
14
|
import os from "node:os";
|
|
@@ -133,6 +134,11 @@ export interface ExecOptions {
|
|
|
133
134
|
* `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
|
|
134
135
|
verify?: string;
|
|
135
136
|
verifyEnv?: Record<string, string>;
|
|
137
|
+
/** The model `verify: judge:` grades with — the workspace's `judge:` from
|
|
138
|
+
* AGENTS.md, resolved; absent, the fast tier. */
|
|
139
|
+
judgeModel?: string;
|
|
140
|
+
/** And how long it thinks — `judge: {effort}`; absent, the SDK's default. */
|
|
141
|
+
judgeEffort?: Effort;
|
|
136
142
|
/** `output: json` — the reply must carry one JSON value; extracting it is
|
|
137
143
|
* part of finishing the step, and failing to is failing the step. */
|
|
138
144
|
output?: "json";
|
|
@@ -1066,6 +1072,8 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
1066
1072
|
data,
|
|
1067
1073
|
modelEnv: opts.env,
|
|
1068
1074
|
signal: halt.signal,
|
|
1075
|
+
...(opts.judgeModel ? { judgeModel: opts.judgeModel } : {}),
|
|
1076
|
+
...(opts.judgeEffort ? { judgeEffort: opts.judgeEffort } : {}),
|
|
1069
1077
|
});
|
|
1070
1078
|
} finally {
|
|
1071
1079
|
for (const t of clocks) clearTimeout(t);
|
|
@@ -1170,6 +1178,9 @@ export async function checkVerify(
|
|
|
1170
1178
|
/** The step's own model environment, for `judge:` — it grades on the
|
|
1171
1179
|
* fast tier through the same credential the step rode. */
|
|
1172
1180
|
modelEnv?: Record<string, string | undefined>;
|
|
1181
|
+
/** The judge's model and effort (the workspace's `judge:`). */
|
|
1182
|
+
judgeModel?: string;
|
|
1183
|
+
judgeEffort?: Effort;
|
|
1173
1184
|
/** Ends a shell check or a judge mid-way: the step's clock ran out, or a
|
|
1174
1185
|
* person asked the run to stop. */
|
|
1175
1186
|
signal?: AbortSignal;
|
|
@@ -1233,9 +1244,9 @@ export async function checkVerify(
|
|
|
1233
1244
|
// GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
|
|
1234
1245
|
// text opened with the narration before it.
|
|
1235
1246
|
const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
|
|
1236
|
-
const
|
|
1237
|
-
const ok =
|
|
1238
|
-
return { ok, headline: ok ? "PASS" : "FAIL", detail
|
|
1247
|
+
const said = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal, ctx.judgeModel, ctx.judgeEffort);
|
|
1248
|
+
const { passed: ok, detail } = judgeVerdict(said);
|
|
1249
|
+
return { ok, headline: ok ? "PASS" : "FAIL", detail };
|
|
1239
1250
|
}
|
|
1240
1251
|
}
|
|
1241
1252
|
return { ok: false, headline: `unknown check "${kind}"`, detail: "" };
|
|
@@ -1243,8 +1254,8 @@ export async function checkVerify(
|
|
|
1243
1254
|
|
|
1244
1255
|
/**
|
|
1245
1256
|
* A toolless, fast-tier grading call: does the reply satisfy the sentence?
|
|
1246
|
-
* Same shape as the eval judge
|
|
1247
|
-
*
|
|
1257
|
+
* Same shape as the eval judge: reasoning first, then the verdict alone on
|
|
1258
|
+
* the last line (judge-verdict.ts says why).
|
|
1248
1259
|
*/
|
|
1249
1260
|
export function judgePrompt(rubric: string, reply: string, work: string): string {
|
|
1250
1261
|
const shown = reply.slice(0, 20_000);
|
|
@@ -1253,8 +1264,7 @@ export function judgePrompt(rubric: string, reply: string, work: string): string
|
|
|
1253
1264
|
`never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
|
|
1254
1265
|
: "";
|
|
1255
1266
|
return (
|
|
1256
|
-
`You are grading a reply against one requirement.
|
|
1257
|
-
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
1267
|
+
`You are grading a reply against one requirement. ${JUDGE_VERDICT_INSTRUCTION}\n\nRequirement: ${rubric}\n\n` +
|
|
1258
1268
|
context +
|
|
1259
1269
|
`<reply>\n${shown}\n</reply>`
|
|
1260
1270
|
);
|
|
@@ -1266,6 +1276,8 @@ async function judgeReply(
|
|
|
1266
1276
|
work: string,
|
|
1267
1277
|
env: Record<string, string | undefined>,
|
|
1268
1278
|
signal?: AbortSignal,
|
|
1279
|
+
model = "haiku",
|
|
1280
|
+
effort?: Effort,
|
|
1269
1281
|
): Promise<string> {
|
|
1270
1282
|
const texts: string[] = [];
|
|
1271
1283
|
const abortController = new AbortController();
|
|
@@ -1275,7 +1287,8 @@ async function judgeReply(
|
|
|
1275
1287
|
const q = query({
|
|
1276
1288
|
prompt: judgePrompt(rubric, reply, work),
|
|
1277
1289
|
options: {
|
|
1278
|
-
model
|
|
1290
|
+
model,
|
|
1291
|
+
...(effort ? { effort } : {}),
|
|
1279
1292
|
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
1280
1293
|
tools: [],
|
|
1281
1294
|
settingSources: [],
|