@sweny-ai/core 0.1.99 → 0.1.100

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.d.ts CHANGED
@@ -74,6 +74,26 @@ export declare class ClaudeClient implements Claude {
74
74
  * `"42"`) from a tool that returned the string "42" (wrapper also sends
75
75
  * `"42"`). Preserving the string is safer than guessing.
76
76
  */
77
+ /**
78
+ * Build the prompt used by `ClaudeClient.evaluate()` to pick a routing edge.
79
+ *
80
+ * Extracted as a pure function so the prompt body can be unit tested without
81
+ * spinning up an SDK query. The body is part of the routing contract: workflow
82
+ * authors and downstream maintainers rely on the model leaning on structured
83
+ * fields rather than prose narrative when picking an edge, especially in the
84
+ * fallback case where a source node did not declare an `output` schema (the
85
+ * executor's schema-strict filter only kicks in when a schema is present;
86
+ * see `buildRouteEvalEntry` in executor.ts).
87
+ *
88
+ * Any change to this prompt should keep:
89
+ * - The three "Evaluation rules" pointing the model at structured fields
90
+ * and away from prose narrative.
91
+ * - A terminal directive to return ONLY the choice ID.
92
+ */
93
+ export declare function buildEvaluatePrompt(question: string, context: Record<string, unknown>, choices: {
94
+ id: string;
95
+ description: string;
96
+ }[]): string;
77
97
  export declare function parseToolResultContent(content: unknown): unknown;
78
98
  /**
79
99
  * Produce a short, single-line description of a tool-error payload suitable
package/dist/claude.js CHANGED
@@ -214,13 +214,7 @@ export class ClaudeClient {
214
214
  }
215
215
  async evaluate(opts) {
216
216
  const { question, context, choices } = opts;
217
- const choiceList = choices.map((c) => `- "${c.id}": ${c.description}`).join("\n");
218
- const prompt = [
219
- question,
220
- `\nContext:\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\``,
221
- `\nChoices:\n${choiceList}`,
222
- `\nRespond with ONLY the choice ID, nothing else.`,
223
- ].join("\n");
217
+ const prompt = buildEvaluatePrompt(question, context, choices);
224
218
  const env = this.buildEnv();
225
219
  let response = "";
226
220
  try {
@@ -351,6 +345,35 @@ function coreToolToSdkTool(coreTool, defaultCtx) {
351
345
  * `"42"`) from a tool that returned the string "42" (wrapper also sends
352
346
  * `"42"`). Preserving the string is safer than guessing.
353
347
  */
