@sweny-ai/core 0.1.101 → 0.1.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/claude.js CHANGED
@@ -372,6 +372,7 @@ export function buildEvaluatePrompt(question, context, choices) {
372
372
  `1. Read each choice's condition literally and match against the structured fields in the context (e.g. status, counts, enum values, boolean flags).`,
373
373
  `2. Ignore prose narrative fields ("summary", free-form rationale, conversational commentary). They are not the contract.`,
374
374
  `3. When a field's value contradicts what a prose field claims, trust the field's value.`,
375
+ `4. A field whose value is explicitly null means the source node DECLARED that field but did NOT emit a value. Treat null as "unknown" and do NOT match it against any specific value (do not match "is 0", "is N", "is true", "is false", or "is undefined" against a null field). Prefer a default/fallback edge when the field needed for a decision is null.`,
375
376
  `\nRespond with ONLY the choice ID, nothing else.`,
376
377
  ].join("\n");
377
378
  }
package/dist/executor.js CHANGED
@@ -174,6 +174,27 @@ export async function execute(workflow, input, options) {
174
174
  // Retry only triggers on eval failure. Bail on tool/API errors.
175
175
  if (result.status !== "success")
176
176
  break;
177
+ // Required-field gate: if the source node's declared output schema
178
+ // marks fields as `required`, those fields are the routing contract.
179
+ // An agent that omits a required field has violated the contract; we
180
+ // fail the node loudly rather than letting routing guess against a
181
+ // half-built data view. See the offload release-notes field bug:
182
+ // an optional quality_retry_count was declared in the schema but
183
+ // omitted by the agent, the route view silently dropped the key,
184
+ // and an "is 0 OR is undefined" condition ghost-matched.
185
+ //
186
+ // We only check declared `required` fields, not full JSON Schema
187
+ // validation. Type mismatches, formats, additionalProperties, etc.
188
+ // stay the workflow author's problem and don't have the same
189
+ // routing-correctness blast radius.
190
+ const missingRequired = findMissingRequiredFields(result.data, node.output);
191
+ if (missingRequired.length > 0) {
192
+ const msg = `output schema violation: required field(s) missing from emitted data: ${missingRequired.join(", ")}`;
193
+ result.status = "failed";
194
+ result.data = { ...(result.data ?? {}), error: msg };
195
+ logger.warn(` ${msg}`, { node: currentId });
196
+ break;
197
+ }
177
198
  const evalResults = await evaluateAll(node.eval, result, {
178
199
  aliases: toolAliases,
179
200
  claude,
@@ -344,16 +365,44 @@ function buildPriorNodeContext(result) {
344
365
  function buildRouteEvalEntry(result, sourceNode) {
345
366
  const fullData = (result.data ?? {});
346
367
  const declaredProps = getDeclaredOutputProperties(sourceNode?.output);
347
- const dataView = declaredProps ? pickKeys(fullData, declaredProps) : fullData;
368
+ // When the source node declared an `output.properties` block, the routing
369
+ // view is contract-shaped: every declared property is present in the
370
+ // view, with an explicit `null` when the agent did not emit it. The
371
+ // alternative (silently dropping the key) lets the LLM evaluator
372
+ // ghost-match conditions like "is 0", "is N", or "is undefined" against
373
+ // a structurally-absent field. That was the offload release-notes field
374
+ // bug: an optional `quality_retry_count` declared in the schema but
375
+ // omitted by the agent, evaluated against a condition like
376
+ // `quality_retry_count is 0 OR is undefined`, took the wrong branch.
377
+ //
378
+ // Returning `missing` separately lets the caller emit a loud warning so
379
+ // operators see the contract violation in the log stream.
380
+ let dataView;
381
+ const missing = [];
382
+ if (declaredProps) {
383
+ dataView = {};
384
+ for (const k of declaredProps) {
385
+ if (k in fullData) {
386
+ dataView[k] = fullData[k];
387
+ }
388
+ else {
389
+ dataView[k] = null;
390
+ missing.push(k);
391
+ }
392
+ }
393
+ }
394
+ else {
395
+ dataView = fullData;
396
+ }
348
397
  const evals = result.evals ?? [];
349
398
  if (evals.length === 0)
350
- return dataView;
399
+ return { view: dataView, missing };
351
400
  const evalsByName = {};
352
401
  for (const e of evals) {
353
402
  evalsByName[e.name] = e;
354
403
  }
355
404
  // Spread evals first so a data-side `evals` key shadows it (back-compat).
356
- return { evals: evalsByName, ...dataView };
405
+ return { view: { evals: evalsByName, ...dataView }, missing };
357
406
  }
358
407
  /**
359
408
  * Return the declared output property names for a node, or null when the
@@ -375,13 +424,34 @@ function getDeclaredOutputProperties(output) {
375
424
  return null;
376
425
  return new Set(keys);
377
426
  }
378
- function pickKeys(obj, keys) {
379
- const out = {};
380
- for (const k of keys) {
381
- if (k in obj)
382
- out[k] = obj[k];
383
- }
384
- return out;
427
+ /**
428
+ * Return the declared `required` field names for a node's output schema,
429
+ * or an empty array when no required block is declared. Tolerant of
430
+ * malformed schemas (mirrors getDeclaredOutputProperties).
431
+ */
432
+ function getDeclaredRequiredFields(output) {
433
+ if (!output || typeof output !== "object")
434
+ return [];
435
+ const required = output.required;
436
+ if (!Array.isArray(required))
437
+ return [];
438
+ return required.filter((r) => typeof r === "string");
439
+ }
440
+ /**
441
+ * Validate a node's emitted data against the declared output schema's
442
+ * required fields. Returns the list of required-but-missing field names.
443
+ *
444
+ * This is intentionally narrow: we do NOT do full JSON Schema validation.
445
+ * The routing impact comes from required fields being absent, so that's
446
+ * what we check. Type mismatches, format violations, additionalProperties
447
+ * etc. stay the workflow author's problem.
448
+ */
449
+ function findMissingRequiredFields(data, output) {
450
+ const required = getDeclaredRequiredFields(output);
451
+ if (required.length === 0)
452
+ return [];
453
+ const obj = (data ?? {});
454
+ return required.filter((k) => !(k in obj));
385
455
  }
386
456
  function nodeSourcesToArray(ns) {
387
457
  if (!ns)
@@ -587,10 +657,28 @@ async function resolveNext(workflow, current, results, input, claude, observer,
587
657
  // always-injected `summary` prose from the reference Claude client) can
588
658
  // sway the natural-language route evaluator and override the actual
589
659
  // structured result. See `buildRouteEvalEntry` for the full rationale.
590
- const context = {
591
- input,
592
- ...Object.fromEntries([...results.entries()].map(([k, v]) => [k, buildRouteEvalEntry(v, workflow.nodes[k])])),
593
- };
660
+ const context = { input };
661
+ for (const [k, v] of results.entries()) {
662
+ const { view, missing } = buildRouteEvalEntry(v, workflow.nodes[k]);
663
+ context[k] = view;
664
+ if (missing.length > 0) {
665
+ // Loud signal for the operator. The view itself already replaces the
666
+ // missing keys with explicit `null`, so the LLM evaluator cannot
667
+ // ghost-match "is N" or "is undefined" against a structurally-absent
668
+ // field. The warning surfaces the contract violation in the log
669
+ // stream and to observer-based telemetry.
670
+ logger?.warn(` route eval: source node '${k}' declared properties not in emitted data: ${missing.join(", ")}`, {
671
+ node: k,
672
+ fields: missing,
673
+ });
674
+ safeObserve(observer, {
675
+ type: "node:warning",
676
+ node: k,
677
+ reason: `declared output properties missing from emitted data; routing view filled them with null`,
678
+ fields: missing,
679
+ }, logger);
680
+ }
681
+ }
594
682
  const choices = conditionalEdges.map((e) => ({
595
683
  id: e.to,
596
684
  description: e.when,
package/dist/types.d.ts CHANGED
@@ -409,6 +409,23 @@ export type ExecutionEvent = {
409
409
  attempt: number;
410
410
  reason: string;
411
411
  preamble: string;
412
+ }
413
+ /**
414
+ * Non-fatal contract violation surfaced during node execution. Currently
415
+ * emitted when a source node declares output properties that the agent
416
+ * did not emit. The route eval view fills the missing keys with `null`
417
+ * so the LLM evaluator can't ghost-match conditions like "is undefined"
418
+ * or "is 0", but the operator still wants a loud signal in the log
419
+ * stream.
420
+ *
421
+ * `fields` lists the declared property names that were missing from the
422
+ * emitted data. `reason` is a short human-readable summary.
423
+ */
424
+ | {
425
+ type: "node:warning";
426
+ node: string;
427
+ reason: string;
428
+ fields: string[];
412
429
  } | {
413
430
  type: "route";
414
431
  from: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.101",
3
+ "version": "0.1.102",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"