@foldrun/core 2.5.0 → 2.7.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/capture.js +39 -1
- package/dist/src/completions.js +25 -3
- package/dist/src/deploy.js +72 -7
- package/dist/src/egress.d.ts +16 -0
- 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 +34 -1
- package/dist/src/runner.js +193 -39
- package/dist/src/scan.d.ts +11 -0
- package/dist/src/scan.js +86 -8
- package/dist/src/secret-files.js +23 -2
- package/dist/src/secrets.d.ts +62 -2
- package/dist/src/secrets.js +206 -20
- 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/capture.ts +33 -1
- package/src/completions.ts +26 -3
- package/src/deploy.ts +59 -7
- package/src/egress.ts +15 -1
- 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 +41 -1
- package/src/runner.ts +189 -40
- package/src/scan.ts +86 -8
- package/src/secret-files.ts +22 -2
- package/src/secrets.ts +237 -23
- package/src/starter.ts +23 -0
- package/src/step-exec.ts +21 -8
- package/src/store.ts +23 -10
package/dist/src/store.js
CHANGED
|
@@ -27,7 +27,7 @@ import { recordRevision, registerTreeReader } from "./history.js";
|
|
|
27
27
|
import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.js";
|
|
28
28
|
import { platform } from "./platform.js";
|
|
29
29
|
import { toLF, asRaw } from "./eol.js";
|
|
30
|
-
import matter from "./matter.js";
|
|
30
|
+
import matter, { frontmatterErrorText, configMatter } from "./matter.js";
|
|
31
31
|
import { readAgentsMdKeys } from "./agents-md.js";
|
|
32
32
|
/**
|
|
33
33
|
* The frontmatter of one markdown file, as the runtime reads it. Exported
|
|
@@ -39,7 +39,7 @@ import { readAgentsMdKeys } from "./agents-md.js";
|
|
|
39
39
|
* the runtime, and there is nothing to fail to import.
|
|
40
40
|
*/
|
|
41
41
|
export function readFrontmatter(file) {
|
|
42
|
-
return
|
|
42
|
+
return configMatter(fs.readFileSync(file, "utf8")).data;
|
|
43
43
|
}
|
|
44
44
|
import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.js";
|
|
45
45
|
import { refNames } from "./refs.js";
|
|
@@ -1126,7 +1126,7 @@ export function listAgents(tenant, workspace) {
|
|
|
1126
1126
|
let data = {};
|
|
1127
1127
|
let frontmatterError;
|
|
1128
1128
|
try {
|
|
1129
|
-
({ data } =
|
|
1129
|
+
({ data } = configMatter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8")));
|
|
1130
1130
|
}
|
|
1131
1131
|
catch (err) {
|
|
1132
1132
|
frontmatterError = frontmatterErrorText(err);
|
|
@@ -1181,12 +1181,9 @@ export function listAgents(tenant, workspace) {
|
|
|
1181
1181
|
};
|
|
1182
1182
|
});
|
|
1183
1183
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
export
|
|
1187
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1188
|
-
return `frontmatter does not parse — ${msg.split("\n")[0].trim()}. Quote a value that contains a colon followed by a space.`;
|
|
1189
|
-
}
|
|
1184
|
+
// Kept here for callers that import it from the store; it lives in matter.ts
|
|
1185
|
+
// so the scanner can use it without the store.
|
|
1186
|
+
export { frontmatterErrorText };
|
|
1190
1187
|
/** The body of a file whose frontmatter would not parse: what follows the
|
|
1191
1188
|
* closing fence, so a flow's steps can still be read and shown. */
|
|
1192
1189
|
function bodyAfterFrontmatter(raw) {
|
|
@@ -1360,7 +1357,7 @@ export function parseFlow(file, raw) {
|
|
|
1360
1357
|
let content;
|
|
1361
1358
|
let frontError = null;
|
|
1362
1359
|
try {
|
|
1363
|
-
({ data, content } =
|
|
1360
|
+
({ data, content } = configMatter(raw));
|
|
1364
1361
|
}
|
|
1365
1362
|
catch (err) {
|
|
1366
1363
|
frontError = frontmatterErrorText(err);
|
|
@@ -2658,6 +2655,13 @@ export function sumAttempts(tries) {
|
|
|
2658
2655
|
}
|
|
2659
2656
|
export { tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens, promptLayersChanged, promptCacheNote } from "./tokens.js";
|
|
2660
2657
|
import { sumTokens } from "./tokens.js";
|
|
2658
|
+
/** The marker rescueStep puts in a rescue's instruction; how a rescue record
|
|
2659
|
+
* written before `rescueOf` existed is still recognised. */
|
|
2660
|
+
export const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
|
|
2661
|
+
/** Is this record an on-fail rescue rather than a step of the flow? */
|
|
2662
|
+
export function isRescueRecord(step) {
|
|
2663
|
+
return !!step.rescueOf || (step.instruction ?? "").includes(RESCUE_MARKER);
|
|
2664
|
+
}
|
|
2661
2665
|
// ---------------------------------------------------------------- captured calls
|
|
2662
2666
|
//
|
|
2663
2667
|
// What each tool call took and gave (FOL-54 step 4), beside the run under
|
package/package.json
CHANGED
package/src/agent-import.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import fs from "node:fs";
|
|
17
17
|
import path from "node:path";
|
|
18
|
-
import
|
|
18
|
+
import { configMatter } from "./matter.ts";
|
|
19
19
|
import {
|
|
20
20
|
assertSafeName,
|
|
21
21
|
listWorkspaceFiles,
|
|
@@ -99,7 +99,7 @@ export function importAgent(
|
|
|
99
99
|
let content = readWorkspaceFile(tenant, from, src);
|
|
100
100
|
const rel = src.slice(`agents/${agent}/`.length);
|
|
101
101
|
if (rel === "agent.md") {
|
|
102
|
-
front =
|
|
102
|
+
front = configMatter(content).data as Record<string, unknown>;
|
|
103
103
|
// The folder is the name flows address; a `name:` that disagrees with
|
|
104
104
|
// it is the first thing check would complain about.
|
|
105
105
|
if (name !== agent) content = content.replace(/^(---\r?\n(?:[\s\S]*?\r?\n)?)name:[^\n]*$/m, `$1name: ${name}`);
|
package/src/agent-tools.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import
|
|
20
|
+
import { configMatter } from "./matter.ts";
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
23
23
|
import { resolveModel, resolveEffort, type Effort } from "./store.ts";
|
|
@@ -51,7 +51,7 @@ export function gatherConsults(
|
|
|
51
51
|
missing.push(name);
|
|
52
52
|
continue;
|
|
53
53
|
}
|
|
54
|
-
const parsed =
|
|
54
|
+
const parsed = configMatter(fs.readFileSync(file, "utf8"));
|
|
55
55
|
consults.push({
|
|
56
56
|
name,
|
|
57
57
|
systemPrompt:
|
package/src/agents-md.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import matter from "./matter.ts";
|
|
12
|
+
import matter, { configMatter } from "./matter.ts";
|
|
13
|
+
import { keySpan, dropEmptyBlock } from "./frontmatter-text.ts";
|
|
14
|
+
import { guardEdit } from "./frontmatter-guard.ts";
|
|
15
|
+
import { JOBS, FOLDABLE } from "./frontmatter-fields.ts";
|
|
16
|
+
|
|
17
|
+
export { keySpan };
|
|
13
18
|
|
|
14
19
|
const FRONT = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n|$)/;
|
|
15
20
|
|
|
@@ -19,7 +24,7 @@ export function readAgentsMdKeys(dir: string): Record<string, unknown> {
|
|
|
19
24
|
const file = path.join(dir, name);
|
|
20
25
|
if (!fs.existsSync(file)) continue;
|
|
21
26
|
try {
|
|
22
|
-
return (
|
|
27
|
+
return (configMatter(fs.readFileSync(file, "utf8")).data ?? {}) as Record<string, unknown>;
|
|
23
28
|
} catch {
|
|
24
29
|
return {};
|
|
25
30
|
}
|
|
@@ -37,8 +42,31 @@ function dumpKey(key: string, value: unknown): string[] {
|
|
|
37
42
|
|
|
38
43
|
/** The text of `content` with each key in `patch` replaced (or removed for
|
|
39
44
|
* null), everything else byte for byte as it was. A file with no
|
|
40
|
-
* frontmatter gets one.
|
|
45
|
+
* frontmatter gets one. Read back before it is returned (frontmatter-guard
|
|
46
|
+
* .ts): an edit that would not parse, would change another key or would
|
|
47
|
+
* double one into its block throws UnsafeEditError (409). */
|
|
41
48
|
export function patchFrontmatter(content: string, patch: Record<string, unknown | null>): string {
|
|
49
|
+
// The guard reads the result folded (matter.ts configMatter), so a patch
|
|
50
|
+
// that writes a whole job block is checked as the keys it holds: each
|
|
51
|
+
// field the block may hold may change, and each one it sets reads back.
|
|
52
|
+
const changed = new Set<string>();
|
|
53
|
+
const expect: Record<string, unknown> = {};
|
|
54
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
55
|
+
changed.add(key);
|
|
56
|
+
const job = JOBS.find((j) => j.name === key)?.name;
|
|
57
|
+
if (!job) {
|
|
58
|
+
expect[key] = value;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (const k of FOLDABLE[job]) changed.add(k);
|
|
62
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
63
|
+
for (const [k, v] of Object.entries(value)) if (FOLDABLE[job].has(k)) expect[k] = v;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return guardEdit(content, patchFrontmatterText(content, patch), [...changed], expect);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function patchFrontmatterText(content: string, patch: Record<string, unknown | null>): string {
|
|
42
70
|
const m = content.match(FRONT);
|
|
43
71
|
let open = "---\n";
|
|
44
72
|
let body = "";
|
|
@@ -52,18 +80,15 @@ export function patchFrontmatter(content: string, patch: Record<string, unknown
|
|
|
52
80
|
}
|
|
53
81
|
let lines = body === "" ? [] : body.split("\n");
|
|
54
82
|
for (const [key, value] of Object.entries(patch)) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
// Where it is written now — at the top or inside its job block — is
|
|
84
|
+
// where it is edited; a key written nowhere is added at the top.
|
|
85
|
+
const where = keySpan(lines, key);
|
|
86
|
+
const replacement = value === null || value === undefined ? [] : dumpKey(key, value).map((l) => `${where?.indent ?? ""}${l}`);
|
|
87
|
+
if (!where) lines = [...lines, ...replacement];
|
|
88
|
+
else {
|
|
89
|
+
lines = [...lines.slice(0, where.at), ...replacement, ...lines.slice(where.end)];
|
|
90
|
+
if (!replacement.length && where.block !== null) lines = dropEmptyBlock(lines, where.block);
|
|
63
91
|
}
|
|
64
|
-
const replacement = value === null || value === undefined ? [] : dumpKey(key, value);
|
|
65
|
-
if (at === -1) lines = [...lines, ...replacement];
|
|
66
|
-
else lines = [...lines.slice(0, at), ...replacement, ...lines.slice(end)];
|
|
67
92
|
}
|
|
68
93
|
const front = lines.length ? `${open}${lines.join("\n")}${close}` : "";
|
|
69
94
|
if (!front) return rest.replace(/^(\r?\n)+/, "");
|
package/src/capture.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { redactText } from "./scan.ts";
|
|
9
9
|
import { writeCapturedCall, CAPTURE_CAP } from "./store.ts";
|
|
10
|
-
import { isFileValue, fileContent } from "./secrets.ts";
|
|
10
|
+
import { isFileValue, fileContent, isApiValue, apiConfigOf, isClientCertValue, clientCertOf } from "./secrets.ts";
|
|
11
11
|
import type { CallIo } from "./step-exec.ts";
|
|
12
12
|
|
|
13
13
|
/** How much past the cap a step sends, so masking sees a secret whole
|
|
@@ -31,6 +31,38 @@ export function redactionsFor(secrets: Record<string, string | undefined>): [str
|
|
|
31
31
|
};
|
|
32
32
|
for (const [name, raw] of Object.entries(secrets)) {
|
|
33
33
|
if (typeof raw !== "string") continue;
|
|
34
|
+
// An API connection's header values are each a secret in their own right
|
|
35
|
+
// (a token, Basic credentials) — and a Basic header's password too.
|
|
36
|
+
// An exported client certificate's passphrase and private key reach the
|
|
37
|
+
// sandbox as files: each is masked, whole and line by line.
|
|
38
|
+
if (isClientCertValue(raw)) {
|
|
39
|
+
const c = clientCertOf(raw);
|
|
40
|
+
for (const v of [c.passphrase, c.key]) {
|
|
41
|
+
if (!v) continue;
|
|
42
|
+
add(v, name);
|
|
43
|
+
add(v.trim(), name);
|
|
44
|
+
const escaped = JSON.stringify(v).slice(1, -1);
|
|
45
|
+
if (escaped !== v) add(escaped, name);
|
|
46
|
+
for (const line of v.split(/\r?\n/)) if (line.trim().length >= 16) add(line.trim(), name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (isApiValue(raw)) {
|
|
50
|
+
// Each as written and as JSON would escape it, like every value here.
|
|
51
|
+
const both = (v: string) => {
|
|
52
|
+
add(v, name);
|
|
53
|
+
const escaped = JSON.stringify(v).slice(1, -1);
|
|
54
|
+
if (escaped !== v) add(escaped, name);
|
|
55
|
+
};
|
|
56
|
+
for (const v of Object.values(apiConfigOf(raw).headers ?? {})) {
|
|
57
|
+
both(v);
|
|
58
|
+
const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(v.trim());
|
|
59
|
+
if (basic) {
|
|
60
|
+
both(basic[1]!);
|
|
61
|
+
const decoded = Buffer.from(basic[1]!, "base64").toString("utf8");
|
|
62
|
+
if (decoded.includes(":")) both(decoded.slice(decoded.indexOf(":") + 1));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
34
66
|
for (const v of isFileValue(raw) ? [raw, fileContent(raw)] : [raw]) {
|
|
35
67
|
add(v, name);
|
|
36
68
|
add(v.trim(), name);
|
package/src/completions.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// renders them.
|
|
11
11
|
|
|
12
12
|
import { PROVIDERS } from "./providers.ts";
|
|
13
|
-
import { FRONTMATTER, type SchemaKind } from "./frontmatter-fields.ts";
|
|
13
|
+
import { FRONTMATTER, JOBS, fieldsOfJob, type SchemaKind } from "./frontmatter-fields.ts";
|
|
14
14
|
|
|
15
15
|
export interface Vocabulary {
|
|
16
16
|
agents: string[];
|
|
@@ -660,8 +660,31 @@ export function completionsAt(
|
|
|
660
660
|
}
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
-
//
|
|
664
|
-
|
|
663
|
+
// 5b. An indented line inside one of the four job blocks (governance,
|
|
664
|
+
// guardrail, observability, evaluation): that job's fields for this
|
|
665
|
+
// kind of file — the block takes nothing else.
|
|
666
|
+
const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
|
|
667
|
+
const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
|
|
668
|
+
const nested = line.match(/^(\s+)([a-zA-Z_]*)$/);
|
|
669
|
+
if (insideFrontmatter && nested) {
|
|
670
|
+
const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
|
|
671
|
+
const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
|
|
672
|
+
if (job) {
|
|
673
|
+
const schemaKind = /(^|\/)AGENTS\.md$/.test(path) || path === "project.md" ? "agents-md" : SCHEMA_OF[kind];
|
|
674
|
+
const keys = new Set(schemaKind ? fieldsOfJob(schemaKind, job.name) : []);
|
|
675
|
+
const partial = nested[2] ?? "";
|
|
676
|
+
return {
|
|
677
|
+
from: cursor - partial.length,
|
|
678
|
+
query: partial,
|
|
679
|
+
items: filter(fieldsFor(path, kind).filter((c) => keys.has(c.label.split(".")[0]!)), partial),
|
|
680
|
+
title: `${job.title} — ${job.what}`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// 6. An indented option under a flow step — the body's steps, never a
|
|
686
|
+
// line inside the frontmatter.
|
|
687
|
+
if (kind === "flow" && !insideFrontmatter && /^\s+[a-z]*$/.test(line) && line.trim().length >= 0) {
|
|
665
688
|
const partial = line.trim();
|
|
666
689
|
return {
|
|
667
690
|
from: cursor - partial.length,
|
package/src/deploy.ts
CHANGED
|
@@ -38,21 +38,24 @@ import {
|
|
|
38
38
|
} from "./store.ts";
|
|
39
39
|
import { readAgentsMdKeys } from "./agents-md.ts";
|
|
40
40
|
import { libraryTools } from "./library.ts";
|
|
41
|
-
import { lintFlow } from "./flow-lint.ts";
|
|
41
|
+
import { lintFlow, regexProblem } from "./flow-lint.ts";
|
|
42
|
+
import { parseEval } from "./evals.ts";
|
|
42
43
|
import { refNames } from "./refs.ts";
|
|
43
|
-
import matter from "./matter.ts";
|
|
44
|
+
import matter, { configMatter } from "./matter.ts";
|
|
45
|
+
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
|
|
44
46
|
import { conformanceIssues } from "./okf.ts";
|
|
45
47
|
import { timezoneProblem } from "./clock.ts";
|
|
46
48
|
import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
|
|
47
49
|
import { readLimits } from "./limits.ts";
|
|
48
50
|
import { parseAlerts } from "./alerts.ts";
|
|
49
51
|
import { parseTelemetry } from "./otel-config.ts";
|
|
52
|
+
import { parseJudge } from "./judge-verdict.ts";
|
|
50
53
|
import { networkProblems } from "./network.ts";
|
|
51
54
|
import { actionProblems } from "./web-actions.ts";
|
|
52
55
|
import { languageProblem } from "./language.ts";
|
|
53
56
|
import { localeProblems } from "./locale.ts";
|
|
54
57
|
import { entriesNoFollow } from "./paths.ts";
|
|
55
|
-
import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, scanRule, type ScanFinding, type ScanOptions } from "./scan.ts";
|
|
58
|
+
import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, scanRule, STRICT_FRONTMATTER_PATH, type ScanFinding, type ScanOptions } from "./scan.ts";
|
|
56
59
|
|
|
57
60
|
/** Directories a deploy never reads out of a source tree. */
|
|
58
61
|
// `.cache` is what a run's tools leave in an agent's folder (HOME is that
|
|
@@ -321,7 +324,7 @@ export function deployBlockMode(files: DeployFile[], accountKeys: Record<string,
|
|
|
321
324
|
let fromWorkspace: DeployBlockMode | null = null;
|
|
322
325
|
if (own) {
|
|
323
326
|
try {
|
|
324
|
-
fromWorkspace = blockMode((
|
|
327
|
+
fromWorkspace = blockMode((configMatter(own.content).data as Record<string, unknown>).deploy_block);
|
|
325
328
|
} catch {
|
|
326
329
|
// an AGENTS.md that does not parse is reported elsewhere
|
|
327
330
|
}
|
|
@@ -450,6 +453,32 @@ function structuralWarnings(files: DeployFile[], extraOutward: Iterable<string>
|
|
|
450
453
|
if (refNames(data.tools).some((t: string) => outwardTools.has(t))) outwardAgents.push(m[1]);
|
|
451
454
|
for (const w of webWarnings(data, { chrome, chromium })) out.push({ where: f.path, message: w });
|
|
452
455
|
}
|
|
456
|
+
// An eval's `matches:` is the same JavaScript RegExp as a verify's; one
|
|
457
|
+
// that does not compile fails its case on every run. Read through
|
|
458
|
+
// parseEval, so a block scalar (`matches: |`) or a continued line is
|
|
459
|
+
// tested as the runtime will run it. The line is the item's own: the
|
|
460
|
+
// parsed assertions and the `- matches:` lines come in the same order.
|
|
461
|
+
for (const f of files) {
|
|
462
|
+
if (!/^evals\/[^/]+\.md$/.test(f.path)) continue;
|
|
463
|
+
let info;
|
|
464
|
+
try { info = parseEval(f.path, f.content); } catch { continue; }
|
|
465
|
+
const lines: number[] = [];
|
|
466
|
+
f.content.split("\n").forEach((line, n) => { if (/^\s*-\s*matches:/.test(line)) lines.push(n + 1); });
|
|
467
|
+
// When a `- matches:` line sits outside a case (an example in the
|
|
468
|
+
// prose), the order no longer pairs up: name the file, not a wrong line.
|
|
469
|
+
const parsed = info.cases.reduce((n, c) => n + c.expect.filter((a) => a.type === "matches").length, 0);
|
|
470
|
+
const pairs = lines.length === parsed;
|
|
471
|
+
let k = 0;
|
|
472
|
+
for (const c of info.cases) {
|
|
473
|
+
for (const a of c.expect) {
|
|
474
|
+
if (a.type !== "matches") continue;
|
|
475
|
+
const at = pairs ? lines[k] : undefined;
|
|
476
|
+
k++;
|
|
477
|
+
const problem = regexProblem(a.value);
|
|
478
|
+
if (problem) out.push({ where: at ? `${f.path}:${at}` : f.path, message: `case "${c.name}": matches: ${problem}` });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
453
482
|
if (!outwardAgents.length) return out;
|
|
454
483
|
for (const f of files) {
|
|
455
484
|
if (!/^flows\/[^/]+\.md$/.test(f.path)) continue;
|
|
@@ -496,10 +525,11 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
496
525
|
// nothing said so — gray-matter's cache let a second parse pass — and one
|
|
497
526
|
// such agent file kept the worker from booting for every account.
|
|
498
527
|
// Definition files only: a skill is read leniently (an unquoted colon is
|
|
499
|
-
// quoted and retried,
|
|
528
|
+
// quoted and retried, matter.ts parseLenient), and the markdown beside a
|
|
500
529
|
// definition — references, a tool's README — is prose, not frontmatter.
|
|
530
|
+
// The scanner warns about skills and library files (config/invalid-frontmatter).
|
|
501
531
|
for (const f of files) {
|
|
502
|
-
if (
|
|
532
|
+
if (!STRICT_FRONTMATTER_PATH.test(f.path)) continue;
|
|
503
533
|
if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
|
|
504
534
|
try {
|
|
505
535
|
matter(f.content);
|
|
@@ -508,6 +538,26 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
508
538
|
}
|
|
509
539
|
}
|
|
510
540
|
|
|
541
|
+
// The four job blocks (frontmatter-fields.ts JOB_FIELDS): a setting
|
|
542
|
+
// written both at the top and in its block, or in another job's block, is
|
|
543
|
+
// a setting that does not do what the file says — a guardrail under
|
|
544
|
+
// governance: guards nothing — so the deploy refuses it as check does
|
|
545
|
+
// (config/conflict, config/misplaced; Codex debate, 21 Oct 2026).
|
|
546
|
+
for (const f of files) {
|
|
547
|
+
const kind = schemaKindOf(f.path);
|
|
548
|
+
if (kind !== "agent" && kind !== "flow" && kind !== "agents-md") continue;
|
|
549
|
+
if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
|
|
550
|
+
let front: Record<string, unknown>;
|
|
551
|
+
try {
|
|
552
|
+
front = configMatter(f.content).data as Record<string, unknown>;
|
|
553
|
+
} catch {
|
|
554
|
+
continue; // unparseable frontmatter is its own report
|
|
555
|
+
}
|
|
556
|
+
for (const p of frontmatterProblems(kind, front, () => null)) {
|
|
557
|
+
if (p.level === "error" && (p.rule === "config/conflict" || p.rule === "config/misplaced")) at(f.path, `${p.message} [${p.rule}]`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
511
561
|
// `timezone:` is read at four levels and a value nothing can parse falls
|
|
512
562
|
// silently through to the level above — so it is refused where it was
|
|
513
563
|
// written, not discovered as a date that is a day out.
|
|
@@ -517,7 +567,7 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
517
567
|
if (unopened) at(f.path, unopened);
|
|
518
568
|
let front: Record<string, unknown> = {};
|
|
519
569
|
try {
|
|
520
|
-
front =
|
|
570
|
+
front = configMatter(f.content).data as Record<string, unknown>;
|
|
521
571
|
} catch {
|
|
522
572
|
continue; // unparseable frontmatter is its own report
|
|
523
573
|
}
|
|
@@ -541,6 +591,8 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
541
591
|
// `telemetry:` that cannot be read would never send — and a token
|
|
542
592
|
// written into it would ship in the file.
|
|
543
593
|
for (const w of parseTelemetry(front.telemetry).problems) at(f.path, w);
|
|
594
|
+
// `judge:` that cannot be read would leave every judge on the default.
|
|
595
|
+
for (const w of parseJudge(front.judge).problems) at(f.path, w);
|
|
544
596
|
const lang = languageProblem(front.language);
|
|
545
597
|
if (lang) at(f.path, lang);
|
|
546
598
|
for (const w of localeProblems(front)) at(f.path, w);
|
package/src/egress.ts
CHANGED
|
@@ -96,6 +96,14 @@ export function hostOf(url: string): string | null {
|
|
|
96
96
|
* from the step's declared secrets and the hosts its tools and provider
|
|
97
97
|
* name; the proxy enforces it per request.
|
|
98
98
|
*/
|
|
99
|
+
/** Which workload a lease is for — the runner's word, never the pod's: what
|
|
100
|
+
* an identity token's subject is built from (oidc-issuer.ts). */
|
|
101
|
+
export interface LeaseWho {
|
|
102
|
+
workspace: string;
|
|
103
|
+
agent: string;
|
|
104
|
+
flow?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
99
107
|
export interface EgressGrant {
|
|
100
108
|
/** Secret name → the hosts that secret may be substituted for. */
|
|
101
109
|
secrets: Record<string, { value: string; hosts: string[] }>;
|
|
@@ -116,6 +124,9 @@ export interface EgressGrant {
|
|
|
116
124
|
/** Refuse a site off the list (true), or allow it and name it in the
|
|
117
125
|
* trace (false: report only). Set by the platform when it leases. */
|
|
118
126
|
enforce?: boolean;
|
|
127
|
+
/** Identity name → the audience its tokens are for: the only identities
|
|
128
|
+
* this step may ask the proxy's /identity door to mint (oidc-issuer.ts). */
|
|
129
|
+
identities?: Record<string, { audience: string }>;
|
|
119
130
|
}
|
|
120
131
|
|
|
121
132
|
export interface EgressLease {
|
|
@@ -161,7 +172,10 @@ export interface EgressHooks {
|
|
|
161
172
|
* `agent` and `peers` — this step's agent and the other steps of its
|
|
162
173
|
* parallel group (tools: [peers]): the proxy serves the roster and
|
|
163
174
|
* addresses a colleague's message to the right inbox. */
|
|
164
|
-
lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[] }): Promise<EgressLease | null>;
|
|
175
|
+
lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[]; who?: LeaseWho }): Promise<EgressLease | null>;
|
|
176
|
+
/** The run is still being driven: renew its leases' heartbeat. Optional —
|
|
177
|
+
* an install without leases has nothing to renew. */
|
|
178
|
+
renew?(runId: string): Promise<number>;
|
|
165
179
|
/** The step is over — every attempt, however it ended (tools: [peers]).
|
|
166
180
|
* A lease comes and goes per attempt, so it cannot say this: a sibling
|
|
167
181
|
* between retries, or waiting for a slot, holds none and is not done.
|
package/src/evals.ts
CHANGED
|
@@ -34,15 +34,19 @@
|
|
|
34
34
|
import { accountModelEnv, withModelEnv } from "./model-env.ts";
|
|
35
35
|
import type { TranslatorSpec } from "./translator.ts";
|
|
36
36
|
import { sdkEnv } from "./step-exec.ts";
|
|
37
|
+
import { platformModelEnv } from "./runner.ts";
|
|
37
38
|
import fs from "node:fs";
|
|
38
39
|
import path from "node:path";
|
|
39
40
|
import { deployedCommit } from "./deploy.ts";
|
|
40
41
|
import { latestRevisionId } from "./history.ts";
|
|
41
42
|
import matter from "./matter.ts";
|
|
42
43
|
import { spawn } from "node:child_process";
|
|
44
|
+
import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION, parseJudge } from "./judge-verdict.ts";
|
|
45
|
+
import { readAgentsMdKeys } from "./agents-md.ts";
|
|
43
46
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
44
47
|
import {
|
|
45
48
|
workspaceDir,
|
|
49
|
+
accountDir,
|
|
46
50
|
readRun,
|
|
47
51
|
runVerdict,
|
|
48
52
|
writeWorkspaceFile,
|
|
@@ -146,7 +150,9 @@ function readScalar(
|
|
|
146
150
|
return { value: [first, ...parts].join(" ").trim(), end: i };
|
|
147
151
|
}
|
|
148
152
|
|
|
149
|
-
|
|
153
|
+
/** `defaults`: the workspace's `judge:` (AGENTS.md), used where the eval
|
|
154
|
+
* names no model or effort of its own. */
|
|
155
|
+
export function parseEval(file: string, raw: string, defaults: { model?: string; effort?: string } = {}): EvalInfo {
|
|
150
156
|
const { data, content } = matter(raw);
|
|
151
157
|
const cases: EvalCase[] = [];
|
|
152
158
|
let current: EvalCase | null = null;
|
|
@@ -197,8 +203,8 @@ export function parseEval(file: string, raw: string): EvalInfo {
|
|
|
197
203
|
file,
|
|
198
204
|
agent: typeof data.agent === "string" ? data.agent : null,
|
|
199
205
|
flow: typeof data.flow === "string" ? data.flow : null,
|
|
200
|
-
model: resolveModel(data.model ?? "fast"),
|
|
201
|
-
effort: resolveEffort(data.effort ?? "low"),
|
|
206
|
+
model: resolveModel(data.model ?? defaults.model ?? "fast"),
|
|
207
|
+
effort: resolveEffort(data.effort ?? defaults.effort ?? "low"),
|
|
202
208
|
trigger: data.trigger === "manual" ? "manual" : "deploy",
|
|
203
209
|
live: data.live === true,
|
|
204
210
|
inputs: data.inputs === true,
|
|
@@ -213,7 +219,18 @@ export function listEvals(tenant: string, workspace: string): EvalInfo[] {
|
|
|
213
219
|
.readdirSync(dir)
|
|
214
220
|
.filter((f) => f.endsWith(".md"))
|
|
215
221
|
.sort()
|
|
216
|
-
.map((f) => parseEval(f, fs.readFileSync(path.join(dir, f), "utf8")));
|
|
222
|
+
.map((f) => parseEval(f, fs.readFileSync(path.join(dir, f), "utf8"), judgeDefaults(tenant, workspace)));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** `judge:` from AGENTS.md, field by field: the workspace's model (or
|
|
226
|
+
* effort), else the account's — so a workspace that sets only an effort
|
|
227
|
+
* keeps the account's model (Codex debate: the form says so). */
|
|
228
|
+
export function judgeDefaults(tenant: string, workspace: string): { model?: string; effort?: string } {
|
|
229
|
+
const ws = parseJudge(readAgentsMdKeys(workspaceDir(tenant, workspace)).judge);
|
|
230
|
+
const account = parseJudge(readAgentsMdKeys(accountDir(tenant)).judge);
|
|
231
|
+
const model = ws.model ?? account.model;
|
|
232
|
+
const effort = ws.effort ?? account.effort;
|
|
233
|
+
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) };
|
|
217
234
|
}
|
|
218
235
|
|
|
219
236
|
// ---------- promoting a run ----------
|
|
@@ -484,9 +501,12 @@ function shell(command: string, cwd: string): Promise<{ code: number | null; out
|
|
|
484
501
|
});
|
|
485
502
|
}
|
|
486
503
|
|
|
487
|
-
// The judge gets the rubric and the output, and
|
|
504
|
+
// The judge gets the rubric and the output, and ends with one word.
|
|
488
505
|
// Constrained output beats asking for a score: "PASS" or "FAIL" can't be
|
|
489
|
-
// misread, where "7/10" needs a threshold nobody agreed on.
|
|
506
|
+
// misread, where "7/10" needs a threshold nobody agreed on. The word comes
|
|
507
|
+
// LAST, after the reasoning: asked for it first, the judge committed before
|
|
508
|
+
// it had looked, then argued the other way — "FAIL … which meets the
|
|
509
|
+
// criterion" (vaza evals, 21 Oct 2026).
|
|
490
510
|
async function judge(
|
|
491
511
|
rubric: string,
|
|
492
512
|
output: string,
|
|
@@ -495,8 +515,7 @@ async function judge(
|
|
|
495
515
|
env: Record<string, string | undefined>,
|
|
496
516
|
): Promise<AssertionResult["detail"] & string> {
|
|
497
517
|
const prompt =
|
|
498
|
-
`You are grading one piece of output against one criterion.
|
|
499
|
-
`PASS or FAIL on the first line, then one short sentence of reason.\n\n` +
|
|
518
|
+
`You are grading one piece of output against one criterion. ${JUDGE_VERDICT_INSTRUCTION}\n\n` +
|
|
500
519
|
`Criterion: ${rubric}\n\n<output>\n${output.slice(0, 20000)}\n</output>`;
|
|
501
520
|
|
|
502
521
|
let text = "";
|
|
@@ -523,13 +542,26 @@ async function judge(
|
|
|
523
542
|
return text.trim();
|
|
524
543
|
}
|
|
525
544
|
|
|
545
|
+
/**
|
|
546
|
+
* The judge's environment: this process's, with the platform's model
|
|
547
|
+
* credential read live. The process's own CLAUDE_CODE_OAUTH_TOKEN is the copy
|
|
548
|
+
* it was handed at boot; the box refreshes the login every four hours and the
|
|
549
|
+
* old token is then revoked, so a judge on the boot copy failed with "OAuth
|
|
550
|
+
* token revoked" from the first refresh until the pod restarted, while the
|
|
551
|
+
* agent steps (which read the live file) kept working (2026-10-21).
|
|
552
|
+
*/
|
|
553
|
+
export function judgeProcessEnv(): Record<string, string | undefined> {
|
|
554
|
+
const live = Object.fromEntries(Object.entries(platformModelEnv()).filter(([, v]) => v !== undefined));
|
|
555
|
+
return sdkEnv({ ...process.env, ...live });
|
|
556
|
+
}
|
|
557
|
+
|
|
526
558
|
async function checkAssertion(
|
|
527
559
|
assertion: Assertion,
|
|
528
560
|
output: string,
|
|
529
561
|
agentDir: string,
|
|
530
562
|
model: string,
|
|
531
563
|
effort: Effort | null,
|
|
532
|
-
judgeEnv: () => { env: Record<string, string | undefined>; translator?: TranslatorSpec | null } = () => ({ env:
|
|
564
|
+
judgeEnv: () => { env: Record<string, string | undefined>; translator?: TranslatorSpec | null } = () => ({ env: judgeProcessEnv() }),
|
|
533
565
|
): Promise<AssertionResult> {
|
|
534
566
|
const hay = output.toLowerCase();
|
|
535
567
|
switch (assertion.type) {
|
|
@@ -570,9 +602,9 @@ async function checkAssertion(
|
|
|
570
602
|
} catch (err) {
|
|
571
603
|
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
572
604
|
}
|
|
573
|
-
const
|
|
574
|
-
const passed =
|
|
575
|
-
return { assertion, passed, detail
|
|
605
|
+
const text = await withModelEnv(m, (env) => judge(assertion.value, output, model, effort, env));
|
|
606
|
+
const { passed, detail } = judgeVerdict(text);
|
|
607
|
+
return { assertion, passed, detail };
|
|
576
608
|
}
|
|
577
609
|
}
|
|
578
610
|
}
|
|
@@ -653,7 +685,7 @@ export async function runEval(
|
|
|
653
685
|
const judges = testCase.expect.filter((a) => a.type === "judge");
|
|
654
686
|
for (const a of cheap) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
|
|
655
687
|
if (assertions.every((r) => r.passed)) {
|
|
656
|
-
const judgeEnv = () => accountModelEnv(tenant, workspace,
|
|
688
|
+
const judgeEnv = () => accountModelEnv(tenant, workspace, judgeProcessEnv);
|
|
657
689
|
for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
|
|
658
690
|
} else {
|
|
659
691
|
for (const a of judges) {
|
package/src/flow-lint.ts
CHANGED
|
@@ -91,6 +91,27 @@ const LOOKS_LIKE_SHELL =
|
|
|
91
91
|
* prefix, nothing shell-shaped anywhere in it, and enough words to be a
|
|
92
92
|
* sentence — so a bare `./check.sh` or a one-word command is left alone and
|
|
93
93
|
* the warning only fires where the author plainly wrote prose. */
|
|
94
|
+
/**
|
|
95
|
+
* Why a `matches:` pattern will not compile, or null when it does. The
|
|
96
|
+
* runtime tests it as a JavaScript RegExp with the `i` flag (step-exec.ts,
|
|
97
|
+
* evals.ts), and a pattern that throws fails its check on every run with
|
|
98
|
+
* "invalid regular expression". The usual cause is another dialect: on
|
|
99
|
+
* 2026-10-21 Python's inline `(?m)` broke five eval checks on vaza, and
|
|
100
|
+
* nothing said so before they ran.
|
|
101
|
+
*/
|
|
102
|
+
export function regexProblem(pattern: string): string | null {
|
|
103
|
+
try {
|
|
104
|
+
new RegExp(pattern, "i");
|
|
105
|
+
return null;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const why = err instanceof Error ? err.message.replace(/^Invalid regular expression: /, "") : String(err);
|
|
108
|
+
const inline = /\(\?[a-z]+\)/.test(pattern)
|
|
109
|
+
? " — JavaScript has no inline flags like (?m); write (^|\\n) for the start of a line"
|
|
110
|
+
: "";
|
|
111
|
+
return `is not a valid JavaScript regular expression (${why})${inline}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
94
115
|
function looksLikeProse(verify: string): boolean {
|
|
95
116
|
const v = verify.trim();
|
|
96
117
|
if (!v || VERIFY_ASSERTION.test(v)) return false;
|
|
@@ -443,6 +464,21 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
443
464
|
});
|
|
444
465
|
}
|
|
445
466
|
|
|
467
|
+
// A `verify: matches:` the runtime cannot compile fails the step on
|
|
468
|
+
// every run, whatever the agent did.
|
|
469
|
+
const verifyRe = step.verify ? /^matches:\s*(.*)$/.exec(step.verify.trim()) : null;
|
|
470
|
+
// The same normalisation checkVerify applies (step-exec.ts): one quote
|
|
471
|
+
// off each end, so the pattern checked is the pattern that runs.
|
|
472
|
+
const reProblem = verifyRe ? regexProblem(verifyRe[1].trim().replace(/^["']|["']$/g, "")) : null;
|
|
473
|
+
if (reProblem) {
|
|
474
|
+
warnings.push({
|
|
475
|
+
step: i,
|
|
476
|
+
line: step.line,
|
|
477
|
+
message: `verify: matches: ${reProblem}`,
|
|
478
|
+
detail: "The runtime tests matches: as a JavaScript regular expression, case-insensitive. One that does not compile fails this step on every run.",
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
446
482
|
// A step's OTHER agent references — the ones no parser validates,
|
|
447
483
|
// because they are options rather than the step's target. Both fail
|
|
448
484
|
// silently and at the worst possible moment: a misspelled `delegate:`
|