@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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Frontmatter edited as text, where it is written: at the top or inside one
|
|
2
|
+
// of the four job blocks. No node imports — the flow canvas (flow-patterns.ts)
|
|
3
|
+
// runs in the browser and edits frontmatter with it too.
|
|
4
|
+
|
|
5
|
+
/** A block header on its own line — an anchor and a trailing `# comment`
|
|
6
|
+
* allowed. */
|
|
7
|
+
const JOB_BLOCK = /^["']?(governance|guardrail|observability|evaluation)["']?\s*:\s*(&\S+\s*)?(#.*)?$/;
|
|
8
|
+
/** Any other block header: an inline map, an alias, a tag — a form these
|
|
9
|
+
* writers do not edit. */
|
|
10
|
+
const JOB_OTHER = /^["']?(governance|guardrail|observability|evaluation)["']?\s*:\s*\S/;
|
|
11
|
+
/** An inline map closed on its own line: `guardrail: {network: [a.com]}`. */
|
|
12
|
+
const JOB_ONE_LINE = /^["']?(governance|guardrail|observability|evaluation)["']?\s*:\s*\{.*\}\s*(#.*)?$/;
|
|
13
|
+
|
|
14
|
+
const indentOf = (l: string) => l.match(/^[ \t]*/)?.[0].length ?? 0;
|
|
15
|
+
|
|
16
|
+
export interface KeySpan {
|
|
17
|
+
/** The key's line. */
|
|
18
|
+
at: number;
|
|
19
|
+
/** One past its last line. */
|
|
20
|
+
end: number;
|
|
21
|
+
/** What its line starts with. */
|
|
22
|
+
indent: string;
|
|
23
|
+
/** The block header's line, when it is inside one. */
|
|
24
|
+
block: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Thrown for a key a writer cannot edit in place: one that is, or may be,
|
|
28
|
+
* inside a block not written one key per line (`guardrail: {…}`, an alias).
|
|
29
|
+
* Rewriting it would mean re-serialising the person's YAML, and adding the
|
|
30
|
+
* key at the top beside it would be a config/conflict — so the writer says
|
|
31
|
+
* what to write instead. */
|
|
32
|
+
export class InlineBlockError extends Error {
|
|
33
|
+
status = 409;
|
|
34
|
+
constructor(key: string, block: string) {
|
|
35
|
+
super(`\`${key}\` may be inside \`${block}:\`, which is not written one key per line — write \`${block}:\` one key per line to change it here, or edit the file`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A comment or blank line: neither ends a block nor is one of its keys. */
|
|
40
|
+
const filler = (l: string) => l.trim() === "" || /^\s*#/.test(l);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where a key is written in frontmatter lines: at the top, or a direct child
|
|
44
|
+
* of one of the four job blocks (`guardrail:` and so on). Every writer that
|
|
45
|
+
* edits a key as text reads it here, so a file written in blocks is edited
|
|
46
|
+
* where it is — never given a second, flat copy that check would refuse as
|
|
47
|
+
* config/conflict. A key inside an inline block map throws InlineBlockError.
|
|
48
|
+
*/
|
|
49
|
+
export function keySpan(lines: string[], key: string): KeySpan | null {
|
|
50
|
+
const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
51
|
+
const span = (at: number, indent: string, block: number | null): KeySpan => {
|
|
52
|
+
let end = at + 1;
|
|
53
|
+
// The key continues while lines are indented deeper, are an item of its
|
|
54
|
+
// list written at its own indent (YAML allows `key:\n- a`), or are blank
|
|
55
|
+
// lines and comments inside it.
|
|
56
|
+
while (
|
|
57
|
+
end < lines.length &&
|
|
58
|
+
(filler(lines[end]) ||
|
|
59
|
+
indentOf(lines[end]) > indent.length ||
|
|
60
|
+
(indentOf(lines[end]) === indent.length && /^[ \t]*-(\s|$)/.test(lines[end])))
|
|
61
|
+
) end++;
|
|
62
|
+
// Blank lines and comments at its foot, no deeper than the key, belong
|
|
63
|
+
// to what follows.
|
|
64
|
+
while (end > at + 1 && filler(lines[end - 1]) && (lines[end - 1].trim() === "" || indentOf(lines[end - 1]) <= indent.length)) end--;
|
|
65
|
+
return { at, end, indent, block };
|
|
66
|
+
};
|
|
67
|
+
// A key may be quoted: `"network": none`.
|
|
68
|
+
const named = `["']?${k}["']?\\s*:`;
|
|
69
|
+
const top = lines.findIndex((l) => new RegExp(`^${named}`).test(l));
|
|
70
|
+
if (top !== -1) return span(top, "", null);
|
|
71
|
+
for (let h = 0; h < lines.length; h++) {
|
|
72
|
+
const other = !JOB_BLOCK.test(lines[h]) && JOB_OTHER.exec(lines[h]);
|
|
73
|
+
if (other) {
|
|
74
|
+
// One closed line can be read: refuse only if the key is in it. Any
|
|
75
|
+
// other form (a map over several lines, an alias) cannot be, so any
|
|
76
|
+
// key not found at the top is refused rather than added beside it.
|
|
77
|
+
if (!JOB_ONE_LINE.test(lines[h]) || new RegExp(`[{,]\\s*${named}`).test(lines[h])) throw new InlineBlockError(key, other[1]!);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!JOB_BLOCK.test(lines[h])) continue;
|
|
81
|
+
// Its children sit at the indent of its first indented line; a deeper
|
|
82
|
+
// line of the same name belongs to one of them, not to the block.
|
|
83
|
+
// Comments and blank lines, indented or not, do not end it.
|
|
84
|
+
let child = -1;
|
|
85
|
+
let merged = false;
|
|
86
|
+
for (let n = h + 1; n < lines.length && (/^[ \t]/.test(lines[n]) || filler(lines[n])); n++) {
|
|
87
|
+
if (filler(lines[n])) continue;
|
|
88
|
+
if (child === -1) child = indentOf(lines[n]);
|
|
89
|
+
if (indentOf(lines[n]) !== child) continue;
|
|
90
|
+
const m = new RegExp(`^([ \t]+)${named}`).exec(lines[n]);
|
|
91
|
+
if (m) return span(n, m[1]!, h);
|
|
92
|
+
if (/^[ \t]+<<\s*:/.test(lines[n])) merged = true;
|
|
93
|
+
}
|
|
94
|
+
// `<<: *policy` may bring the key in from elsewhere: not one this text
|
|
95
|
+
// edit can see, so it is refused rather than added beside it.
|
|
96
|
+
if (merged) throw new InlineBlockError(key, JOB_BLOCK.exec(lines[h])![1]!);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A block header left with nothing under it is removed with its last key.
|
|
102
|
+
* Blank lines and comments between it and its next key do not count as
|
|
103
|
+
* nothing: the header stays while any indented key follows them. */
|
|
104
|
+
export function dropEmptyBlock(lines: string[], header: number): string[] {
|
|
105
|
+
let n = header + 1;
|
|
106
|
+
while (n < lines.length && filler(lines[n])) n++;
|
|
107
|
+
const next = lines[n];
|
|
108
|
+
return next === undefined || !/^[ \t]+\S/.test(next) ? [...lines.slice(0, header), ...lines.slice(header + 1)] : lines;
|
|
109
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// What a judge said: the PASS or FAIL alone on its last line, after its
|
|
2
|
+
// reasoning. Shared by the eval judge (evals.ts) and a flow step's
|
|
3
|
+
// `verify: judge:` (step-exec.ts), which ask for the same shape.
|
|
4
|
+
//
|
|
5
|
+
// The word comes last because, asked for it first, a judge commits before
|
|
6
|
+
// it has looked and then argues the other way — "FAIL … which meets the
|
|
7
|
+
// criterion" (vaza evals, 21 Oct 2026).
|
|
8
|
+
|
|
9
|
+
import { EFFORT_LEVELS } from "./frontmatter-fields.ts";
|
|
10
|
+
|
|
11
|
+
/** The line to end a judge prompt with. */
|
|
12
|
+
export const JUDGE_VERDICT_INSTRUCTION =
|
|
13
|
+
"Check each part against the text in a few short sentences. Then, on the last line and alone, write exactly PASS or FAIL — PASS only if every part holds.";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The judge's verdict: the PASS or FAIL alone on its last line, with the
|
|
17
|
+
* reasoning before it as the detail. An answer in the old shape (the word
|
|
18
|
+
* first) still reads; one with no verdict at all is a FAIL that says so.
|
|
19
|
+
*/
|
|
20
|
+
export function judgeVerdict(text: string): { passed: boolean; detail: string } {
|
|
21
|
+
const lines = text.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
22
|
+
const word = (l: string | undefined) => /^[*_`#\s]*(PASS|FAIL)[*_`.\s]*$/i.exec(l ?? "")?.[1]?.toUpperCase();
|
|
23
|
+
let verdict = word(lines[lines.length - 1]);
|
|
24
|
+
let reason = lines.slice(0, -1).join(" ");
|
|
25
|
+
if (!verdict) {
|
|
26
|
+
const first = /^[*_`#\s]*(PASS|FAIL)\b[*_`:.\s-]*(.*)$/i.exec(lines[0] ?? "");
|
|
27
|
+
if (first) {
|
|
28
|
+
verdict = first[1]!.toUpperCase();
|
|
29
|
+
reason = [first[2], ...lines.slice(1)].join(" ").trim();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!verdict) return { passed: false, detail: `judge gave no PASS or FAIL: ${text.trim().slice(0, 300) || "nothing"}` };
|
|
33
|
+
// The end of the reasoning is the part that decided it.
|
|
34
|
+
const why = reason.length > 400 ? `…${reason.slice(-400)}` : reason;
|
|
35
|
+
return { passed: verdict === "PASS", detail: `${verdict}${why ? ` — ${why}` : ""}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `judge:` in AGENTS.md — the model and effort every judge in the workspace
|
|
39
|
+
* grades with, unless an eval names its own. `model` is a tier or alias as
|
|
40
|
+
* anywhere else; what is wrong with the block, in words, for check. */
|
|
41
|
+
export function parseJudge(raw: unknown): { model?: string; effort?: string; problems: string[] } {
|
|
42
|
+
if (raw === undefined || raw === null) return { problems: [] };
|
|
43
|
+
if (typeof raw !== "object" || Array.isArray(raw)) return { problems: ["judge: is a block — {model: fast | default | max, effort: low…max}"] };
|
|
44
|
+
const o = raw as Record<string, unknown>;
|
|
45
|
+
const problems: string[] = [];
|
|
46
|
+
for (const k of Object.keys(o)) if (k !== "model" && k !== "effort") problems.push(`judge: ${k} — not a key; the keys are model and effort`);
|
|
47
|
+
const model = typeof o.model === "string" && o.model.trim() ? o.model.trim() : undefined;
|
|
48
|
+
if (o.model !== undefined && !model) problems.push("judge: model — a tier (fast, default, max), an alias or a model id");
|
|
49
|
+
const effort = typeof o.effort === "string" && (EFFORT_LEVELS as readonly string[]).includes(o.effort.trim()) ? o.effort.trim() : undefined;
|
|
50
|
+
if (o.effort !== undefined && !effort) problems.push(`judge: effort ${String(o.effort)} — low, medium, high, xhigh or max`);
|
|
51
|
+
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}), problems };
|
|
52
|
+
}
|
package/src/matter.ts
CHANGED
|
@@ -21,13 +21,125 @@
|
|
|
21
21
|
// it.
|
|
22
22
|
|
|
23
23
|
import grayMatter from "gray-matter";
|
|
24
|
+
import { JOBS, FOLDABLE, type Job } from "./frontmatter-fields.ts";
|
|
24
25
|
|
|
25
26
|
type Options = NonNullable<Parameters<typeof grayMatter>[1]>;
|
|
26
27
|
|
|
28
|
+
/** Where the four job blocks put things, on the parsed data — out of sight
|
|
29
|
+
* of every reader that spreads it or writes it out as JSON. */
|
|
30
|
+
const FOLD = Symbol.for("foldrun.jobBlocks");
|
|
31
|
+
|
|
32
|
+
export interface JobFold {
|
|
33
|
+
/** A key lifted out of a block, and the block it was in. */
|
|
34
|
+
folded: Map<string, Job>;
|
|
35
|
+
/** A key set both at the top and inside a block: the top one is kept, and
|
|
36
|
+
* check refuses the file (config/conflict). */
|
|
37
|
+
conflicts: Map<string, Job>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What the fold did to this data, or null when it had no blocks. */
|
|
41
|
+
export function jobFold(data: unknown): JobFold | null {
|
|
42
|
+
return data && typeof data === "object" ? ((data as Record<symbol, JobFold | undefined>)[FOLD] ?? null) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The four job blocks (`governance:`, `guardrail:`, `observability:`,
|
|
47
|
+
* `evaluation:`) read as the flat keys they hold (frontmatter-fields.ts
|
|
48
|
+
* JOB_FIELDS). Only a key some config kind lets that block hold is lifted;
|
|
49
|
+
* anything else stays in the block for check to report. A key set both ways
|
|
50
|
+
* keeps its top-level value and is recorded. Nothing parsed is changed: new
|
|
51
|
+
* objects are built (YAML aliases share nodes — deleting from one would
|
|
52
|
+
* delete from the other).
|
|
53
|
+
*/
|
|
54
|
+
function foldJobs(data: Record<string, unknown>): Record<string, unknown> {
|
|
55
|
+
let fold: JobFold | null = null;
|
|
56
|
+
let out: Record<string, unknown> | null = null;
|
|
57
|
+
for (const { name } of JOBS) {
|
|
58
|
+
const block = data[name];
|
|
59
|
+
if (!block || typeof block !== "object" || Array.isArray(block) || block instanceof Date) continue;
|
|
60
|
+
const inner = block as Record<string, unknown>;
|
|
61
|
+
const lift = Object.keys(inner).filter((k) => FOLDABLE[name].has(k));
|
|
62
|
+
if (!lift.length) continue;
|
|
63
|
+
out ??= { ...data };
|
|
64
|
+
fold ??= { folded: new Map(), conflicts: new Map() };
|
|
65
|
+
const rest: Record<string, unknown> = {};
|
|
66
|
+
for (const [k, v] of Object.entries(inner)) if (!lift.includes(k)) rest[k] = v;
|
|
67
|
+
for (const k of lift) {
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(data, k)) fold.conflicts.set(k, name);
|
|
69
|
+
else {
|
|
70
|
+
out[k] = inner[k];
|
|
71
|
+
fold.folded.set(k, name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (Object.keys(rest).length) out[name] = rest;
|
|
75
|
+
else delete out[name];
|
|
76
|
+
}
|
|
77
|
+
if (!out || !fold) return data;
|
|
78
|
+
Object.defineProperty(out, FOLD, { value: fold, enumerable: false });
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
27
82
|
function matter(input: string, options?: Options) {
|
|
28
83
|
return grayMatter(input, options ?? {});
|
|
29
84
|
}
|
|
30
85
|
matter.stringify = grayMatter.stringify;
|
|
31
86
|
matter.test = grayMatter.test;
|
|
32
87
|
|
|
88
|
+
/**
|
|
89
|
+
* matter(), for the three files that hold configuration — an agent, a flow,
|
|
90
|
+
* AGENTS.md — with the four job blocks read as flat keys. Every reader of
|
|
91
|
+
* those files goes through this (tests/jobs.test.ts holds them to it);
|
|
92
|
+
* knowledge, memory, skills and every other document keep matter(), so a
|
|
93
|
+
* note's own `governance:` is its own (Codex debate, 21 Oct 2026).
|
|
94
|
+
*/
|
|
95
|
+
export function configMatter(input: string, options?: Options) {
|
|
96
|
+
const file = grayMatter(input, options ?? {});
|
|
97
|
+
if (file.data && typeof file.data === "object" && !Array.isArray(file.data)) {
|
|
98
|
+
(file as { data: Record<string, unknown> }).data = foldJobs(file.data as Record<string, unknown>);
|
|
99
|
+
}
|
|
100
|
+
return file;
|
|
101
|
+
}
|
|
102
|
+
|
|
33
103
|
export default matter;
|
|
104
|
+
|
|
105
|
+
/** The first line of a YAML parse error, with where it is: what a person
|
|
106
|
+
* needs to fix the file, without the parser's excerpt. */
|
|
107
|
+
export function frontmatterErrorText(err: unknown): string {
|
|
108
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
109
|
+
return `frontmatter does not parse — ${msg.split("\n")[0].trim().replace(/:$/, "")}. Quote a value that contains a colon followed by a space.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The 1-based file line a YAML parse error points at, when it says. The
|
|
113
|
+
* text gray-matter hands the parser starts with the newline after the
|
|
114
|
+
* opening `---`, so the parser's 0-based line is the file's 1-based one. */
|
|
115
|
+
export function frontmatterErrorLine(err: unknown): number | undefined {
|
|
116
|
+
const line = (err as { mark?: { line?: unknown } } | null)?.mark?.line;
|
|
117
|
+
return typeof line === "number" ? line + 1 : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Frontmatter as the runtime reads a skill: strictly first, then once more
|
|
121
|
+
* with every unquoted value that holds a colon quoted (the commonest way a
|
|
122
|
+
* hand-written SKILL.md breaks, and one other skills clients forgive), and
|
|
123
|
+
* empty when even that fails. `lenient` says the second read was needed. */
|
|
124
|
+
export function parseLenient(raw: string): { data: Record<string, unknown>; lenient: boolean; failed: boolean } {
|
|
125
|
+
try {
|
|
126
|
+
return { data: matter(raw).data as Record<string, unknown>, lenient: false, failed: false };
|
|
127
|
+
} catch {
|
|
128
|
+
const fm = raw.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/);
|
|
129
|
+
if (fm) {
|
|
130
|
+
const fixed = fm[1].replace(
|
|
131
|
+
/^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm,
|
|
132
|
+
(_m, k: string, v: string) => `${k}: ${JSON.stringify(v)}`,
|
|
133
|
+
);
|
|
134
|
+
try {
|
|
135
|
+
// A function replacer, not a string: a fixed block containing `$&` or
|
|
136
|
+
// `$$` (a value with those in it) would otherwise be treated as a
|
|
137
|
+
// replacement pattern and splice the whole frontmatter into itself.
|
|
138
|
+
return { data: matter(raw.replace(fm[1], () => fixed)).data as Record<string, unknown>, lenient: true, failed: false };
|
|
139
|
+
} catch {
|
|
140
|
+
/* fall through */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { data: {}, lenient: false, failed: true };
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/network.ts
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
// proxy (foldrun-platform egress-proxy.ts) holds the rule per request.
|
|
29
29
|
|
|
30
30
|
/** How wide a step's network is. */
|
|
31
|
+
import { keySpan, dropEmptyBlock } from "./frontmatter-text.ts";
|
|
32
|
+
|
|
31
33
|
export type NetworkLevel = "open" | "trusted" | "none";
|
|
32
34
|
|
|
33
35
|
export const NETWORK_LEVELS: readonly NetworkLevel[] = ["open", "trusted", "none"];
|
|
@@ -402,13 +404,15 @@ export function setNetworkSetting(raw: string, setting: { level?: NetworkLevel;
|
|
|
402
404
|
if (lines[0]?.trim() !== "---") return line ? ["---", line, "---", ...lines].join(eol) : raw;
|
|
403
405
|
const close = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
|
|
404
406
|
if (close < 0) return raw;
|
|
405
|
-
|
|
406
|
-
|
|
407
|
+
// Where network: is written — at the top or inside `guardrail:` — is
|
|
408
|
+
// where it is set (frontmatter-text.ts); written nowhere, it goes last.
|
|
409
|
+
const where = keySpan(lines.slice(1, close), "network");
|
|
410
|
+
if (!where) {
|
|
407
411
|
if (line) lines.splice(close, 0, line);
|
|
408
412
|
return lines.join(eol);
|
|
409
413
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
return lines.join(eol);
|
|
414
|
+
const front = lines.slice(1, close);
|
|
415
|
+
front.splice(where.at, where.end - where.at, ...(line ? [`${where.indent}${line}`] : []));
|
|
416
|
+
const edited = !line && where.block !== null ? dropEmptyBlock(front, where.block) : front;
|
|
417
|
+
return [lines[0], ...edited, ...lines.slice(close)].join(eol);
|
|
414
418
|
}
|
package/src/package.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import fs from "node:fs";
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import matter from "./matter.ts";
|
|
29
|
+
import matter, { configMatter } from "./matter.ts";
|
|
30
30
|
import {
|
|
31
31
|
assertSafeName,
|
|
32
32
|
isEditablePath,
|
|
@@ -592,7 +592,13 @@ interface AgentFront {
|
|
|
592
592
|
}
|
|
593
593
|
|
|
594
594
|
function frontOf(content: string): AgentFront {
|
|
595
|
-
|
|
595
|
+
// An agent's secrets: and tools: may sit in its guardrail: block.
|
|
596
|
+
let d: Record<string, unknown>;
|
|
597
|
+
try {
|
|
598
|
+
d = configMatter(content).data as Record<string, unknown>;
|
|
599
|
+
} catch {
|
|
600
|
+
d = {};
|
|
601
|
+
}
|
|
596
602
|
const apis = Array.isArray(d.apis) ? d.apis.map((a) => (a && typeof a === "object" ? String((a as { name?: unknown }).name ?? "") : "")) : [];
|
|
597
603
|
const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
|
|
598
604
|
const scripts = list(d.scripts);
|
package/src/provider-health.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { accountModelBlock } from "./model-credential.ts";
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import path from "node:path";
|
|
24
|
-
import
|
|
24
|
+
import { configMatter } from "./matter.ts";
|
|
25
25
|
import { getSecret } from "./secrets.ts";
|
|
26
26
|
import { noteSecretUse, healthKey } from "./secret-health.ts";
|
|
27
27
|
import { accountDir, workspaceDir, listWorkspaces, listAgents, parseProvider, type ProviderSpec } from "./store.ts";
|
|
@@ -205,7 +205,7 @@ function cheapestModel(spec: ProviderSpec): string {
|
|
|
205
205
|
|
|
206
206
|
function frontmatterOf(file: string): Record<string, unknown> {
|
|
207
207
|
try {
|
|
208
|
-
return (
|
|
208
|
+
return (configMatter(fs.readFileSync(file, "utf8")).data ?? {}) as Record<string, unknown>;
|
|
209
209
|
} catch {
|
|
210
210
|
return {};
|
|
211
211
|
}
|
package/src/run-container.ts
CHANGED
|
@@ -73,6 +73,9 @@ export interface ContainerStepInput {
|
|
|
73
73
|
budgetNote?: string;
|
|
74
74
|
price?: TokenPrice | null;
|
|
75
75
|
verify?: string;
|
|
76
|
+
/** The model `verify: judge:` grades with (the workspace's `judge:`). */
|
|
77
|
+
judgeModel?: string;
|
|
78
|
+
judgeEffort?: string;
|
|
76
79
|
/** `output: json` — checked inside, where the reply is. */
|
|
77
80
|
output?: "json";
|
|
78
81
|
/** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
|
|
@@ -556,7 +559,8 @@ export function runtimeCacheMount(
|
|
|
556
559
|
|
|
557
560
|
// ---------- the runner image ----------
|
|
558
561
|
|
|
559
|
-
|
|
562
|
+
/** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
|
|
563
|
+
export const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
|
|
560
564
|
// same core the server uses, speaks JSONL on stdout, exits.
|
|
561
565
|
import fs from "node:fs";
|
|
562
566
|
const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
|
|
@@ -754,6 +758,8 @@ try {
|
|
|
754
758
|
price: input.price,
|
|
755
759
|
verify: input.verify,
|
|
756
760
|
verifyEnv: toolEnv,
|
|
761
|
+
...(typeof input.judgeModel === "string" ? { judgeModel: input.judgeModel } : {}),
|
|
762
|
+
...(typeof input.judgeEffort === "string" ? { judgeEffort: input.judgeEffort } : {}),
|
|
757
763
|
output: input.output,
|
|
758
764
|
schema: input.schema,
|
|
759
765
|
maxTurns: input.maxTurns,
|
|
@@ -1358,6 +1364,10 @@ export interface RunInContainerArgs {
|
|
|
1358
1364
|
* without it, "stop" could only mean "stop after this step", and a
|
|
1359
1365
|
* browser step has fifteen minutes left to spend. */
|
|
1360
1366
|
runId?: string;
|
|
1367
|
+
/** Whether a person has stopped the run, read from the record. A step that
|
|
1368
|
+
* waits for room before its sandbox exists asks this, since there is no
|
|
1369
|
+
* sandbox yet for the stop to destroy. */
|
|
1370
|
+
stopRequested?: () => boolean;
|
|
1361
1371
|
/** The reservation class — which limits this step's sandbox holds. */
|
|
1362
1372
|
size?: "small" | "large" | "heavy";
|
|
1363
1373
|
/** Re-attach to a sandbox a previous driver started, instead of creating
|