@sweny-ai/core 0.1.216 → 0.1.217

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.
@@ -0,0 +1,56 @@
1
+ import type { OutputGroundedQuote, OutputGroundedRule, ToolAccess, ToolCall } from "../types.js";
2
+ /** What a node's grounding is checked against. */
3
+ export interface GroundingEvidence {
4
+ /** The evaluated attempt's tool calls, with outputs (in memory). */
5
+ toolCalls: ToolCall[];
6
+ /** The context the node's prompt received: `{ input, ...priorNodes }`. */
7
+ context?: Record<string, unknown>;
8
+ /** The evaluated node's id. Its own earlier visit (loops) is not evidence. */
9
+ nodeId?: string;
10
+ /** The author's text the prompt carried: instruction, rules, context sources. Always evidence. */
11
+ authored?: string[];
12
+ /** Access class of the node's skill tools by name. A `write` skill tool is never evidence. */
13
+ toolAccess?: ReadonlyMap<string, ToolAccess>;
14
+ }
15
+ export declare const BUILTIN_PATTERNS: readonly ["url", "github_ref", "issue_key"];
16
+ /** What `{}` scans prose for. Issue keys only when listed: prose is full of `UTF-8`-style tokens. */
17
+ export declare const DEFAULT_PATTERNS: readonly ["url", "github_ref"];
18
+ /** Same cap as `output_matches[].matches` (see value.ts). */
19
+ export declare const MAX_REGEX_SOURCE_LENGTH = 1000;
20
+ /** Offending values listed per failure group before "and N more". */
21
+ export declare const MAX_LISTED = 8;
22
+ /** Shortest quote accepted by default, after whitespace normalization. */
23
+ export declare const DEFAULT_MIN_QUOTE = 8;
24
+ /**
25
+ * Tools whose result is not evidence: shells, subagents, file writers, the
26
+ * safe-output tool (`emit_output`, EMIT_OUTPUT_TOOL in safe-outputs.ts) and
27
+ * tools that echo what they were given. Lower-cased; Claude Code, Codex and pi names.
28
+ */
29
+ export declare const NOT_EVIDENCE_TOOLS: ReadonlySet<string>;
30
+ type Verdict = {
31
+ pass: boolean;
32
+ reasoning?: string;
33
+ };
34
+ /**
35
+ * Canonical form for comparing URLs: scheme and host lowercased and the
36
+ * default port dropped (by URL), any trailing slash on the path dropped.
37
+ * Path, query and fragment stay as written. A scheme-less GitHub or
38
+ * Atlassian link gets `https://`. A string that is not a URL comes back trimmed.
39
+ */
40
+ export declare function normalizeUrl(raw: string): string;
41
+ /** `needle` occurs in `hay` as a whole token: no letter, digit, `_` or `-` against a word edge. */
42
+ export declare function containsToken(hay: string, needle: string): boolean;
43
+ /** A tool's result is evidence by default only when the tool reads. */
44
+ export declare function isEvidenceTool(c: ToolCall, toolAccess?: ReadonlyMap<string, ToolAccess>): boolean;
45
+ /** A `tools` entry names this call: a bare name for sweny's own or a built-in, else `server/tool` or `mcp__server__tool`. */
46
+ export declare function namesCall(name: string, c: ToolCall): boolean;
47
+ /** The whitespace rule for a quote. `trim` is the default. */
48
+ export declare function normalizeQuote(s: string, mode: OutputGroundedQuote["whitespace"]): string;
49
+ /**
50
+ * Evaluate an `output_grounded` rule against the node's output and evidence.
51
+ * Never throws: a bad config (malformed path, invalid pattern) is a failure
52
+ * with a reason, not a silent pass. A configured path that matched nothing
53
+ * passes with a warning in the reasoning.
54
+ */
55
+ export declare function evaluateGroundedRule(rule: OutputGroundedRule, data: unknown, evidence: GroundingEvidence): Verdict;
56
+ export {};
@@ -0,0 +1,416 @@
1
+ // ─── Eval: output_grounded (#580) ────────────────────────────────────
2
+ //
3
+ // A `function` rule with two checks, both pure and deterministic (no model call):
4
+ //
5
+ // - Ids: every URL and `owner/repo#N` ref a node outputs (plus issue keys or
6
+ // custom patterns when listed, and every value at the configured `ids`
7
+ // paths) must appear in what the agent saw: the outputs of its own
8
+ // successful read-tool calls, the author's text its prompt carried, and (by
9
+ // default) the workflow input and the prior-node context.
10
+ // - Quotes: every value at a `quotes` path must appear verbatim, under an
11
+ // explicit whitespace rule, in one string of a named tool's output.
12
+ //
13
+ // Evidence lives in memory only. Tool outputs are never journaled (see
14
+ // NODE_RESULT_JOURNAL), so the verdict is computed once, while the run holds
15
+ // them, and the EvalResult is what the journal keeps. A node replayed on
16
+ // resume keeps its journaled verdict; a node that re-runs is re-checked
17
+ // against its new tool outputs.
18
+ import { isErrorOutput } from "./function.js";
19
+ export const BUILTIN_PATTERNS = ["url", "github_ref", "issue_key"];
20
+ /** What `{}` scans prose for. Issue keys only when listed: prose is full of `UTF-8`-style tokens. */
21
+ export const DEFAULT_PATTERNS = ["url", "github_ref"];
22
+ /** Same cap as `output_matches[].matches` (see value.ts). */
23
+ export const MAX_REGEX_SOURCE_LENGTH = 1000;
24
+ /** Offending values listed per failure group before "and N more". */
25
+ export const MAX_LISTED = 8;
26
+ /** Shortest quote accepted by default, after whitespace normalization. */
27
+ export const DEFAULT_MIN_QUOTE = 8;
28
+ /**
29
+ * Tools whose result is not evidence: shells, subagents, file writers, the
30
+ * safe-output tool (`emit_output`, EMIT_OUTPUT_TOOL in safe-outputs.ts) and
31
+ * tools that echo what they were given. Lower-cased; Claude Code, Codex and pi names.
32
+ */
33
+ export const NOT_EVIDENCE_TOOLS = new Set([
34
+ "bash",
35
+ "bashoutput",
36
+ "killshell",
37
+ "shell",
38
+ "apply_patch",
39
+ "task",
40
+ "agent",
41
+ "write",
42
+ "edit",
43
+ "multiedit",
44
+ "notebookedit",
45
+ "todowrite",
46
+ "structuredoutput",
47
+ "exitplanmode",
48
+ "emit_output",
49
+ ]);
50
+ /** A tool name that says it writes, sends or runs: its result likely echoes its input. */
51
+ const WRITE_VERB_RE = /(?:^|[_-])(?:create|update|save|add|post|send|delete|remove|set|write|edit|upsert|put|patch|merge|close|reopen|assign|comment|reply|publish|push|commit|run|exec|execute|echo|emit)(?:[_-]|$)/i;
52
+ const URL_BODY = String.raw `[^\s<>"'${"`"}()[\]{}|\\^]+`;
53
+ /** `http(s)://...` in any case, or a scheme-less GitHub or Atlassian link. */
54
+ const URL_RE = new RegExp(String.raw `https?:\/\/${URL_BODY}|(?<![\w.@/:-])(?:github\.com|[\w-]+\.atlassian\.net)\/${URL_BODY}`, "gi");
55
+ const KNOWN_HOST_RE = /^(?:github\.com|[\w-]+\.atlassian\.net)\//i;
56
+ const TRAILING_PUNCT_RE = /[.,;:!?]+$/;
57
+ const GITHUB_REF_RE = /(?<![\w./-])([\w.-]+)\/([\w.-]+)#(\d+)(?!\w)/g;
58
+ const ISSUE_KEY_RE = /(?<![\w-])([A-Z][A-Z0-9]{1,9})-\d+(?![\w-])/g;
59
+ /** Matched against a normalized URL: `//host/owner/repo/(issues|pull)/N` at the end. */
60
+ const GITHUB_URL_RE = /\/\/[^/]+\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)$/;
61
+ const NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
62
+ const WORD_CHAR = /[\w-]/;
63
+ const SEGMENT_RE = /^([A-Za-z_]\w*)(\[\*\])?$/;
64
+ const IDENT_RE = /^[A-Za-z_]\w*$/;
65
+ /** sweny's own MCP server: its tools are matched by bare name, like built-ins. */
66
+ const OWN_SERVER = "sweny-core";
67
+ // ─── URLs ────────────────────────────────────────────────────────────
68
+ /**
69
+ * Canonical form for comparing URLs: scheme and host lowercased and the
70
+ * default port dropped (by URL), any trailing slash on the path dropped.
71
+ * Path, query and fragment stay as written. A scheme-less GitHub or
72
+ * Atlassian link gets `https://`. A string that is not a URL comes back trimmed.
73
+ */
74
+ export function normalizeUrl(raw) {
75
+ const s = raw.trim();
76
+ try {
77
+ const u = new URL(KNOWN_HOST_RE.test(s) ? `https://${s}` : s);
78
+ return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}${u.search}${u.hash}`;
79
+ }
80
+ catch {
81
+ return s;
82
+ }
83
+ }
84
+ /** The URL minus its fragment. */
85
+ function withoutFragment(normalized) {
86
+ return normalized.split("#")[0];
87
+ }
88
+ /** The URL minus its last path segment (`https://h/a/b/3` -> `https://h/a/b`); none for a bare origin. */
89
+ function urlCollection(normalized) {
90
+ const base = withoutFragment(normalized);
91
+ const pathStart = base.indexOf("/", base.indexOf("//") + 2);
92
+ return pathStart === -1 ? undefined : base.slice(0, base.lastIndexOf("/"));
93
+ }
94
+ // ─── Walking values ──────────────────────────────────────────────────
95
+ /** `a.b`, `a[0]`, `a["not an identifier"]`. */
96
+ function childPath(parent, key) {
97
+ if (!IDENT_RE.test(String(key)))
98
+ return `${parent}[${JSON.stringify(key)}]`;
99
+ return parent === "" ? `${key}` : `${parent}.${key}`;
100
+ }
101
+ /** Every string (and, with `numbers`, number) leaf under a value of the output, with its concrete path. */
102
+ function leaves(value, path, numbers) {
103
+ if (typeof value === "string")
104
+ return [{ path, text: value }];
105
+ if (numbers && typeof value === "number")
106
+ return [{ path, text: String(value) }];
107
+ if (typeof value !== "object" || value === null)
108
+ return [];
109
+ if (Array.isArray(value))
110
+ return value.flatMap((v, i) => leaves(v, childPath(path, i), numbers));
111
+ return Object.entries(value).flatMap(([k, v]) => leaves(v, childPath(path, k), numbers));
112
+ }
113
+ /** Every string and number in an evidence value, and its object keys (never array indexes). Cycles read once. */
114
+ function evidenceText(value, seen = new WeakSet()) {
115
+ if (typeof value === "string")
116
+ return { values: [value], keys: [] };
117
+ if (typeof value === "number")
118
+ return { values: [String(value)], keys: [] };
119
+ if (typeof value !== "object" || value === null || seen.has(value))
120
+ return { values: [], keys: [] };
121
+ seen.add(value);
122
+ const entries = Array.isArray(value)
123
+ ? value.map((v) => [undefined, v])
124
+ : Object.entries(value);
125
+ const out = { values: [], keys: [] };
126
+ for (const [k, v] of entries) {
127
+ const inner = evidenceText(v, seen);
128
+ out.values.push(...inner.values);
129
+ out.keys.push(...(k === undefined ? [] : [k]), ...inner.keys);
130
+ }
131
+ return out;
132
+ }
133
+ /**
134
+ * Resolve an eval path (grammar: spec nodes#path-grammar) to concrete
135
+ * `{ path, value }` pairs, so a failure names `items[3].url`, not
136
+ * `items[*].url`. A path that does not resolve has nothing to check (the
137
+ * caller warns). A malformed path throws.
138
+ */
139
+ function resolveConcrete(data, path) {
140
+ let current = [{ path: "", value: data }];
141
+ for (const part of path.replace(/^(all|any):/, "").split(".")) {
142
+ const m = SEGMENT_RE.exec(part);
143
+ if (!m)
144
+ throw new Error(`malformed path '${path}'`);
145
+ const name = m[1];
146
+ current = current.flatMap((c) => {
147
+ const obj = c.value;
148
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj) || !(name in obj))
149
+ return [];
150
+ const p = childPath(c.path, name);
151
+ const child = obj[name];
152
+ if (m[2] === undefined)
153
+ return [{ path: p, value: child }];
154
+ return Array.isArray(child) ? child.map((el, i) => ({ path: childPath(p, i), value: el })) : [];
155
+ });
156
+ }
157
+ return current;
158
+ }
159
+ const isBuiltin = (p) => BUILTIN_PATTERNS.includes(p);
160
+ function compileRegex(p) {
161
+ if (p.length > MAX_REGEX_SOURCE_LENGTH) {
162
+ throw new Error(`invalid pattern: regex source exceeds ${MAX_REGEX_SOURCE_LENGTH} characters`);
163
+ }
164
+ try {
165
+ return { name: p, re: new RegExp(p, "g") };
166
+ }
167
+ catch (e) {
168
+ throw new Error(`invalid pattern /${p}/: ${e.message}`);
169
+ }
170
+ }
171
+ function compilePatterns(patterns) {
172
+ return { builtins: patterns.filter(isBuiltin), custom: patterns.filter((p) => !isBuiltin(p)).map(compileRegex) };
173
+ }
174
+ /** Built-ins only: what runs over tool outputs (a user regex never does). */
175
+ const BUILTINS_ONLY = compilePatterns(BUILTIN_PATTERNS);
176
+ /**
177
+ * Find ids and URLs in one string. Every pattern reads the whole text, so an
178
+ * id inside a URL's path, query or fragment is a candidate of its own.
179
+ */
180
+ function extract(text, compiled, path = "") {
181
+ const out = [];
182
+ const has = (p) => compiled.builtins.includes(p);
183
+ if (has("url")) {
184
+ for (const m of text.matchAll(URL_RE)) {
185
+ const value = m[0].replace(TRAILING_PUNCT_RE, "");
186
+ out.push({ path, value, type: "url", cls: urlCollection(normalizeUrl(value)) });
187
+ }
188
+ }
189
+ if (has("github_ref")) {
190
+ for (const m of text.matchAll(GITHUB_REF_RE)) {
191
+ out.push({ path, value: m[0], type: "github_ref", cls: `gh:${m[1]}/${m[2]}`.toLowerCase() });
192
+ }
193
+ }
194
+ if (has("issue_key")) {
195
+ for (const m of text.matchAll(ISSUE_KEY_RE))
196
+ out.push({ path, value: m[0], cls: `key:${m[1]}` });
197
+ }
198
+ for (const c of compiled.custom) {
199
+ for (const m of text.matchAll(c.re)) {
200
+ const value = m[1] ?? m[0];
201
+ if (value !== "")
202
+ out.push({ path, value });
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+ /** A whole value at an `ids` path, classified (for `stale`) when it has a recognizable kind. */
208
+ function wholeValue(value, path) {
209
+ const same = extract(value, BUILTINS_ONLY, path).find((c) => c.value === value);
210
+ return { ...(same ?? { path, value }), whole: true };
211
+ }
212
+ // ─── Evidence ────────────────────────────────────────────────────────
213
+ /** One body of evidence, indexed for lookup. Built in memory, never stored. */
214
+ class Corpus {
215
+ values = [];
216
+ keys = [];
217
+ urls = new Set();
218
+ bases = new Set();
219
+ refs = new Set();
220
+ add(value) {
221
+ const text = evidenceText(value);
222
+ this.keys.push(...text.keys);
223
+ for (const s of text.values) {
224
+ this.values.push(s);
225
+ for (const m of s.matchAll(URL_RE)) {
226
+ const url = normalizeUrl(m[0].replace(TRAILING_PUNCT_RE, ""));
227
+ this.urls.add(url);
228
+ this.bases.add(withoutFragment(url));
229
+ const gh = GITHUB_URL_RE.exec(withoutFragment(url));
230
+ if (gh)
231
+ this.refs.add(`${gh[1]}/${gh[2]}#${gh[3]}`.toLowerCase());
232
+ }
233
+ for (const m of s.matchAll(GITHUB_REF_RE))
234
+ this.refs.add(m[0].toLowerCase());
235
+ }
236
+ }
237
+ has(c) {
238
+ if (c.type === "url") {
239
+ // A fragment must have been seen; a link without one is grounded by its page.
240
+ const url = normalizeUrl(c.value);
241
+ return url.includes("#") ? this.urls.has(url) : this.bases.has(url);
242
+ }
243
+ if (c.type === "github_ref" && this.refs.has(c.value.toLowerCase()))
244
+ return true;
245
+ if (c.whole && NUMBER_RE.test(c.value))
246
+ return this.values.some((v) => v.trim() === c.value);
247
+ const texts = c.whole ? this.values : [...this.values, ...this.keys];
248
+ return texts.some((t) => containsToken(t, c.value));
249
+ }
250
+ /** Every id kind this corpus holds, by the built-in patterns only (undefined for a kindless URL). */
251
+ kinds() {
252
+ return new Set(this.values.flatMap((t) => extract(t, BUILTINS_ONLY)).map((c) => c.cls));
253
+ }
254
+ }
255
+ /** `needle` occurs in `hay` as a whole token: no letter, digit, `_` or `-` against a word edge. */
256
+ export function containsToken(hay, needle) {
257
+ const wordStart = WORD_CHAR.test(needle.charAt(0));
258
+ const wordEnd = WORD_CHAR.test(needle.charAt(needle.length - 1));
259
+ for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + 1)) {
260
+ const clearBefore = !wordStart || !WORD_CHAR.test(hay.charAt(i - 1));
261
+ const clearAfter = !wordEnd || !WORD_CHAR.test(hay.charAt(i + needle.length));
262
+ if (clearBefore && clearAfter)
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+ function didSucceed(c) {
268
+ if (c.status !== undefined)
269
+ return c.status === "success";
270
+ return !isErrorOutput(c.output);
271
+ }
272
+ /** sweny's own tools and built-ins have no server (sweny-core counts as none). */
273
+ function serverOf(c) {
274
+ return c.server === OWN_SERVER ? undefined : c.server;
275
+ }
276
+ /** A tool's result is evidence by default only when the tool reads. */
277
+ export function isEvidenceTool(c, toolAccess) {
278
+ if (serverOf(c) === undefined) {
279
+ const access = toolAccess?.get(c.tool);
280
+ if (access !== undefined)
281
+ return access === "read";
282
+ if (NOT_EVIDENCE_TOOLS.has(c.tool.toLowerCase()))
283
+ return false;
284
+ }
285
+ return !WRITE_VERB_RE.test(c.tool);
286
+ }
287
+ /** A `tools` entry names this call: a bare name for sweny's own or a built-in, else `server/tool` or `mcp__server__tool`. */
288
+ export function namesCall(name, c) {
289
+ const server = serverOf(c);
290
+ if (server === undefined)
291
+ return name === c.tool;
292
+ return name === `${server}/${c.tool}` || name === `mcp__${server}__${c.tool}`;
293
+ }
294
+ /** Successful calls that count: the named ones when `names` is set, else every read tool. */
295
+ function evidenceCalls(evidence, names) {
296
+ return evidence.toolCalls.filter((c) => didSucceed(c) &&
297
+ (names === undefined ? isEvidenceTool(c, evidence.toolAccess) : names.some((n) => namesCall(n, c))));
298
+ }
299
+ // ─── Reasoning ───────────────────────────────────────────────────────
300
+ function label(path, value) {
301
+ const shown = value.length > 120 ? `${value.slice(0, 119)}…` : value;
302
+ return `${path} ${JSON.stringify(shown)}`;
303
+ }
304
+ function listed(failures) {
305
+ const more = failures.length - MAX_LISTED;
306
+ return failures.slice(0, MAX_LISTED).join("; ") + (more > 0 ? `; and ${more} more` : "");
307
+ }
308
+ /** Resolve a configured path, noting it when it matched nothing. */
309
+ function resolveNoting(data, path, empty) {
310
+ const found = resolveConcrete(data, path);
311
+ if (found.length === 0)
312
+ empty.push(path);
313
+ return found;
314
+ }
315
+ // ─── The two checks ──────────────────────────────────────────────────
316
+ function checkIds(rule, data, evidence, empty) {
317
+ const compiled = compilePatterns(rule.patterns ?? DEFAULT_PATTERNS);
318
+ const roots = rule.paths === undefined ? [{ path: "", value: data }] : rule.paths.flatMap((p) => resolveNoting(data, p, empty));
319
+ const scanned = roots
320
+ .flatMap((r) => leaves(r.value, r.path, false))
321
+ .flatMap((l) => extract(l.text, compiled, l.path));
322
+ const whole = (rule.ids ?? [])
323
+ .flatMap((p) => resolveNoting(data, p, empty))
324
+ .flatMap((r) => leaves(r.value, r.path, true))
325
+ .filter((l) => l.text.trim() !== "")
326
+ .map((l) => wholeValue(l.text.trim(), l.path));
327
+ const tools = new Corpus();
328
+ for (const c of evidenceCalls(evidence, rule.tools))
329
+ tools.add(c.output);
330
+ const useInputs = rule.inputs !== false;
331
+ const useContext = rule.context !== false;
332
+ const given = new Corpus();
333
+ given.add(evidence.authored);
334
+ for (const [k, v] of Object.entries(evidence.context ?? {})) {
335
+ if (k !== evidence.nodeId && (k === "input" ? useInputs : useContext))
336
+ given.add(v);
337
+ }
338
+ const allow = new Set(rule.allow?.map(normalizeUrl));
339
+ const staleKinds = rule.stale === true ? tools.kinds() : undefined;
340
+ const failures = [];
341
+ const seen = new Set();
342
+ for (const c of [...scanned, ...whole]) {
343
+ const key = JSON.stringify([c.path, c.value]);
344
+ if (seen.has(key) || allow.has(normalizeUrl(c.value)) || tools.has(c))
345
+ continue;
346
+ seen.add(key);
347
+ if (!given.has(c))
348
+ failures.push(label(c.path, c.value));
349
+ else if (c.cls !== undefined && staleKinds?.has(c.cls)) {
350
+ failures.push(`${label(c.path, c.value)} (stale: this node's tools returned other ids of its kind, not this one)`);
351
+ }
352
+ }
353
+ if (failures.length === 0)
354
+ return [];
355
+ const sources = ["tool outputs", "instruction"];
356
+ if (useInputs)
357
+ sources.push("inputs");
358
+ if (useContext)
359
+ sources.push("prior context");
360
+ return [`${failures.length} value(s) not grounded (evidence: ${sources.join(", ")}): ${listed(failures)}`];
361
+ }
362
+ /** The whitespace rule for a quote. `trim` is the default. */
363
+ export function normalizeQuote(s, mode) {
364
+ if (mode === "exact")
365
+ return s;
366
+ if (mode === "collapse")
367
+ return s.replace(/\s+/g, " ").trim();
368
+ return s.trim();
369
+ }
370
+ function checkQuotes(rule, data, evidence, empty) {
371
+ const parts = [];
372
+ for (const q of rule.quotes ?? []) {
373
+ const mode = q.whitespace ?? "trim";
374
+ const min = q.min_length ?? DEFAULT_MIN_QUOTE;
375
+ const names = q.tools ?? rule.tools;
376
+ // Normalizing the output under trim changes nothing a trimmed quote can match; under collapse it is the rule.
377
+ const hay = evidenceCalls(evidence, names).flatMap((c) => evidenceText(c.output).values.map((s) => normalizeQuote(s, mode)));
378
+ const failures = [];
379
+ for (const l of resolveNoting(data, q.path, empty).flatMap((r) => leaves(r.value, r.path, true))) {
380
+ const needle = normalizeQuote(l.text, mode);
381
+ if (!/\S/.test(needle))
382
+ failures.push(`${label(l.path, l.text)} (empty)`);
383
+ else if (needle.length < min)
384
+ failures.push(`${label(l.path, l.text)} (shorter than ${min} characters)`);
385
+ else if (!hay.some((h) => h.includes(needle)))
386
+ failures.push(label(l.path, l.text));
387
+ }
388
+ if (failures.length > 0) {
389
+ const from = names === undefined ? "any read tool's" : names.join(", ");
390
+ parts.push(`${failures.length} quote(s) not found verbatim in ${from} output (whitespace: ${mode}): ${listed(failures)}`);
391
+ }
392
+ }
393
+ return parts;
394
+ }
395
+ // ─── Entry point ─────────────────────────────────────────────────────
396
+ /**
397
+ * Evaluate an `output_grounded` rule against the node's output and evidence.
398
+ * Never throws: a bad config (malformed path, invalid pattern) is a failure
399
+ * with a reason, not a silent pass. A configured path that matched nothing
400
+ * passes with a warning in the reasoning.
401
+ */
402
+ export function evaluateGroundedRule(rule, data, evidence) {
403
+ const empty = [];
404
+ let parts;
405
+ try {
406
+ parts = [...checkIds(rule, data, evidence, empty), ...checkQuotes(rule, data, evidence, empty)];
407
+ }
408
+ catch (e) {
409
+ return { pass: false, reasoning: `output_grounded: ${e.message}` };
410
+ }
411
+ const warning = empty.length > 0 ? [`warning: nothing at ${empty.join(", ")}`] : [];
412
+ const notes = [...parts, ...warning];
413
+ if (notes.length === 0)
414
+ return { pass: true };
415
+ return { pass: parts.length === 0, reasoning: `output_grounded: ${notes.join(" | ")}` };
416
+ }
@@ -1,4 +1,4 @@
1
- import type { Claude, EvalPolicy, EvalResult, Evaluator, NodeResult, Workflow, Node } from "../types.js";
1
+ import type { Claude, EvalPolicy, EvalResult, Evaluator, NodeResult, ToolAccess, Workflow, Node } from "../types.js";
2
2
  import { type ToolAliases } from "./function.js";
3
3
  export type { ToolAliases } from "./function.js";
4
4
  export interface EvaluateAllOptions {
@@ -20,6 +20,17 @@ export interface EvaluateAllOptions {
20
20
  signal?: AbortSignal;
21
21
  /** Per-call timeout forwarded to judge calls. */
22
22
  timeoutMs?: number;
23
+ /**
24
+ * The context the node's prompt received (`{ input, ...priorNodes }`).
25
+ * Evidence for `output_grounded`; held in memory, never journaled.
26
+ */
27
+ context?: Record<string, unknown>;
28
+ /** The evaluated node's id, so its own earlier visit in `context` is not evidence. */
29
+ nodeId?: string;
30
+ /** The author's text the prompt carried (instruction, rules, context sources). Evidence for `output_grounded`. */
31
+ authored?: string[];
32
+ /** Access class of the node's skill tools by name, so `output_grounded` counts only read tools. */
33
+ toolAccess?: ReadonlyMap<string, ToolAccess>;
23
34
  }
24
35
  /**
25
36
  * Run every declared evaluator against the node's result and return one
@@ -6,6 +6,7 @@
6
6
  import { evaluateValueRule } from "./value.js";
7
7
  import { evaluateFunctionRule } from "./function.js";
8
8
  import { evaluateJudge } from "./judge.js";
9
+ import { evaluateGroundedRule } from "./grounded.js";
9
10
  /** Reasoning cap, matching the spec's ~500-character ceiling. */
10
11
  const MAX_REASONING_LENGTH = 500;
11
12
  /**
@@ -78,8 +79,22 @@ async function evaluateOne(e, result, opts) {
78
79
  return { name: e.name, kind: "value", pass: verdict.pass, reasoning: capReasoning(verdict.reasoning) };
79
80
  }
80
81
  if (e.kind === "function") {
81
- const verdict = evaluateFunctionRule(e.rule ?? {}, result.toolCalls, opts.aliases);
82
- return { name: e.name, kind: "function", pass: verdict.pass, reasoning: capReasoning(verdict.reasoning) };
82
+ const rule = e.rule ?? {};
83
+ const verdicts = [evaluateFunctionRule(rule, result.toolCalls, opts.aliases)];
84
+ if (rule.output_grounded) {
85
+ verdicts.push(evaluateGroundedRule(rule.output_grounded, result.data, {
86
+ toolCalls: result.toolCalls,
87
+ ...(opts.context ? { context: opts.context } : {}),
88
+ ...(opts.nodeId !== undefined ? { nodeId: opts.nodeId } : {}),
89
+ ...(opts.authored ? { authored: opts.authored } : {}),
90
+ ...(opts.toolAccess ? { toolAccess: opts.toolAccess } : {}),
91
+ }));
92
+ }
93
+ const failed = verdicts.filter((v) => !v.pass);
94
+ // A passing verdict can still carry a warning (output_grounded: a path that matched nothing).
95
+ const notes = (failed.length > 0 ? failed : verdicts).flatMap((v) => (v.reasoning ? [v.reasoning] : []));
96
+ const reasoning = notes.length > 0 ? notes.join("; ") : undefined;
97
+ return { name: e.name, kind: "function", pass: failed.length === 0, reasoning: capReasoning(reasoning) };
83
98
  }
84
99
  if (e.kind === "judge" && opts.claude) {
85
100
  const model = resolveJudgeModel(e, opts.node, opts.workflow, opts.claude.defaultJudgeModel);
package/dist/executor.js CHANGED
@@ -934,6 +934,12 @@ async function executeRun(workflow, input, options, results, trace, at) {
934
934
  claude: judgeClaude,
935
935
  node,
936
936
  workflow,
937
+ // What the prompt was given: evidence for output_grounded (#580).
938
+ context,
939
+ nodeId: currentId,
940
+ // The author's own text (never a retry preamble), and which skill tools only read.
941
+ authored: [resolvedInstruction, effectiveRules, effectiveContext],
942
+ toolAccess: new Map(filteredTools.map((t) => [t.name, t.access ?? "write"])),
937
943
  sequential: budgetOn,
938
944
  ...(signal ? { signal } : {}),
939
945
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
@@ -774,9 +774,13 @@ export class ClaudeCodeHarness {
774
774
  if (am.message?.content && Array.isArray(am.message.content)) {
775
775
  for (const block of am.message.content) {
776
776
  if (block.type === "tool_use") {
777
+ const server = mcpServerOf(block.name ?? "");
777
778
  const call = {
778
779
  tool: stripMcpPrefix(block.name ?? ""),
779
780
  input: block.input,
781
+ // External MCP tools keep their server, so output_grounded can tell
782
+ // linear's get_issue from another server's (#580).
783
+ ...(server !== undefined && server !== "sweny-core" ? { server } : {}),
780
784
  };
781
785
  toolCalls.push(call);
782
786
  if (typeof block.id === "string")
@@ -1058,6 +1062,11 @@ export function coreToolToSdkTool(coreTool, defaultCtx) {
1058
1062
  });
1059
1063
  }
1060
1064
  /** Strip MCP server prefix: "mcp__server__tool" → "tool" */
1065
+ /** The server of an `mcp__server__tool` name, else undefined. */
1066
+ function mcpServerOf(name) {
1067
+ const parts = name.split("__");
1068
+ return parts.length >= 3 && parts[0] === "mcp" ? parts[1] : undefined;
1069
+ }
1061
1070
  function stripMcpPrefix(name) {
1062
1071
  const parts = name.split("__");
1063
1072
  if (parts.length >= 3 && parts[0] === "mcp")
@@ -762,7 +762,9 @@ export class CodexHarness {
762
762
  : kind === "mcp_tool_call" || kind === "collab_tool_call"
763
763
  ? String(item.tool ?? "")
764
764
  : "web_search";
765
- call = { tool, input };
765
+ const server = kind === "mcp_tool_call" && typeof item.server === "string" ? item.server : undefined;
766
+ // External MCP tools keep their server for output_grounded (#580).
767
+ call = { tool, input, ...(server !== undefined && server !== "sweny-core" ? { server } : {}) };
766
768
  startCall(id, call);
767
769
  const detail = kind === "command_execution" ? `shell: ${String(item.command ?? "")}` : tool;
768
770
  opts.onProgress?.(detail.length > 80 ? detail.slice(0, 79) + "…" : detail);
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * })
22
22
  * ```
23
23
  */
24
- export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ToolRunContext, ConfigField, JSONSchema, Workflow, Node, Edge, EdgeWhen, WhenExpression, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, NodeAccess, NodePermissions, NodePermissionsSpec, SafeOutputType, SafeOutputDeclaration, SafeOutputsPolicy, SafeOutputReceipt, SafeOutputPin, AuthorAssociation, } from "./types.js";
24
+ export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ToolRunContext, ConfigField, JSONSchema, Workflow, Node, Edge, EdgeWhen, WhenExpression, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, OutputGroundedRule, OutputGroundedQuote, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, NodeAccess, NodePermissions, NodePermissionsSpec, SafeOutputType, SafeOutputDeclaration, SafeOutputsPolicy, SafeOutputReceipt, SafeOutputPin, AuthorAssociation, } from "./types.js";
25
25
  export { WORKFLOW_INPUT_TYPES } from "./types.js";
26
26
  export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, NODE_ACCESS, TOOL_CLASSES, SAFE_OUTPUT_TYPES, SAFE_OUTPUT_APPLIERS, AUTHOR_ASSOCIATIONS, } from "./types.js";
27
27
  export { consoleLogger } from "./types.js";
package/dist/schema.d.ts CHANGED
@@ -149,6 +149,27 @@ export declare const evalPolicyZ: z.ZodEnum<{
149
149
  any_pass: "any_pass";
150
150
  weighted: "weighted";
151
151
  }>;
152
+ /** `output_grounded` config (#580). Mirrors `OutputGroundedRule` in types.ts. */
153
+ export declare const outputGroundedZ: z.ZodObject<{
154
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
155
+ ids: z.ZodOptional<z.ZodArray<z.ZodString>>;
156
+ patterns: z.ZodOptional<z.ZodArray<z.ZodString>>;
157
+ allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
158
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
159
+ inputs: z.ZodOptional<z.ZodBoolean>;
160
+ context: z.ZodOptional<z.ZodBoolean>;
161
+ stale: z.ZodOptional<z.ZodBoolean>;
162
+ quotes: z.ZodOptional<z.ZodArray<z.ZodObject<{
163
+ path: z.ZodString;
164
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
165
+ whitespace: z.ZodOptional<z.ZodEnum<{
166
+ exact: "exact";
167
+ trim: "trim";
168
+ collapse: "collapse";
169
+ }>>;
170
+ min_length: z.ZodOptional<z.ZodNumber>;
171
+ }, z.core.$strict>>>;
172
+ }, z.core.$strict>;
152
173
  export declare const evaluatorRuleZ: z.ZodObject<{
153
174
  any_tool_called: z.ZodOptional<z.ZodArray<z.ZodString>>;
154
175
  all_tools_called: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -160,6 +181,26 @@ export declare const evaluatorRuleZ: z.ZodObject<{
160
181
  in: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
161
182
  matches: z.ZodOptional<z.ZodString>;
162
183
  }, z.core.$strict>>>;
184
+ output_grounded: z.ZodOptional<z.ZodObject<{
185
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
186
+ ids: z.ZodOptional<z.ZodArray<z.ZodString>>;
187
+ patterns: z.ZodOptional<z.ZodArray<z.ZodString>>;
188
+ allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
189
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
190
+ inputs: z.ZodOptional<z.ZodBoolean>;
191
+ context: z.ZodOptional<z.ZodBoolean>;
192
+ stale: z.ZodOptional<z.ZodBoolean>;
193
+ quotes: z.ZodOptional<z.ZodArray<z.ZodObject<{
194
+ path: z.ZodString;
195
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
196
+ whitespace: z.ZodOptional<z.ZodEnum<{
197
+ exact: "exact";
198
+ trim: "trim";
199
+ collapse: "collapse";
200
+ }>>;
201
+ min_length: z.ZodOptional<z.ZodNumber>;
202
+ }, z.core.$strict>>>;
203
+ }, z.core.$strict>>;
163
204
  }, z.core.$strict>;
164
205
  /**
165
206
  * A single evaluator. The `kind` field discriminates required-fields:
@@ -185,6 +226,26 @@ export declare const evaluatorZ: z.ZodObject<{
185
226
  in: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
186
227
  matches: z.ZodOptional<z.ZodString>;
187
228
  }, z.core.$strict>>>;
229
+ output_grounded: z.ZodOptional<z.ZodObject<{
230
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
231
+ ids: z.ZodOptional<z.ZodArray<z.ZodString>>;
232
+ patterns: z.ZodOptional<z.ZodArray<z.ZodString>>;
233
+ allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
234
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
235
+ inputs: z.ZodOptional<z.ZodBoolean>;
236
+ context: z.ZodOptional<z.ZodBoolean>;
237
+ stale: z.ZodOptional<z.ZodBoolean>;
238
+ quotes: z.ZodOptional<z.ZodArray<z.ZodObject<{
239
+ path: z.ZodString;
240
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
241
+ whitespace: z.ZodOptional<z.ZodEnum<{
242
+ exact: "exact";
243
+ trim: "trim";
244
+ collapse: "collapse";
245
+ }>>;
246
+ min_length: z.ZodOptional<z.ZodNumber>;
247
+ }, z.core.$strict>>>;
248
+ }, z.core.$strict>>;
188
249
  }, z.core.$strict>>;
189
250
  rubric: z.ZodOptional<z.ZodString>;
190
251
  pass_when: z.ZodOptional<z.ZodString>;
@@ -467,6 +528,26 @@ export declare const nodeZ: z.ZodObject<{
467
528
  in: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
468
529
  matches: z.ZodOptional<z.ZodString>;
469
530
  }, z.core.$strict>>>;
531
+ output_grounded: z.ZodOptional<z.ZodObject<{
532
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
533
+ ids: z.ZodOptional<z.ZodArray<z.ZodString>>;
534
+ patterns: z.ZodOptional<z.ZodArray<z.ZodString>>;
535
+ allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
536
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
537
+ inputs: z.ZodOptional<z.ZodBoolean>;
538
+ context: z.ZodOptional<z.ZodBoolean>;
539
+ stale: z.ZodOptional<z.ZodBoolean>;
540
+ quotes: z.ZodOptional<z.ZodArray<z.ZodObject<{
541
+ path: z.ZodString;
542
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
543
+ whitespace: z.ZodOptional<z.ZodEnum<{
544
+ exact: "exact";
545
+ trim: "trim";
546
+ collapse: "collapse";
547
+ }>>;
548
+ min_length: z.ZodOptional<z.ZodNumber>;
549
+ }, z.core.$strict>>>;
550
+ }, z.core.$strict>>;
470
551
  }, z.core.$strict>>;
471
552
  rubric: z.ZodOptional<z.ZodString>;
472
553
  pass_when: z.ZodOptional<z.ZodString>;
@@ -675,6 +756,26 @@ export declare const workflowZ: z.ZodObject<{
675
756
  in: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
676
757
  matches: z.ZodOptional<z.ZodString>;
677
758
  }, z.core.$strict>>>;
759
+ output_grounded: z.ZodOptional<z.ZodObject<{
760
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
761
+ ids: z.ZodOptional<z.ZodArray<z.ZodString>>;
762
+ patterns: z.ZodOptional<z.ZodArray<z.ZodString>>;
763
+ allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
764
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
765
+ inputs: z.ZodOptional<z.ZodBoolean>;
766
+ context: z.ZodOptional<z.ZodBoolean>;
767
+ stale: z.ZodOptional<z.ZodBoolean>;
768
+ quotes: z.ZodOptional<z.ZodArray<z.ZodObject<{
769
+ path: z.ZodString;
770
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
771
+ whitespace: z.ZodOptional<z.ZodEnum<{
772
+ exact: "exact";
773
+ trim: "trim";
774
+ collapse: "collapse";
775
+ }>>;
776
+ min_length: z.ZodOptional<z.ZodNumber>;
777
+ }, z.core.$strict>>>;
778
+ }, z.core.$strict>>;
678
779
  }, z.core.$strict>>;
679
780
  rubric: z.ZodOptional<z.ZodString>;
680
781
  pass_when: z.ZodOptional<z.ZodString>;
@@ -938,6 +1039,22 @@ export declare function parseWorkflow(raw: unknown): {
938
1039
  in?: unknown[] | undefined;
939
1040
  matches?: string | undefined;
940
1041
  }[] | undefined;
1042
+ output_grounded?: {
1043
+ paths?: string[] | undefined;
1044
+ ids?: string[] | undefined;
1045
+ patterns?: string[] | undefined;
1046
+ allow?: string[] | undefined;
1047
+ tools?: string[] | undefined;
1048
+ inputs?: boolean | undefined;
1049
+ context?: boolean | undefined;
1050
+ stale?: boolean | undefined;
1051
+ quotes?: {
1052
+ path: string;
1053
+ tools?: string[] | undefined;
1054
+ whitespace?: "exact" | "trim" | "collapse" | undefined;
1055
+ min_length?: number | undefined;
1056
+ }[] | undefined;
1057
+ } | undefined;
941
1058
  } | undefined;
942
1059
  rubric?: string | undefined;
943
1060
  pass_when?: string | undefined;
@@ -1233,6 +1350,93 @@ export declare const workflowJsonSchema: {
1233
1350
  };
1234
1351
  readonly minItems: 1;
1235
1352
  };
1353
+ readonly output_grounded: {
1354
+ readonly type: "object";
1355
+ readonly description: "function only. Every id and URL in the output must appear in the node's successful read-tool outputs, its instruction, the workflow input or prior nodes' context.";
1356
+ readonly additionalProperties: false;
1357
+ readonly properties: {
1358
+ readonly paths: {
1359
+ readonly type: "array";
1360
+ readonly items: {
1361
+ readonly type: "string";
1362
+ readonly minLength: 1;
1363
+ };
1364
+ readonly minItems: 1;
1365
+ };
1366
+ readonly ids: {
1367
+ readonly type: "array";
1368
+ readonly items: {
1369
+ readonly type: "string";
1370
+ readonly minLength: 1;
1371
+ };
1372
+ readonly minItems: 1;
1373
+ };
1374
+ readonly patterns: {
1375
+ readonly type: "array";
1376
+ readonly items: {
1377
+ readonly type: "string";
1378
+ readonly minLength: 1;
1379
+ };
1380
+ };
1381
+ readonly allow: {
1382
+ readonly type: "array";
1383
+ readonly items: {
1384
+ readonly type: "string";
1385
+ readonly minLength: 1;
1386
+ };
1387
+ readonly minItems: 1;
1388
+ };
1389
+ readonly tools: {
1390
+ readonly type: "array";
1391
+ readonly items: {
1392
+ readonly type: "string";
1393
+ readonly minLength: 1;
1394
+ };
1395
+ readonly minItems: 1;
1396
+ };
1397
+ readonly inputs: {
1398
+ readonly type: "boolean";
1399
+ };
1400
+ readonly context: {
1401
+ readonly type: "boolean";
1402
+ };
1403
+ readonly stale: {
1404
+ readonly type: "boolean";
1405
+ };
1406
+ readonly quotes: {
1407
+ readonly type: "array";
1408
+ readonly minItems: 1;
1409
+ readonly description: "Values that must appear verbatim in a tool's output.";
1410
+ readonly items: {
1411
+ readonly type: "object";
1412
+ readonly required: readonly ["path"];
1413
+ readonly additionalProperties: false;
1414
+ readonly properties: {
1415
+ readonly path: {
1416
+ readonly type: "string";
1417
+ readonly minLength: 1;
1418
+ };
1419
+ readonly tools: {
1420
+ readonly type: "array";
1421
+ readonly items: {
1422
+ readonly type: "string";
1423
+ readonly minLength: 1;
1424
+ };
1425
+ readonly minItems: 1;
1426
+ };
1427
+ readonly whitespace: {
1428
+ readonly type: "string";
1429
+ readonly enum: readonly ["exact", "trim", "collapse"];
1430
+ };
1431
+ readonly min_length: {
1432
+ readonly type: "integer";
1433
+ readonly minimum: 1;
1434
+ };
1435
+ };
1436
+ };
1437
+ };
1438
+ };
1439
+ };
1236
1440
  };
1237
1441
  };
1238
1442
  readonly rubric: {
@@ -1286,6 +1490,23 @@ export declare const workflowJsonSchema: {
1286
1490
  readonly required: readonly ["rule"];
1287
1491
  };
1288
1492
  };
1493
+ }, {
1494
+ readonly if: {
1495
+ readonly properties: {
1496
+ readonly kind: {
1497
+ readonly const: "value";
1498
+ };
1499
+ };
1500
+ };
1501
+ readonly then: {
1502
+ readonly properties: {
1503
+ readonly rule: {
1504
+ readonly not: {
1505
+ readonly required: readonly ["output_grounded"];
1506
+ };
1507
+ };
1508
+ };
1509
+ };
1289
1510
  }];
1290
1511
  };
1291
1512
  readonly Permissions: {
package/dist/schema.js CHANGED
@@ -126,6 +126,31 @@ export const evalPolicyZ = z.enum(EVAL_POLICIES);
126
126
  * workflow fails at load time rather than throwing mid-run from `aggregateEval`.
127
127
  */
128
128
  const SUPPORTED_EVAL_POLICIES = new Set(["all_pass"]);
129
+ /** `output_grounded` config (#580). Mirrors `OutputGroundedRule` in types.ts. */
130
+ export const outputGroundedZ = z
131
+ .object({
132
+ paths: z.array(z.string().min(1)).min(1).optional(),
133
+ ids: z.array(z.string().min(1)).min(1).optional(),
134
+ // `[]` is allowed: it turns the pattern scan off.
135
+ patterns: z.array(z.string().min(1)).optional(),
136
+ allow: z.array(z.string().min(1)).min(1).optional(),
137
+ tools: z.array(z.string().min(1)).min(1).optional(),
138
+ inputs: z.boolean().optional(),
139
+ context: z.boolean().optional(),
140
+ stale: z.boolean().optional(),
141
+ quotes: z
142
+ .array(z
143
+ .object({
144
+ path: z.string().min(1),
145
+ tools: z.array(z.string().min(1)).min(1).optional(),
146
+ whitespace: z.enum(["exact", "trim", "collapse"]).optional(),
147
+ min_length: z.number().int().min(1).optional(),
148
+ })
149
+ .strict())
150
+ .min(1)
151
+ .optional(),
152
+ })
153
+ .strict();
129
154
  export const evaluatorRuleZ = z
130
155
  .object({
131
156
  any_tool_called: z.array(z.string().min(1)).min(1).optional(),
@@ -133,14 +158,16 @@ export const evaluatorRuleZ = z
133
158
  no_tool_called: z.array(z.string().min(1)).min(1).optional(),
134
159
  output_required: z.array(z.string().min(1)).min(1).optional(),
135
160
  output_matches: z.array(outputMatchZ).min(1).optional(),
161
+ output_grounded: outputGroundedZ.optional(),
136
162
  })
137
163
  .strict()
138
164
  .refine((r) => r.any_tool_called !== undefined ||
139
165
  r.all_tools_called !== undefined ||
140
166
  r.no_tool_called !== undefined ||
141
167
  r.output_required !== undefined ||
142
- r.output_matches !== undefined, {
143
- message: "evaluator rule must declare at least one of: any_tool_called, all_tools_called, no_tool_called, output_required, output_matches",
168
+ r.output_matches !== undefined ||
169
+ r.output_grounded !== undefined, {
170
+ message: "evaluator rule must declare at least one of: any_tool_called, all_tools_called, no_tool_called, output_required, output_matches, output_grounded",
144
171
  });
145
172
  /**
146
173
  * A single evaluator. The `kind` field discriminates required-fields:
@@ -175,6 +202,14 @@ export const evaluatorZ = z
175
202
  path: ["rule"],
176
203
  });
177
204
  }
205
+ // output_grounded reads the tool trace; a value evaluator would skip it and pass silently.
206
+ if (e.kind === "value" && e.rule?.output_grounded !== undefined) {
207
+ ctx.addIssue({
208
+ code: z.ZodIssueCode.custom,
209
+ message: `evaluator '${e.name}' declares output_grounded on kind: value; output_grounded reads the tool trace, so declare it on kind: function`,
210
+ path: ["rule", "output_grounded"],
211
+ });
212
+ }
178
213
  if (e.rubric !== undefined || e.pass_when !== undefined || e.model !== undefined) {
179
214
  ctx.addIssue({
180
215
  code: z.ZodIssueCode.custom,
@@ -967,6 +1002,37 @@ export const workflowJsonSchema = {
967
1002
  no_tool_called: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 },
968
1003
  output_required: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 },
969
1004
  output_matches: { type: "array", items: { $ref: "#/$defs/OutputMatch" }, minItems: 1 },
1005
+ output_grounded: {
1006
+ type: "object",
1007
+ description: "function only. Every id and URL in the output must appear in the node's successful read-tool outputs, its instruction, the workflow input or prior nodes' context.",
1008
+ additionalProperties: false,
1009
+ properties: {
1010
+ paths: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 },
1011
+ ids: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 },
1012
+ patterns: { type: "array", items: { type: "string", minLength: 1 } },
1013
+ allow: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 },
1014
+ tools: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 },
1015
+ inputs: { type: "boolean" },
1016
+ context: { type: "boolean" },
1017
+ stale: { type: "boolean" },
1018
+ quotes: {
1019
+ type: "array",
1020
+ minItems: 1,
1021
+ description: "Values that must appear verbatim in a tool's output.",
1022
+ items: {
1023
+ type: "object",
1024
+ required: ["path"],
1025
+ additionalProperties: false,
1026
+ properties: {
1027
+ path: { type: "string", minLength: 1 },
1028
+ tools: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 },
1029
+ whitespace: { type: "string", enum: ["exact", "trim", "collapse"] },
1030
+ min_length: { type: "integer", minimum: 1 },
1031
+ },
1032
+ },
1033
+ },
1034
+ },
1035
+ },
970
1036
  },
971
1037
  },
972
1038
  rubric: {
@@ -1009,6 +1075,11 @@ export const workflowJsonSchema = {
1009
1075
  not: { required: ["rule"] },
1010
1076
  },
1011
1077
  },
1078
+ {
1079
+ // output_grounded reads the tool trace: function only.
1080
+ if: { properties: { kind: { const: "value" } } },
1081
+ then: { properties: { rule: { not: { required: ["output_grounded"] } } } },
1082
+ },
1012
1083
  ],
1013
1084
  },
1014
1085
  Permissions: {
package/dist/types.d.ts CHANGED
@@ -288,6 +288,67 @@ export interface EvaluatorRule {
288
288
  output_required?: string[];
289
289
  /** Each assertion must hold against `result.data`. (`value` rules.) */
290
290
  output_matches?: OutputMatch[];
291
+ /**
292
+ * Every id and URL in `result.data` must appear in what the agent saw: its
293
+ * successful read-tool outputs, its own instruction, plus (by default) the
294
+ * workflow input and prior nodes' context. (`function` rules only.) See
295
+ * {@link OutputGroundedRule}.
296
+ */
297
+ output_grounded?: OutputGroundedRule;
298
+ }
299
+ /**
300
+ * Config for the `output_grounded` check (#580). Every field is optional;
301
+ * `{}` scans the whole output for URLs and `owner/repo#N` refs.
302
+ */
303
+ export interface OutputGroundedRule {
304
+ /** Paths into `result.data` whose strings are scanned with `patterns`. Default: the whole output. */
305
+ paths?: string[];
306
+ /** Paths whose string or number values are each one id, checked whole (e.g. `items[*].ticket_id`). */
307
+ ids?: string[];
308
+ /**
309
+ * What counts as an id in scanned text: built-in names `url`, `github_ref`
310
+ * (`owner/repo#123`), `issue_key` (`ENG-123`), or a JavaScript regex source
311
+ * (its first capture group is the id when it has one). Default: `url` and
312
+ * `github_ref`; issue keys only when listed (prose is full of `UTF-8`-style
313
+ * tokens), or at `ids` paths. `[]` turns the scan off.
314
+ */
315
+ patterns?: string[];
316
+ /** Constants that always pass (URLs compared normalized). */
317
+ allow?: string[];
318
+ /**
319
+ * Only these tools' outputs count as evidence, whatever their class. A bare
320
+ * name matches sweny's own or a built-in tool; an MCP tool is named
321
+ * `server/tool` (or `mcp__server__tool`). Default: every successful read tool.
322
+ */
323
+ tools?: string[];
324
+ /** The workflow input counts as evidence. Default `true`. */
325
+ inputs?: boolean;
326
+ /** Prior nodes' context (what this node's prompt was given) counts as evidence. Default `true`. */
327
+ context?: boolean;
328
+ /**
329
+ * Fail an id seen only in the input or prior context when this node's tool
330
+ * outputs returned other ids of the same kind (same issue-key prefix, same
331
+ * repo, same URL collection) but not this one. Default `false`.
332
+ */
333
+ stale?: boolean;
334
+ /** Values that must appear verbatim in a tool's output (never in the input or context). */
335
+ quotes?: OutputGroundedQuote[];
336
+ }
337
+ /** One `output_grounded.quotes` entry: the values at `path` must be copied from a tool's output. */
338
+ export interface OutputGroundedQuote {
339
+ /** Path into `result.data`; every string or number value under it is one quote. */
340
+ path: string;
341
+ /** Tools whose outputs the quote must come from, named as in the rule's `tools`. Default: the rule's `tools`, else any read tool. */
342
+ tools?: string[];
343
+ /** Shortest quote accepted, counted after whitespace normalization. Default 8. */
344
+ min_length?: number;
345
+ /**
346
+ * How whitespace compares. `exact`: byte for byte. `trim` (default): the
347
+ * quote's leading and trailing whitespace is ignored. `collapse`: every run
348
+ * of whitespace, in the quote and the tool output, counts as one space, and
349
+ * the quote is trimmed.
350
+ */
351
+ whitespace?: "exact" | "trim" | "collapse";
291
352
  }
292
353
  /**
293
354
  * A single evaluator on a node.
@@ -859,6 +920,12 @@ export interface ToolCall {
859
920
  * evaluators fall back to inspecting `output` for an `error` key.
860
921
  */
861
922
  status?: "success" | "error";
923
+ /**
924
+ * The MCP server an external tool came from (`linear` for
925
+ * `mcp__linear__get_issue`). Absent for sweny's own tools, built-ins, and
926
+ * harnesses that do not report it. `output_grounded` matches `tools` on it.
927
+ */
928
+ server?: string;
862
929
  }
863
930
  export type ExecutionEvent = {
864
931
  type: "workflow:start";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.216",
3
+ "version": "0.1.217",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"