348
+ /**
349
+ * Build the prompt used by `ClaudeClient.evaluate()` to pick a routing edge.
350
+ *
351
+ * Extracted as a pure function so the prompt body can be unit tested without
352
+ * spinning up an SDK query. The body is part of the routing contract: workflow
353
+ * authors and downstream maintainers rely on the model leaning on structured
354
+ * fields rather than prose narrative when picking an edge, especially in the
355
+ * fallback case where a source node did not declare an `output` schema (the
356
+ * executor's schema-strict filter only kicks in when a schema is present;
357
+ * see `buildRouteEvalEntry` in executor.ts).
358
+ *
359
+ * Any change to this prompt should keep:
360
+ * - The three "Evaluation rules" pointing the model at structured fields
361
+ * and away from prose narrative.
362
+ * - A terminal directive to return ONLY the choice ID.
363
+ */
364
+ export function buildEvaluatePrompt(question, context, choices) {
365
+ const choiceList = choices.map((c) => `- "${c.id}": ${c.description}`).join("\n");
366
+ return [
367
+ question,
368
+ `\nContext:\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\``,
369
+ `\nChoices:\n${choiceList}`,
370
+ `\nEvaluation rules:`,
371
+ `1. Read each choice's condition literally and match against the structured fields in the context (e.g. status, counts, enum values, boolean flags).`,
372
+ `2. Ignore prose narrative fields ("summary", free-form rationale, conversational commentary). They are not the contract.`,
373
+ `3. When a field's value contradicts what a prose field claims, trust the field's value.`,
374
+ `\nRespond with ONLY the choice ID, nothing else.`,
375
+ ].join("\n");
376
+ }
354
377
  export function parseToolResultContent(content) {
355
378
  if (typeof content !== "string")
356
379
  return content;
package/dist/executor.js CHANGED
@@ -315,6 +315,73 @@ function buildPriorNodeContext(result) {
315
315
  // Spread evals first so a data-side `evals` key shadows it (back-compat).
316
316
  return { evals: evalsByName, ...data };
317
317
  }
318
+ /**
319
+ * Build the prior-node entry shown to the LLM route evaluator.
320
+ *
321
+ * Differs from `buildPriorNodeContext` (which feeds downstream node `run()`
322
+ * calls) in one way: when the source node declared an `output` schema with
323
+ * a `properties` block, the data view is restricted to those declared
324
+ * properties. The `evals` namespace is always preserved when present.
325
+ *
326
+ * Why this exists. The route evaluator is a natural-language model. Anything
327
+ * it sees in the context can sway the decision, including prose narrative
328
+ * fields like the always-injected `summary` from the reference Claude
329
+ * client (claude.ts: `data: { summary: response, ...parsed }`). Real-world
330
+ * symptom: a node correctly emits `status: "pass"` but also adds a
331
+ * conversational `summary` like "the quality_retry_count is 1", and the
332
+ * evaluator pattern-matches the retry mention to flip the routing decision.
333
+ *
334
+ * The fix is to treat the `output` schema as the routing contract. If the
335
+ * author declared which fields matter, only those reach the route
336
+ * evaluator. When no schema is declared we fall back to the full data
337
+ * (back-compat for workflows without structured outputs).
338
+ *
339
+ * The downstream node prompt is untouched: nodes still see the full prior
340
+ * `data` including any prose `summary`, so workflows that consume the
341
+ * narrative in subsequent steps keep working.
342
+ */
343
+ function buildRouteEvalEntry(result, sourceNode) {
344
+ const fullData = (result.data ?? {});
345
+ const declaredProps = getDeclaredOutputProperties(sourceNode?.output);
346
+ const dataView = declaredProps ? pickKeys(fullData, declaredProps) : fullData;
347
+ const evals = result.evals ?? [];
348
+ if (evals.length === 0)
349
+ return dataView;
350
+ const evalsByName = {};
351
+ for (const e of evals) {
352
+ evalsByName[e.name] = e;
353
+ }
354
+ // Spread evals first so a data-side `evals` key shadows it (back-compat).
355
+ return { evals: evalsByName, ...dataView };
356
+ }
357
+ /**
358
+ * Return the declared output property names for a node, or null when the
359
+ * node has no `output` schema or its schema does not declare a `properties`
360
+ * block. Null signals "no contract" and routing falls back to the full
361
+ * data view.
362
+ *
363
+ * Tolerant of malformed schemas because `output` is `JSONSchema =
364
+ * Record<string, unknown>` and is not parsed further by the executor.
365
+ */
366
+ function getDeclaredOutputProperties(output) {
367
+ if (!output || typeof output !== "object")
368
+ return null;
369
+ const props = output.properties;
370
+ if (!props || typeof props !== "object")
371
+ return null;
372
+ const keys = Object.keys(props);
373
+ if (keys.length === 0)
374
+ return null;
375
+ return new Set(keys);
376
+ }
377
+ function pickKeys(obj, keys) {
378
+ const out = {};
379
+ for (const k of keys) {
380
+ if (k in obj)
381
+ out[k] = obj[k];
382
+ }
383
+ return out;
384
+ }
318
385
  function nodeSourcesToArray(ns) {
319
386
  if (!ns)
320
387
  return { sources: [], only: false };
@@ -512,9 +579,16 @@ async function resolveNext(workflow, current, results, input, claude, observer,
512
579
  // Claude evaluates which condition matches. Include input so conditions
513
580
  // can reference workflow-level flags like dryRun, and expose evals so
514
581
  // routing edges can read `priorNode.evals.X.pass`.
582
+ //
583
+ // Crucial difference from the run() context: when a prior node declared
584
+ // an `output` schema, the routing view of its data is restricted to the
585
+ // declared properties. Without this, non-schema fields (notably the
586
+ // always-injected `summary` prose from the reference Claude client) can
587
+ // sway the natural-language route evaluator and override the actual
588
+ // structured result. See `buildRouteEvalEntry` for the full rationale.
515
589
  const context = {
516
590
  input,
517
- ...Object.fromEntries([...results.entries()].map(([k, v]) => [k, buildPriorNodeContext(v)])),
591
+ ...Object.fromEntries([...results.entries()].map(([k, v]) => [k, buildRouteEvalEntry(v, workflow.nodes[k])])),
518
592
  };
519
593
  const choices = conditionalEdges.map((e) => ({
520
594
  id: e.to,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.99",
3
+ "version": "0.1.100",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"