@sweny-ai/core 0.1.213 → 0.1.215

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.
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * This replaces ~8k lines of engine + recipe step code.
14
14
  */
15
- import type { Workflow, Node, Skill, Tool, Claude, Observer, NodeResult, Logger, ExecutionResult, JSONSchema, EvalResult, Evaluator, ToolCall } from "./types.js";
15
+ import type { Workflow, Node, Skill, Tool, Claude, Observer, NodeResult, Logger, ExecutionResult, EvalResult, Evaluator, ToolCall } from "./types.js";
16
16
  import type { ToolAliases } from "./eval/index.js";
17
17
  import type { HarnessPolicyMode } from "./harness/policy.js";
18
18
  import type { AgentHarness } from "./harness/types.js";
@@ -20,6 +20,7 @@ import type { DeciderOperatorConfig } from "./decider.js";
20
20
  import type { Budget } from "./budget.js";
21
21
  import { type ActorInfo } from "./safe-outputs.js";
22
22
  import { type ExecutionJournal } from "./journal.js";
23
+ import { COUNT_OF_KEYWORD, outputContractProblems } from "./output-contract.js";
23
24
  export interface ExecuteOptions {
24
25
  /** Registered skills (id → Skill) */
25
26
  skills: Map<string, Skill>;
@@ -175,22 +176,8 @@ export declare function deciderRoutingState(workflow: Workflow, current: string,
175
176
  * @internal
176
177
  */
177
178
  export declare function buildBoundedContext(workflow: Workflow, nodeId: string, results: Map<string, NodeResult>, input: unknown, instruction: string): Record<string, unknown>;
178
- /** One way a node's emitted data breaks its declared output contract. */
179
- export interface ContractProblem {
180
- field: string;
181
- kind: "missing" | "invalid";
182
- problem: string;
183
- }
184
- /** Keyword on an integer output property: the count sweny cross-checks against an array in the same output. */
185
- export declare const COUNT_OF_KEYWORD = "x-sweny-count-of";
186
- /**
187
- * Check emitted data against the declared output contract (#357): every
188
- * `required` field present; every present top-level property of its declared
189
- * `type` and inside its `enum`; and every `x-sweny-count-of` integer equal to
190
- * the count it declares over its array. Not full JSON Schema: formats,
191
- * nested shapes and additionalProperties stay the author's concern.
192
- */
193
- export declare function outputContractProblems(data: unknown, output: JSONSchema | undefined): ContractProblem[];
179
+ export { COUNT_OF_KEYWORD, outputContractProblems };
180
+ export type { ContractProblem } from "./output-contract.js";
194
181
  /**
195
182
  * Required tools the agent never called, when that is the only reason a node
196
183
  * failed its evals and another turn could call them: every failure is a
@@ -216,4 +203,3 @@ export declare function isReadTool(tool: Pick<Tool, "access">): boolean;
216
203
  * any side effect.
217
204
  */
218
205
  export declare function guardStagedWrite(tool: Pick<Tool, "name" | "access">, staged: boolean): void;
219
- export {};
package/dist/executor.js CHANGED
@@ -37,6 +37,7 @@ import { redact, runSecretValues } from "./journal.js";
37
37
  import { buildNodePolicy, hasNodePolicy, resolveNodePermissions } from "./node-policy.js";
38
38
  import { applySafeOutputs, createEmitOutputTool, createWriteStageState, resolveActor, safeOutputsInstruction, unresolvedOutputs, SAFE_OUTPUT_SCREEN_INSTRUCTION, } from "./safe-outputs.js";
39
39
  import { JournalWriteError } from "./journal.js";
40
+ import { COUNT_OF_KEYWORD, checkOutputContract, contractFailureMessage, contractPaths, listContractProblems, outputContractProblems, } from "./output-contract.js";
40
41
  /**
41
42
  * Default workflow-level step budget. Counts total node executions across the
42
43
  * whole run (loop iterations included). Generous enough for legitimate deep
@@ -119,7 +120,7 @@ async function executeRun(workflow, input, options, results, trace, at) {
119
120
  const nodeRunCounts = new Map(); // node → times executed
120
121
  const maxSteps = options.max_steps ?? DEFAULT_MAX_STEPS;
121
122
  let stepCount = 0; // total node executions across the run (loop iterations included)
122
- validate(workflow, skills);
123
+ validate(workflow, skills, logger);
123
124
  // Safe outputs (#365): run-wide caps and dedupe, the staged switch and the
124
125
  // triggering actor, resolved once per run.
125
126
  const writeState = createWriteStageState();
@@ -768,6 +769,8 @@ async function executeRun(workflow, input, options, results, trace, at) {
768
769
  context,
769
770
  tools: trackedTools,
770
771
  outputSchema: harnessSchema(node.output),
772
+ // The executor checks the output and asks for one repair itself, the same on every harness.
773
+ ...(node.output ? { callerRepairsOutput: true } : {}),
771
774
  maxTurns: node.max_turns,
772
775
  disallowedTools: node.disallowed_tools,
773
776
  ...(denyClasses.length > 0 ? { deny: denyClasses } : {}),
@@ -854,73 +857,69 @@ async function executeRun(workflow, input, options, results, trace, at) {
854
857
  agentRunFailed = true;
855
858
  break;
856
859
  }
857
- // Required-field gate: if the source node's declared output schema
858
- // marks fields as `required`, those fields are the routing contract.
859
- // An agent that omits a required field has violated the contract; we
860
- // fail the node loudly rather than letting routing guess against a
861
- // half-built data view. See the offload release-notes field bug:
862
- // an optional quality_retry_count was declared in the schema but
863
- // omitted by the agent, the route view silently dropped the key,
864
- // and an "is 0 OR is undefined" condition ghost-matched.
865
- //
866
- // We only check declared `required` fields, not full JSON Schema
867
- // validation. Type mismatches, formats, additionalProperties, etc.
868
- // stay the workflow author's problem and don't have the same
869
- // routing-correctness blast radius.
870
- // A missing required field is a contract violation handled on the same
871
- // footing as an eval failure: an otherwise-recoverable one-off omission
872
- // should self-heal on a second attempt when the node has `retry`
873
- // configured. We synthesize an eval-shaped failure so the retry preamble
874
- // and trace bookkeeping below treat it identically. When no retry is
875
- // configured (or the budget is exhausted), it still hard-fails the node.
876
- // Output contract (#357): required fields present, declared types and
877
- // enums respected, declared counts consistent. One repair round trip
878
- // first, for every harness: the agent is told exactly what is wrong.
879
- const contract = outputContractProblems(result.data, node.output);
860
+ // Output contract (#357, #573): the emitted data is checked against the
861
+ // node's declared `output` JSON Schema at every depth (nested objects,
862
+ // arrays of objects, enums, `required` at any depth,
863
+ // additionalProperties), plus `x-sweny-count-of` counts. The check runs
864
+ // here, on the data every harness returns, so a violation reads the same
865
+ // whichever harness ran the node, each problem with its exact path and
866
+ // never the emitted value. One repair round trip first, for every
867
+ // harness: the agent is told exactly what is wrong. A check that cannot
868
+ // run (it threw) fails this node, never the run, and gets no repair.
869
+ const checked = checkOutputContract(result.data, node.output);
870
+ const contract = "problems" in checked ? checked.problems : [];
880
871
  if (contract.length > 0 && !repaired) {
881
872
  // Part of this node visit, like a retry of the same call: no extra
882
873
  // max_steps slot. Its spend still counts (budget, journal usage).
883
874
  repaired = true;
884
- const listed = contract.map((p) => `- ${p.field}: ${p.problem}`).join("\n");
885
- logger.warn(` output contract: ${contract.map((p) => p.field).join(", ")}; asking the agent once to fix it`, {
875
+ const listed = listContractProblems(contract)
876
+ .map((line) => `- ${line}`)
877
+ .join("\n");
878
+ logger.warn(` output contract: ${contractPaths(contract).join(", ")}; asking the agent once to fix it`, {
886
879
  node: currentId,
887
880
  });
888
881
  safeObserve(observer, {
889
882
  type: "node:warning",
890
883
  node: currentId,
891
884
  reason: "output contract violated; one repair attempt",
892
- fields: contract.map((p) => p.field),
885
+ fields: contractPaths(contract),
893
886
  }, logger);
894
887
  currentInstruction =
895
888
  `Your previous output did not match this step's declared output schema:\n${listed}\n\n` +
896
- `Do the step again and return the complete output with every listed field present and of the declared type.` +
889
+ `Do the step again and return the complete output, with every listed field present and matching the declared schema.` +
897
890
  `\n\n---\n\n${currentInstruction}`;
898
891
  continue;
899
892
  }
900
- // Still wrong after the repair. A field an expression routes on is not a
901
- // node failure: routes that read it fall through to the next rung
902
- // (decider or agent) at routing time. Other missing required fields keep
903
- // failing the node, as before.
893
+ // Still wrong after the repair. A problem under a field an expression
894
+ // routes on is not a node failure: routes that read it fall through to
895
+ // the next rung (decider or agent) at routing time. Every other problem
896
+ // fails the node, on the same footing as an eval failure: with `retry`
897
+ // configured, the reflection and retry path below gets another attempt.
898
+ // A schema that does not compile keeps the pre-#573 rule (deprecated):
899
+ // only a missing field fails the node.
904
900
  const routedOn = routingFieldsOf(workflow, currentId);
905
901
  const tolerated = contract.filter((p) => routedOn.has(p.field));
906
902
  if (tolerated.length > 0) {
907
- logger.warn(` output contract still violated for routing field(s) ${tolerated.map((p) => p.field).join(", ")}; ` +
903
+ logger.warn(` output contract still violated for routing field(s) ${contractPaths(tolerated).join(", ")}; ` +
908
904
  `routes that read them fall through to the next rung`, { node: currentId });
909
905
  safeObserve(observer, {
910
906
  type: "node:warning",
911
907
  node: currentId,
912
908
  reason: "routing fields missing or invalid after repair; their routes fall through",
913
- fields: tolerated.map((p) => p.field),
909
+ fields: contractPaths(tolerated),
914
910
  }, logger);
915
911
  }
916
- const missingRequired = contract
917
- .filter((p) => p.kind === "missing" && !routedOn.has(p.field))
918
- .map((p) => p.field);
912
+ const broken = contract.filter((p) => !routedOn.has(p.field) && (p.kind === "missing" || !p.fallback));
913
+ const contractError = "error" in checked
914
+ ? `output validation error: ${checked.error}`
915
+ : broken.length > 0
916
+ ? contractFailureMessage(broken)
917
+ : undefined;
919
918
  let outcome;
920
- if (missingRequired.length > 0) {
921
- const msg = `output schema violation: required field(s) missing from emitted data: ${missingRequired.join(", ")}`;
922
- const failure = { name: "output_required", kind: "value", pass: false, reasoning: msg };
923
- outcome = { pass: false, error: msg, failures: [failure] };
919
+ if (contractError !== undefined) {
920
+ const name = broken.some((p) => p.kind === "missing") ? "output_required" : "output_schema";
921
+ const failure = { name, kind: "value", pass: false, reasoning: contractError };
922
+ outcome = { pass: false, error: contractError, failures: [failure] };
924
923
  // Still record the synthesized failure as the node's eval result so
925
924
  // observers and downstream context see the contract violation.
926
925
  result.evals = [failure];
@@ -1408,9 +1407,12 @@ export function deciderRoutingState(workflow, current, results, conditions, secr
1408
1407
  const fields = fieldsOf(id);
1409
1408
  if (fields.size > 0 && r.status === "success") {
1410
1409
  const data = (r.data ?? {});
1410
+ // A field that breaks the node's contract by any rule (#573) is sent as null, like a mistyped one.
1411
+ const broken = invalidOutputFields(r, hasOwn(workflow.nodes, id) ? workflow.nodes[id] : undefined);
1411
1412
  const output = Object.create(null);
1412
- for (const [k, spec] of fields)
1413
- output[k] = projectValue(spec, hasOwn(data, k) ? data[k] : undefined);
1413
+ for (const [k, spec] of fields) {
1414
+ output[k] = broken.has(k) ? null : projectValue(spec, hasOwn(data, k) ? data[k] : undefined);
1415
+ }
1414
1416
  entry.output = output;
1415
1417
  }
1416
1418
  if (r.evals && r.evals.length > 0) {
@@ -1518,114 +1520,19 @@ export function buildBoundedContext(workflow, nodeId, results, input, instructio
1518
1520
  .map(([k, v]) => [k, buildBoundedNodeEntry(v, workflow.nodes[k])])),
1519
1521
  };
1520
1522
  }
1523
+ // The output contract (#357, #573) lives in output-contract.ts; re-exported for existing importers.
1524
+ export { COUNT_OF_KEYWORD, outputContractProblems };
1521
1525
  /**
1522
- * Return the declared `required` field names for a node's output schema,
1523
- * or an empty array when no required block is declared. Tolerant of
1524
- * malformed schemas (mirrors getDeclaredOutputProperties).
1526
+ * The top-level output fields of a node's result that are present but break
1527
+ * its contract (#573), at any depth and by any rule. Routing reads them as
1528
+ * unknown and the decider never sees their values. A check that cannot run
1529
+ * (it threw) marks every declared field, so nothing unchecked routes.
1525
1530
  */
1526
- function getDeclaredRequiredFields(output) {
1527
- if (!output || typeof output !== "object")
1528
- return [];
1529
- const required = output.required;
1530
- if (!Array.isArray(required))
1531
- return [];
1532
- return required.filter((r) => typeof r === "string");
1533
- }
1534
- /** Keyword on an integer output property: the count sweny cross-checks against an array in the same output. */
1535
- export const COUNT_OF_KEYWORD = "x-sweny-count-of";
1536
- function typeMatches(t, v) {
1537
- switch (t) {
1538
- case "string":
1539
- return typeof v === "string";
1540
- case "number":
1541
- return typeof v === "number" && Number.isFinite(v);
1542
- case "integer":
1543
- return typeof v === "number" && Number.isInteger(v);
1544
- case "boolean":
1545
- return typeof v === "boolean";
1546
- case "array":
1547
- return Array.isArray(v);
1548
- case "object":
1549
- return typeof v === "object" && v !== null && !Array.isArray(v);
1550
- case "null":
1551
- return v === null;
1552
- default:
1553
- return true; // unknown or absent type: not checked
1554
- }
1555
- }
1556
- function countOf(spec, data) {
1557
- const items = hasOwn(data, spec.array) ? data[spec.array] : undefined;
1558
- if (!Array.isArray(items))
1559
- return undefined;
1560
- let n = 0;
1561
- for (const item of items) {
1562
- if (!item || typeof item !== "object")
1563
- continue;
1564
- const it = item;
1565
- const whereOk = Object.entries(spec.where ?? {}).every(([k, allowed]) => hasOwn(it, k) && Array.isArray(allowed) && allowed.some((a) => a === it[k]));
1566
- const nonEmptyOk = (spec.non_empty ?? []).every((k) => hasOwn(it, k) && typeof it[k] === "string" && it[k].trim() !== "");
1567
- if (whereOk && nonEmptyOk)
1568
- n++;
1569
- }
1570
- return n;
1571
- }
1572
- /**
1573
- * Check emitted data against the declared output contract (#357): every
1574
- * `required` field present; every present top-level property of its declared
1575
- * `type` and inside its `enum`; and every `x-sweny-count-of` integer equal to
1576
- * the count it declares over its array. Not full JSON Schema: formats,
1577
- * nested shapes and additionalProperties stay the author's concern.
1578
- */
1579
- export function outputContractProblems(data, output) {
1580
- if (!output || typeof output !== "object")
1581
- return [];
1582
- const obj = (data && typeof data === "object" ? data : {});
1583
- const problems = [];
1584
- for (const k of getDeclaredRequiredFields(output)) {
1585
- if (!hasOwn(obj, k))
1586
- problems.push({ field: k, kind: "missing", problem: "required but missing" });
1587
- }
1588
- const props = output.properties;
1589
- if (!props || typeof props !== "object")
1590
- return problems;
1591
- for (const [k, raw] of Object.entries(props)) {
1592
- if (!hasOwn(obj, k) || !raw || typeof raw !== "object")
1593
- continue;
1594
- const p = raw;
1595
- const v = obj[k];
1596
- const types = Array.isArray(p.type) ? p.type : p.type === undefined ? [] : [p.type];
1597
- if (types.length > 0 && !types.some((t) => typeMatches(t, v))) {
1598
- problems.push({ field: k, kind: "invalid", problem: `expected ${types.join(" or ")}, got ${JSON.stringify(v)}` });
1599
- continue;
1600
- }
1601
- if (Array.isArray(p.enum) && !p.enum.some((e) => e === v)) {
1602
- problems.push({
1603
- field: k,
1604
- kind: "invalid",
1605
- problem: `expected one of ${JSON.stringify(p.enum)}, got ${JSON.stringify(v)}`,
1606
- });
1607
- continue;
1608
- }
1609
- const spec = p[COUNT_OF_KEYWORD];
1610
- if (spec && typeof spec === "object" && typeof spec.array === "string") {
1611
- const want = countOf(spec, obj);
1612
- if (want === undefined) {
1613
- problems.push({
1614
- field: k,
1615
- kind: "invalid",
1616
- problem: `cannot be checked: '${spec.array}' is not a list`,
1617
- });
1618
- }
1619
- else if (v !== want) {
1620
- problems.push({
1621
- field: k,
1622
- kind: "invalid",
1623
- problem: `is ${JSON.stringify(v)} but '${spec.array}' has ${want} matching item(s)`,
1624
- });
1625
- }
1626
- }
1627
- }
1628
- return problems;
1531
+ function invalidOutputFields(result, node) {
1532
+ const checked = checkOutputContract(result.data, node?.output);
1533
+ if ("error" in checked)
1534
+ return getDeclaredOutputProperties(node?.output) ?? new Set();
1535
+ return new Set(checked.problems.filter((p) => p.kind === "invalid").map((p) => p.field));
1629
1536
  }
1630
1537
  /**
1631
1538
  * Required tools the agent never called, when that is the only reason a node
@@ -2300,9 +2207,8 @@ function resolveByExpressions(workflow, current, results, conditionalEdges, defa
2300
2207
  if (r.status !== "success")
2301
2208
  continue;
2302
2209
  const node = hasOwn(workflow.nodes, id) ? workflow.nodes[id] : undefined;
2303
- for (const p of outputContractProblems(r.data, node?.output))
2304
- if (p.kind === "invalid")
2305
- invalid.add(`${id}.${p.field}`);
2210
+ for (const f of invalidOutputFields(r, node))
2211
+ invalid.add(`${id}.${f}`);
2306
2212
  scope[id] = buildPriorNodeContext(r);
2307
2213
  }
2308
2214
  const problems = [];
@@ -2368,7 +2274,7 @@ function resolveByExpressions(workflow, current, results, conditionalEdges, defa
2368
2274
  /**
2369
2275
  * Validate a workflow definition before execution.
2370
2276
  */
2371
- function validate(workflow, skills) {
2277
+ function validate(workflow, skills, logger) {
2372
2278
  for (const [id, def] of Object.entries(workflow.skills ?? {})) {
2373
2279
  if (!def.instruction?.trim()) {
2374
2280
  throw new Error(`Inline skill "${id}" must provide a non-empty instruction`);
@@ -2392,10 +2298,14 @@ function validate(workflow, skills) {
2392
2298
  // reach execute() without the loader, so without this they got none of it
2393
2299
  // (#326). Runs once per execute() call, before any node runs. Skill
2394
2300
  // availability stays a warning below, so no knownSkills here.
2395
- const structural = validateWorkflow(workflow);
2301
+ const loadWarnings = [];
2302
+ const structural = validateWorkflow(workflow, undefined, { warnings: loadWarnings });
2396
2303
  if (structural.length > 0) {
2397
2304
  throw new Error(`Invalid workflow "${workflow.id}":\n${structural.map((e) => ` ${e.code}: ${e.message}`).join("\n")}`);
2398
2305
  }
2306
+ // Deprecated but still loading (#573): said once per run, never silently.
2307
+ for (const w of loadWarnings)
2308
+ logger.warn(` ${w.code}: ${w.message}`, { node: w.nodeId });
2399
2309
  // Check that each node has at least one available skill (if it lists any)
2400
2310
  for (const [nodeId, node] of Object.entries(workflow.nodes)) {
2401
2311
  if (node.skills.length === 0)
@@ -53,7 +53,8 @@ import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
53
53
  import { gitCredentialPolicy } from "../git-credentials.js";
54
54
  import { buildNodePrompt, reportCompletionUsage } from "./prompts.js";
55
55
  import { makeAbort } from "./abort.js";
56
- import { parseToolResultContent, schemaMismatches, summarizeToolError, tryParseJSON } from "./parse.js";
56
+ import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
57
+ import { checkOutputContract, listContractProblems } from "../output-contract.js";
57
58
  import { startToolBridge } from "./tool-bridge/server.js";
58
59
  import { defaultSandboxWrapper, prepareAgentSpawn, wrappersFrom, } from "./sandbox-wrapper.js";
59
60
  import { TOKEN_ENV } from "./tool-bridge/protocol.js";
@@ -775,18 +776,25 @@ export class AcpHarness {
775
776
  const basePrompt = buildNodePrompt(req.instruction, req.context, req.outputSchema);
776
777
  const schema = req.outputSchema;
777
778
  const retry = { asked: false };
778
- // One correction turn when the answer does not match the declared output.
779
+ // The same check, at every depth, that sweny's executor applies to every
780
+ // harness's output (output-contract.ts). A check that cannot run asks nothing.
781
+ const mismatches = (text) => {
782
+ const checked = checkOutputContract(tryParseJSON(text, schema, { warn() { } }), schema);
783
+ return "problems" in checked ? listContractProblems(checked.problems) : [];
784
+ };
785
+ // One correction turn when the answer does not match the declared output,
786
+ // unless the caller (sweny's executor) runs the one repair itself: one repair loop on every harness.
779
787
  const followUp = (turn) => {
780
- if (!schema || retry.asked)
788
+ if (!schema || retry.asked || req.callerRepairsOutput)
781
789
  return undefined;
782
- const parsed = tryParseJSON(turn.text, schema, { warn() { } });
783
- const problems = schemaMismatches(parsed, schema);
790
+ const problems = mismatches(turn.text);
784
791
  if (problems.length === 0)
785
792
  return undefined;
786
793
  retry.asked = true;
787
794
  this.logger.info(` acp: the answer does not match the output shape (${problems.join("; ")}); asking once more`);
788
795
  return [
789
- `Your last reply did not match the required output: ${problems.join("; ")}.`,
796
+ "Your last reply did not match the required output:",
797
+ ...problems.map((line) => `- ${line}`),
790
798
  "Reply again, ending with ONLY a JSON object that matches this schema:",
791
799
  "```json",
792
800
  JSON.stringify(schema, null, 2),
@@ -838,8 +846,7 @@ export class AcpHarness {
838
846
  const result = this.toResult(conv, schema, req.timeoutMs);
839
847
  if (result.status === "success" && schema && retry.asked) {
840
848
  const last = conv.turns.at(-1)?.text ?? "";
841
- const left = schemaMismatches(tryParseJSON(last, schema, { warn() { } }), schema);
842
- if (left.length > 0) {
849
+ if (mismatches(last).length > 0) {
843
850
  extraDegraded.push(`structured_output: the answer still did not match the declared output after one retry`);
844
851
  }
845
852
  }
package/dist/loader.js CHANGED
@@ -13,7 +13,7 @@ import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import { parse as parseYaml } from "yaml";
15
15
  import { ZodError } from "zod";
16
- import { workflowZ, validateWorkflow, preflightLegacyVerify } from "./schema.js";
16
+ import { workflowZ, validateWorkflow, preflightLegacyVerify, } from "./schema.js";
17
17
  import { approveDirectiveWarnings } from "./node-policy.js";
18
18
  import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow, migrationWarning, } from "./migrations.js";
19
19
  /**
@@ -90,13 +90,15 @@ export function validateParsed(raw, options = {}) {
90
90
  const msg = err instanceof Error ? err.message : String(err);
91
91
  return { ok: false, errors: [{ message: msg, code: "SCHEMA" }] };
92
92
  }
93
- const structural = validateWorkflow(parsed, options.knownSkills);
93
+ const schemaWarnings = [];
94
+ const structural = validateWorkflow(parsed, options.knownSkills, { warnings: schemaWarnings });
94
95
  if (structural.length > 0) {
95
96
  return {
96
97
  ok: false,
97
98
  errors: structural.map((e) => ({ message: e.message, code: e.code, nodeId: e.nodeId })),
98
99
  };
99
100
  }
101
+ warnings.push(...schemaWarnings.map((w) => `${w.code}: ${w.message}`));
100
102
  warnings.push(...approveDirectiveWarnings(parsed));
101
103
  return warnings.length > 0 ? { ok: true, workflow: parsed, warnings } : { ok: true, workflow: parsed };
102
104
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The JSON Schema draft-06 meta-schema, as ajv ships it in
3
+ * `ajv/dist/refs/json-schema-draft-06.json` (ajv 8 adds it with
4
+ * `addMetaSchema` to support draft-06). Kept here as a module because core
5
+ * cannot import JSON files; a spec asserts it deep-equals ajv's copy.
6
+ */
7
+ export declare const DRAFT_06_META_SCHEMA: Record<string, unknown>;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The JSON Schema draft-06 meta-schema, as ajv ships it in
3
+ * `ajv/dist/refs/json-schema-draft-06.json` (ajv 8 adds it with
4
+ * `addMetaSchema` to support draft-06). Kept here as a module because core
5
+ * cannot import JSON files; a spec asserts it deep-equals ajv's copy.
6
+ */
7
+ export const DRAFT_06_META_SCHEMA = {
8
+ $schema: "http://json-schema.org/draft-06/schema#",
9
+ $id: "http://json-schema.org/draft-06/schema#",
10
+ title: "Core schema meta-schema",
11
+ definitions: {
12
+ schemaArray: {
13
+ type: "array",
14
+ minItems: 1,
15
+ items: {
16
+ $ref: "#",
17
+ },
18
+ },
19
+ nonNegativeInteger: {
20
+ type: "integer",
21
+ minimum: 0,
22
+ },
23
+ nonNegativeIntegerDefault0: {
24
+ allOf: [
25
+ {
26
+ $ref: "#/definitions/nonNegativeInteger",
27
+ },
28
+ {
29
+ default: 0,
30
+ },
31
+ ],
32
+ },
33
+ simpleTypes: {
34
+ enum: ["array", "boolean", "integer", "null", "number", "object", "string"],
35
+ },
36
+ stringArray: {
37
+ type: "array",
38
+ items: {
39
+ type: "string",
40
+ },
41
+ uniqueItems: true,
42
+ default: [],
43
+ },
44
+ },
45
+ type: ["object", "boolean"],
46
+ properties: {
47
+ $id: {
48
+ type: "string",
49
+ format: "uri-reference",
50
+ },
51
+ $schema: {
52
+ type: "string",
53
+ format: "uri",
54
+ },
55
+ $ref: {
56
+ type: "string",
57
+ format: "uri-reference",
58
+ },
59
+ title: {
60
+ type: "string",
61
+ },
62
+ description: {
63
+ type: "string",
64
+ },
65
+ default: {},
66
+ examples: {
67
+ type: "array",
68
+ items: {},
69
+ },
70
+ multipleOf: {
71
+ type: "number",
72
+ exclusiveMinimum: 0,
73
+ },
74
+ maximum: {
75
+ type: "number",
76
+ },
77
+ exclusiveMaximum: {
78
+ type: "number",
79
+ },
80
+ minimum: {
81
+ type: "number",
82
+ },
83
+ exclusiveMinimum: {
84
+ type: "number",
85
+ },
86
+ maxLength: {
87
+ $ref: "#/definitions/nonNegativeInteger",
88
+ },
89
+ minLength: {
90
+ $ref: "#/definitions/nonNegativeIntegerDefault0",
91
+ },
92
+ pattern: {
93
+ type: "string",
94
+ format: "regex",
95
+ },
96
+ additionalItems: {
97
+ $ref: "#",
98
+ },
99
+ items: {
100
+ anyOf: [
101
+ {
102
+ $ref: "#",
103
+ },
104
+ {
105
+ $ref: "#/definitions/schemaArray",
106
+ },
107
+ ],
108
+ default: {},
109
+ },
110
+ maxItems: {
111
+ $ref: "#/definitions/nonNegativeInteger",
112
+ },
113
+ minItems: {
114
+ $ref: "#/definitions/nonNegativeIntegerDefault0",
115
+ },
116
+ uniqueItems: {
117
+ type: "boolean",
118
+ default: false,
119
+ },
120
+ contains: {
121
+ $ref: "#",
122
+ },
123
+ maxProperties: {
124
+ $ref: "#/definitions/nonNegativeInteger",
125
+ },
126
+ minProperties: {
127
+ $ref: "#/definitions/nonNegativeIntegerDefault0",
128
+ },
129
+ required: {
130
+ $ref: "#/definitions/stringArray",
131
+ },
132
+ additionalProperties: {
133
+ $ref: "#",
134
+ },
135
+ definitions: {
136
+ type: "object",
137
+ additionalProperties: {
138
+ $ref: "#",
139
+ },
140
+ default: {},
141
+ },
142
+ properties: {
143
+ type: "object",
144
+ additionalProperties: {
145
+ $ref: "#",
146
+ },
147
+ default: {},
148
+ },
149
+ patternProperties: {
150
+ type: "object",
151
+ additionalProperties: {
152
+ $ref: "#",
153
+ },
154
+ default: {},
155
+ },
156
+ dependencies: {
157
+ type: "object",
158
+ additionalProperties: {
159
+ anyOf: [
160
+ {
161
+ $ref: "#",
162
+ },
163
+ {
164
+ $ref: "#/definitions/stringArray",
165
+ },
166
+ ],
167
+ },
168
+ },
169
+ propertyNames: {
170
+ $ref: "#",
171
+ },
172
+ const: {},
173
+ enum: {
174
+ type: "array",
175
+ minItems: 1,
176
+ uniqueItems: true,
177
+ },
178
+ type: {
179
+ anyOf: [
180
+ {
181
+ $ref: "#/definitions/simpleTypes",
182
+ },
183
+ {
184
+ type: "array",
185
+ items: {
186
+ $ref: "#/definitions/simpleTypes",
187
+ },
188
+ minItems: 1,
189
+ uniqueItems: true,
190
+ },
191
+ ],
192
+ },
193
+ format: {
194
+ type: "string",
195
+ },
196
+ allOf: {
197
+ $ref: "#/definitions/schemaArray",
198
+ },
199
+ anyOf: {
200
+ $ref: "#/definitions/schemaArray",
201
+ },
202
+ oneOf: {
203
+ $ref: "#/definitions/schemaArray",
204
+ },
205
+ not: {
206
+ $ref: "#",
207
+ },
208
+ },
209
+ default: {},
210
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The node output contract (#357, #573): a node's emitted data checked
3
+ * against its declared `output` JSON Schema, at every depth.
4
+ *
5
+ * One standard validator (ajv) does the checking, so nested objects, arrays
6
+ * of objects, enums, `required` at any depth, `additionalProperties`,
7
+ * `oneOf` and the rest mean what JSON Schema says they mean. The check runs
8
+ * in the executor on the data every harness returns, so a violation reads
9
+ * the same whichever harness ran the node. Each problem carries the exact
10
+ * path of the value that broke the schema (`findings[1].severity`) and the
11
+ * rule it broke, never the value itself: output can carry secrets.
12
+ *
13
+ * Dialect: JSON Schema 2020-12, or the draft a schema names in `$schema`
14
+ * (draft-04, draft-06, draft-07, 2019-09). `format` is an annotation, as
15
+ * 2020-12 defines it by default, and is not asserted. Keywords ajv does not
16
+ * know (sweny's `x-` keywords among them) are ignored by ajv;
17
+ * `x-sweny-count-of` is checked here after the schema itself.
18
+ *
19
+ * A schema ajv cannot compile (deprecated, see {@link outputSchemaError})
20
+ * keeps the check sweny ran before #573: top-level `required`, and the
21
+ * `type` and `enum` of each top-level property.
22
+ *
23
+ * Output nested deeper than {@link MAX_OUTPUT_DEPTH} levels, or holding more
24
+ * than {@link MAX_OUTPUT_VALUES} values, is not checked: it is a problem in
25
+ * itself.
26
+ *
27
+ * No harness or skill imports.
28
+ */
29
+ import type { JSONSchema } from "./types.js";
30
+ /** One way a node's emitted data breaks its declared output contract. */
31
+ export interface ContractProblem {
32
+ /**
33
+ * The top-level output field the problem sits under, or {@link ROOT_PATH}
34
+ * for the output object itself. Routing tolerance keys on it: a route that
35
+ * reads this field falls through instead of failing the node.
36
+ */
37
+ field: string;
38
+ /** Where the broken value is, e.g. `findings[1].severity` ({@link ROOT_PATH} for the object itself). */
39
+ path: string;
40
+ /**
41
+ * `missing`: a top-level `required` field is absent. `invalid`: any other
42
+ * break, a `required` field missing at depth included.
43
+ */
44
+ kind: "missing" | "invalid";
45
+ /** The rule that broke and what it expects. Never the emitted value. */
46
+ problem: string;
47
+ /**
48
+ * Found by the pre-#573 top-level check, because the schema does not
49
+ * compile. As before #573, only a missing field fails the node.
50
+ */
51
+ fallback?: true;
52
+ }
53
+ /** The path that names the output object itself. */
54
+ export declare const ROOT_PATH = "(output)";
55
+ /** Keyword on an integer output property: the count sweny cross-checks against an array in the same output. */
56
+ export declare const COUNT_OF_KEYWORD = "x-sweny-count-of";
57
+ /** Deepest nesting checked: the output object is level 0. */
58
+ export declare const MAX_OUTPUT_DEPTH = 64;
59
+ /** Most values (objects, arrays and scalars together) checked in one output. */
60
+ export declare const MAX_OUTPUT_VALUES = 100000;
61
+ /**
62
+ * Why a declared output schema cannot be compiled, or undefined when it
63
+ * compiles (or there is none). Such a schema is deprecated: the node keeps
64
+ * the pre-#573 top-level check, and a future minor will refuse to load it.
65
+ */
66
+ export declare function outputSchemaError(output: JSONSchema | undefined): string | undefined;
67
+ /**
68
+ * Check emitted data against the declared output contract: the whole JSON
69
+ * Schema at every depth, then every `x-sweny-count-of` integer against the
70
+ * count it declares over its array. One problem per broken path, in schema
71
+ * order. Empty when the node declares no schema or the data conforms. A
72
+ * schema that does not compile gets the pre-#573 top-level check instead.
73
+ * Can throw (a hostile getter, an ajv fault): callers use
74
+ * {@link checkOutputContract}.
75
+ */
76
+ export declare function outputContractProblems(data: unknown, output: JSONSchema | undefined): ContractProblem[];
77
+ /** The contract check that never throws: its problems, or why it could not run. */
78
+ export declare function checkOutputContract(data: unknown, output: JSONSchema | undefined): {
79
+ problems: ContractProblem[];
80
+ } | {
81
+ error: string;
82
+ };
83
+ /** The problems' paths, at most {@link MAX_LISTED} of them, for log lines and events. */
84
+ export declare function contractPaths(problems: ContractProblem[]): string[];
85
+ /** The problems as `path: problem` lines, at most {@link MAX_LISTED} of them. */
86
+ export declare function listContractProblems(problems: ContractProblem[]): string[];
87
+ /**
88
+ * The node error for a contract still broken after the repair request.
89
+ * Missing top-level fields are named together, the way the required-field
90
+ * gate always has; every other problem follows as `path: problem`.
91
+ */
92
+ export declare function contractFailureMessage(problems: ContractProblem[]): string;
@@ -0,0 +1,374 @@
1
+ /**
2
+ * The node output contract (#357, #573): a node's emitted data checked
3
+ * against its declared `output` JSON Schema, at every depth.
4
+ *
5
+ * One standard validator (ajv) does the checking, so nested objects, arrays
6
+ * of objects, enums, `required` at any depth, `additionalProperties`,
7
+ * `oneOf` and the rest mean what JSON Schema says they mean. The check runs
8
+ * in the executor on the data every harness returns, so a violation reads
9
+ * the same whichever harness ran the node. Each problem carries the exact
10
+ * path of the value that broke the schema (`findings[1].severity`) and the
11
+ * rule it broke, never the value itself: output can carry secrets.
12
+ *
13
+ * Dialect: JSON Schema 2020-12, or the draft a schema names in `$schema`
14
+ * (draft-04, draft-06, draft-07, 2019-09). `format` is an annotation, as
15
+ * 2020-12 defines it by default, and is not asserted. Keywords ajv does not
16
+ * know (sweny's `x-` keywords among them) are ignored by ajv;
17
+ * `x-sweny-count-of` is checked here after the schema itself.
18
+ *
19
+ * A schema ajv cannot compile (deprecated, see {@link outputSchemaError})
20
+ * keeps the check sweny ran before #573: top-level `required`, and the
21
+ * `type` and `enum` of each top-level property.
22
+ *
23
+ * Output nested deeper than {@link MAX_OUTPUT_DEPTH} levels, or holding more
24
+ * than {@link MAX_OUTPUT_VALUES} values, is not checked: it is a problem in
25
+ * itself.
26
+ *
27
+ * No harness or skill imports.
28
+ */
29
+ import { Ajv } from "ajv";
30
+ import { Ajv2019 } from "ajv/dist/2019.js";
31
+ import { Ajv2020 } from "ajv/dist/2020.js";
32
+ import Ajv04Module from "ajv-draft-04";
33
+ import { DRAFT_06_META_SCHEMA } from "./output-contract-draft06.js";
34
+ /** The path that names the output object itself. */
35
+ export const ROOT_PATH = "(output)";
36
+ /** Keyword on an integer output property: the count sweny cross-checks against an array in the same output. */
37
+ export const COUNT_OF_KEYWORD = "x-sweny-count-of";
38
+ /** Deepest nesting checked: the output object is level 0. */
39
+ export const MAX_OUTPUT_DEPTH = 64;
40
+ /** Most values (objects, arrays and scalars together) checked in one output. */
41
+ export const MAX_OUTPUT_VALUES = 100_000;
42
+ /**
43
+ * Prose every harness puts in `data.summary` next to the parsed output. It is
44
+ * not the agent's structured answer, so it is checked only when the schema
45
+ * names it (a declared or required `summary`).
46
+ */
47
+ const HARNESS_SUMMARY_KEY = "summary";
48
+ /** Problems listed in one message, log line or event before the rest are counted. */
49
+ const MAX_LISTED = 20;
50
+ const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
51
+ // ─── Compilation ─────────────────────────────────────────────────
52
+ /**
53
+ * `strict: false`: unknown keywords (descriptions, `x-` keywords) are not
54
+ * errors. `validateFormats: false`: `format` is an annotation (2020-12
55
+ * default), so no format plugin is needed. `allErrors`: report every
56
+ * problem at once, so one repair request names them all.
57
+ */
58
+ const AJV_OPTIONS = { allErrors: true, strict: false, validateFormats: false, logger: false };
59
+ // ajv-draft-04 is CommonJS with only a default export; `.default` is the class under Node and bundlers alike.
60
+ const Ajv04 = Ajv04Module.default;
61
+ const DRAFT_04 = new Ajv04(AJV_OPTIONS);
62
+ const DRAFT_07 = new Ajv(AJV_OPTIONS).addMetaSchema(DRAFT_06_META_SCHEMA);
63
+ const DRAFT_2019 = new Ajv2019(AJV_OPTIONS);
64
+ const DRAFT_2020 = new Ajv2020(AJV_OPTIONS);
65
+ /** The validator for the dialect the schema names in `$schema`; 2020-12 when it names none. */
66
+ function validatorFor(schema) {
67
+ const dialect = String(schema.$schema);
68
+ if (/draft-04/.test(dialect))
69
+ return DRAFT_04;
70
+ if (/draft-0[67]/.test(dialect))
71
+ return DRAFT_07;
72
+ return /2019-09/.test(dialect) ? DRAFT_2019 : DRAFT_2020;
73
+ }
74
+ const INVALID_SCHEMA = "schema is invalid: ";
75
+ /** ajv repeats a meta-schema complaint once per branch it tried: say each once. */
76
+ function oneEach(message) {
77
+ const head = message.startsWith(INVALID_SCHEMA) ? INVALID_SCHEMA : "";
78
+ return head + [...new Set(message.slice(head.length).split(", "))].join(", ");
79
+ }
80
+ const compiled = new WeakMap();
81
+ /** Compile once per schema object. The schema is released from ajv afterwards, so two schemas may share an `$id`. */
82
+ function compile(schema) {
83
+ const hit = compiled.get(schema);
84
+ if (hit)
85
+ return hit;
86
+ const v = validatorFor(schema);
87
+ let out;
88
+ try {
89
+ out = { validate: v.compile(schema) };
90
+ }
91
+ catch (err) {
92
+ out = { error: oneEach(err.message) };
93
+ }
94
+ finally {
95
+ v.removeSchema(schema);
96
+ }
97
+ compiled.set(schema, out);
98
+ return out;
99
+ }
100
+ /**
101
+ * Why a declared output schema cannot be compiled, or undefined when it
102
+ * compiles (or there is none). Such a schema is deprecated: the node keeps
103
+ * the pre-#573 top-level check, and a future minor will refuse to load it.
104
+ */
105
+ export function outputSchemaError(output) {
106
+ if (!output || typeof output !== "object")
107
+ return undefined;
108
+ const c = compile(output);
109
+ return "error" in c ? c.error : undefined;
110
+ }
111
+ // ─── Paths and messages ──────────────────────────────────────────
112
+ function pointerSegments(pointer) {
113
+ if (pointer === "")
114
+ return [];
115
+ return pointer
116
+ .slice(1)
117
+ .split("/")
118
+ .map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
119
+ }
120
+ /** One step down the data. Every segment but a final missing or extra key exists, since ajv found it there. */
121
+ const step = (cur, seg) => cur[seg];
122
+ /**
123
+ * `["findings", "1", "severity"]` over the data -> `findings[1].severity`.
124
+ * `[n]` only where the value is an array; a key that is not an identifier is
125
+ * quoted (`["odd key"]`, `byIndex["0"]`).
126
+ */
127
+ function renderPath(segments, data) {
128
+ if (segments.length === 0)
129
+ return ROOT_PATH;
130
+ let out = "";
131
+ let cur = data;
132
+ for (const seg of segments) {
133
+ if (Array.isArray(cur))
134
+ out += `[${seg}]`;
135
+ else if (/^[A-Za-z_$][\w$-]*$/.test(seg))
136
+ out += out === "" ? seg : `.${seg}`;
137
+ else
138
+ out += `[${JSON.stringify(seg)}]`;
139
+ cur = step(cur, seg);
140
+ }
141
+ return out;
142
+ }
143
+ /** A schema value (an enum list, a const) as JSON, cut to 80 characters. Never used on output data. */
144
+ function preview(v) {
145
+ const s = String(JSON.stringify(v));
146
+ return s.length > 80 ? `${s.slice(0, 77)}...` : s;
147
+ }
148
+ function toProblem(e, data) {
149
+ const segs = pointerSegments(e.instancePath);
150
+ const p = e.params;
151
+ let problem;
152
+ switch (e.keyword) {
153
+ case "required":
154
+ segs.push(String(p.missingProperty));
155
+ problem = "required but missing";
156
+ break;
157
+ case "dependentRequired":
158
+ case "dependencies":
159
+ segs.push(String(p.missingProperty));
160
+ problem = `required when '${String(p.property)}' is present, but missing`;
161
+ break;
162
+ case "additionalProperties":
163
+ segs.push(String(p.additionalProperty));
164
+ problem = "not allowed (additionalProperties: false)";
165
+ break;
166
+ case "unevaluatedProperties":
167
+ segs.push(String(p.unevaluatedProperty));
168
+ problem = "not allowed (unevaluatedProperties: false)";
169
+ break;
170
+ case "type":
171
+ problem = `expected ${[p.type].flat().join(" or ")}`;
172
+ break;
173
+ case "enum":
174
+ problem = `expected one of ${preview(p.allowedValues)}`;
175
+ break;
176
+ case "const":
177
+ problem = `expected ${preview(p.allowedValue)}`;
178
+ break;
179
+ default:
180
+ // ajv's wording names the rule and its limit, never the value.
181
+ problem = `${e.message}`;
182
+ }
183
+ const missingTopLevel = e.keyword === "required" && segs.length === 1;
184
+ return {
185
+ field: segs.length === 0 ? ROOT_PATH : segs[0],
186
+ path: renderPath(segs, data),
187
+ kind: missingTopLevel ? "missing" : "invalid",
188
+ problem,
189
+ };
190
+ }
191
+ // ─── The pre-#573 top-level check (schemas that do not compile) ──
192
+ function typeMatches(t, v) {
193
+ switch (t) {
194
+ case "string":
195
+ return typeof v === "string";
196
+ case "number":
197
+ return Number.isFinite(v);
198
+ case "integer":
199
+ return Number.isInteger(v);
200
+ case "boolean":
201
+ return typeof v === "boolean";
202
+ case "array":
203
+ return Array.isArray(v);
204
+ case "object":
205
+ return typeof v === "object" && v !== null && !Array.isArray(v);
206
+ case "null":
207
+ return v === null;
208
+ default:
209
+ return true; // unknown or absent type: not checked, as before
210
+ }
211
+ }
212
+ /** Every `required` field present; every present top-level property of its declared `type` and in its `enum`. */
213
+ function topLevelProblems(output, data) {
214
+ const problems = [];
215
+ const required = Array.isArray(output.required) ? output.required : [];
216
+ for (const k of required) {
217
+ if (typeof k === "string" && !hasOwn(data, k)) {
218
+ problems.push({ field: k, path: k, kind: "missing", problem: "required but missing", fallback: true });
219
+ }
220
+ }
221
+ // This schema did not compile, so any part of it may be malformed: read it defensively.
222
+ for (const [k, raw] of Object.entries(output.properties ?? {})) {
223
+ if (!hasOwn(data, k))
224
+ continue;
225
+ const p = (raw ?? {});
226
+ // An absent or unknown type matches anything, as before #573.
227
+ const types = [p.type].flat();
228
+ let problem;
229
+ if (!types.some((t) => typeMatches(t, data[k])))
230
+ problem = `expected ${types.join(" or ")}`;
231
+ else if (Array.isArray(p.enum) && !p.enum.some((e) => e === data[k]))
232
+ problem = `expected one of ${preview(p.enum)}`;
233
+ if (problem)
234
+ problems.push({ field: k, path: renderPath([k], data), kind: "invalid", problem, fallback: true });
235
+ }
236
+ return problems;
237
+ }
238
+ function countOf(spec, data) {
239
+ const items = data[spec.array];
240
+ if (!Array.isArray(items))
241
+ return undefined;
242
+ let n = 0;
243
+ for (const item of items) {
244
+ if (!item || typeof item !== "object")
245
+ continue;
246
+ const it = item;
247
+ const whereOk = Object.entries(spec.where ?? {}).every(([k, allowed]) => Array.isArray(allowed) && allowed.some((a) => a === it[k]));
248
+ const nonEmptyOk = (spec.non_empty ?? []).every((k) => typeof it[k] === "string" && it[k].trim() !== "");
249
+ if (whereOk && nonEmptyOk)
250
+ n++;
251
+ }
252
+ return n;
253
+ }
254
+ function countProblems(output, data, alreadyBroken, fallback) {
255
+ const problems = [];
256
+ for (const [k, raw] of Object.entries(output.properties ?? {})) {
257
+ if (!hasOwn(data, k) || alreadyBroken.has(k))
258
+ continue;
259
+ const spec = raw?.[COUNT_OF_KEYWORD];
260
+ if (typeof spec?.array !== "string")
261
+ continue;
262
+ const want = countOf(spec, data);
263
+ const problem = want === undefined
264
+ ? `cannot be checked: '${spec.array}' is not a list`
265
+ : data[k] !== want
266
+ ? `does not match the count: '${spec.array}' has ${want} matching item(s)`
267
+ : undefined;
268
+ if (problem) {
269
+ problems.push({
270
+ field: k,
271
+ path: renderPath([k], data),
272
+ kind: "invalid",
273
+ problem,
274
+ ...(fallback ? { fallback: true } : {}),
275
+ });
276
+ }
277
+ }
278
+ return problems;
279
+ }
280
+ // ─── The check ───────────────────────────────────────────────────
281
+ /** The data the schema is checked against: the harness's `summary` prose left out unless the schema names it. */
282
+ function structuredView(output, data) {
283
+ const declared = hasOwn(Object(output.properties), HARNESS_SUMMARY_KEY) ||
284
+ (Array.isArray(output.required) && output.required.includes(HARNESS_SUMMARY_KEY));
285
+ if (declared)
286
+ return data;
287
+ const { [HARNESS_SUMMARY_KEY]: _summary, ...rest } = data;
288
+ return rest;
289
+ }
290
+ /** Why the output is too big to check, without recursion, or undefined. */
291
+ function tooBig(data) {
292
+ const stack = [[data, 0]];
293
+ let values = 0;
294
+ while (stack.length > 0) {
295
+ const [v, depth] = stack.pop();
296
+ if (depth > MAX_OUTPUT_DEPTH)
297
+ return `not checked: the output nests deeper than ${MAX_OUTPUT_DEPTH} levels`;
298
+ if (++values > MAX_OUTPUT_VALUES)
299
+ return `not checked: the output holds more than ${MAX_OUTPUT_VALUES} values`;
300
+ if (v !== null && typeof v === "object")
301
+ for (const c of Object.values(v))
302
+ stack.push([c, depth + 1]);
303
+ }
304
+ return undefined;
305
+ }
306
+ /**
307
+ * Check emitted data against the declared output contract: the whole JSON
308
+ * Schema at every depth, then every `x-sweny-count-of` integer against the
309
+ * count it declares over its array. One problem per broken path, in schema
310
+ * order. Empty when the node declares no schema or the data conforms. A
311
+ * schema that does not compile gets the pre-#573 top-level check instead.
312
+ * Can throw (a hostile getter, an ajv fault): callers use
313
+ * {@link checkOutputContract}.
314
+ */
315
+ export function outputContractProblems(data, output) {
316
+ if (!output || typeof output !== "object")
317
+ return [];
318
+ const obj = structuredView(output, (data && typeof data === "object" ? data : {}));
319
+ const big = tooBig(obj);
320
+ if (big)
321
+ return [{ field: ROOT_PATH, path: ROOT_PATH, kind: "invalid", problem: big }];
322
+ const c = compile(output);
323
+ if ("error" in c) {
324
+ const problems = topLevelProblems(output, obj);
325
+ return [...problems, ...countProblems(output, obj, new Set(problems.map((p) => p.field)), true)];
326
+ }
327
+ const problems = [];
328
+ const seen = new Set();
329
+ c.validate(obj);
330
+ for (const e of c.validate.errors ?? []) {
331
+ const p = toProblem(e, obj);
332
+ if (seen.has(p.path))
333
+ continue;
334
+ seen.add(p.path);
335
+ problems.push(p);
336
+ }
337
+ return [...problems, ...countProblems(output, obj, new Set(problems.map((p) => p.field)), false)];
338
+ }
339
+ /** The contract check that never throws: its problems, or why it could not run. */
340
+ export function checkOutputContract(data, output) {
341
+ try {
342
+ return { problems: outputContractProblems(data, output) };
343
+ }
344
+ catch (err) {
345
+ return { error: err instanceof Error ? err.message : String(err) };
346
+ }
347
+ }
348
+ function capped(lines) {
349
+ if (lines.length <= MAX_LISTED)
350
+ return lines;
351
+ return [...lines.slice(0, MAX_LISTED), `and ${lines.length - MAX_LISTED} more`];
352
+ }
353
+ /** The problems' paths, at most {@link MAX_LISTED} of them, for log lines and events. */
354
+ export function contractPaths(problems) {
355
+ return capped(problems.map((p) => p.path));
356
+ }
357
+ /** The problems as `path: problem` lines, at most {@link MAX_LISTED} of them. */
358
+ export function listContractProblems(problems) {
359
+ return capped(problems.map((p) => `${p.path}: ${p.problem}`));
360
+ }
361
+ /**
362
+ * The node error for a contract still broken after the repair request.
363
+ * Missing top-level fields are named together, the way the required-field
364
+ * gate always has; every other problem follows as `path: problem`.
365
+ */
366
+ export function contractFailureMessage(problems) {
367
+ const missing = problems.filter((p) => p.kind === "missing").map((p) => p.path);
368
+ const rest = problems.filter((p) => p.kind !== "missing");
369
+ const parts = [
370
+ ...(missing.length > 0 ? [`required field(s) missing from emitted data: ${capped(missing).join(", ")}`] : []),
371
+ ...listContractProblems(rest),
372
+ ];
373
+ return `output schema violation: ${parts.join("; ")}`;
374
+ }
package/dist/schema.d.ts CHANGED
@@ -1047,6 +1047,21 @@ export interface WorkflowError {
1047
1047
  message: string;
1048
1048
  nodeId?: string;
1049
1049
  }
1050
+ /**
1051
+ * A finding that does not stop the workflow loading (#573): something that
1052
+ * works today and is deprecated. `OUTPUT_SCHEMA_FALLBACK`: a node's `output`
1053
+ * schema does not compile, so its output keeps the pre-#573 top-level check;
1054
+ * a future minor will refuse to load it.
1055
+ */
1056
+ export interface WorkflowWarning {
1057
+ code: "OUTPUT_SCHEMA_FALLBACK";
1058
+ message: string;
1059
+ nodeId: string;
1060
+ }
1061
+ export interface ValidateWorkflowOptions {
1062
+ /** Receives warning-level findings. They never appear in the returned errors. */
1063
+ warnings?: WorkflowWarning[];
1064
+ }
1050
1065
  /**
1051
1066
  * Validate a workflow's graph structure.
1052
1067
  *
@@ -1061,7 +1076,7 @@ export interface WorkflowError {
1061
1076
  * Note: nodes with no outgoing edges are valid terminal nodes — the executor
1062
1077
  * returns null when it reaches one, ending the workflow cleanly.
1063
1078
  */
1064
- export declare function validateWorkflow(workflow: Workflow | z.infer<typeof workflowZ>, knownSkills?: Set<string>): WorkflowError[];
1079
+ export declare function validateWorkflow(workflow: Workflow | z.infer<typeof workflowZ>, knownSkills?: Set<string>, options?: ValidateWorkflowOptions): WorkflowError[];
1065
1080
  export declare const workflowJsonSchema: {
1066
1081
  readonly $schema: "https://json-schema.org/draft/2020-12/schema";
1067
1082
  readonly $id: "https://spec.sweny.ai/schemas/workflow.json";
package/dist/schema.js CHANGED
@@ -12,6 +12,7 @@ import { sourceZ } from "./sources.js";
12
12
  import { resolveNodePermissions } from "./node-policy.js";
13
13
  import { workflowInputsZ, WORKFLOW_INPUT_TYPES } from "./inputs.js";
14
14
  import { checkExpression, isWhenExpression, WHEN_EXPR_MAX_LENGTH } from "./when.js";
15
+ import { outputSchemaError } from "./output-contract.js";
15
16
  // Studio and other schema-only consumers label edges without importing the root entry.
16
17
  export { isWhenExpression, whenLabel } from "./when.js";
17
18
  export { sourceZ };
@@ -455,7 +456,7 @@ export function parseWorkflow(raw) {
455
456
  * Note: nodes with no outgoing edges are valid terminal nodes — the executor
456
457
  * returns null when it reaches one, ending the workflow cleanly.
457
458
  */
458
- export function validateWorkflow(workflow, knownSkills) {
459
+ export function validateWorkflow(workflow, knownSkills, options = {}) {
459
460
  const errors = [];
460
461
  const nodeIds = new Set(Object.keys(workflow.nodes));
461
462
  const entryExists = nodeIds.has(workflow.entry);
@@ -693,6 +694,21 @@ export function validateWorkflow(workflow, knownSkills) {
693
694
  });
694
695
  }
695
696
  }
697
+ // Output schemas compile with the validator the executor checks outputs
698
+ // with (#573). One that does not compile still loads, as it did before: a
699
+ // warning, and the node keeps the pre-#573 top-level check. Deprecated.
700
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
701
+ const why = outputSchemaError(node.output);
702
+ if (why !== undefined) {
703
+ options.warnings?.push({
704
+ code: "OUTPUT_SCHEMA_FALLBACK",
705
+ message: `Node "${nodeId}" declares an output schema the validator cannot compile (${why}). ` +
706
+ `Its output gets only the old top-level check (required fields, top-level types and enums). ` +
707
+ `Deprecated: a future minor will refuse to load this workflow; fix the schema or name its draft in $schema.`,
708
+ nodeId,
709
+ });
710
+ }
711
+ }
696
712
  // Reject eval policies that are reserved in the vocabulary but not yet
697
713
  // implemented. The Zod schema accepts the whole EVAL_POLICIES enum (the
698
714
  // published JSON schema advertises them as reserved), but only `all_pass`
package/dist/types.d.ts CHANGED
@@ -1003,6 +1003,12 @@ export interface Claude {
1003
1003
  context: Record<string, unknown>;
1004
1004
  tools: Tool[];
1005
1005
  outputSchema?: JSONSchema;
1006
+ /**
1007
+ * The caller checks the output against `outputSchema` and asks for one
1008
+ * repair itself (sweny's executor, #573), so the harness must not run a
1009
+ * correction turn of its own: one repair loop on every harness.
1010
+ */
1011
+ callerRepairsOutput?: boolean;
1006
1012
  /** Called with status messages while Claude is working (tool name, etc.) */
1007
1013
  onProgress?: (message: string) => void;
1008
1014
  /** MCP servers declared by this node's resolved skills. Explicit client configs win. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.213",
3
+ "version": "0.1.215",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"
@@ -66,6 +66,8 @@
66
66
  "@anthropic-ai/claude-agent-sdk": "^0.3.314",
67
67
  "@clack/prompts": "^1.8.1",
68
68
  "@modelcontextprotocol/sdk": "^1.32.2",
69
+ "ajv": "^8.20.0",
70
+ "ajv-draft-04": "^1.0.0",
69
71
  "chalk": "^5",
70
72
  "commander": "^13",
71
73
  "yaml": "^2.9.1",
@@ -75,7 +77,6 @@
75
77
  "@anthropic-ai/claude-code": "^2.1.314",
76
78
  "@stryker-mutator/core": "^10.1.1",
77
79
  "@stryker-mutator/vitest-runner": "^10.1.1",
78
- "ajv": "^8.20.0",
79
80
  "esbuild": "^0.28.2",
80
81
  "fast-check": "^3.23.2",
81
82
  "prettier": "^3.9.10",