@sweny-ai/core 0.1.101 → 0.1.103

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/executor.js CHANGED
@@ -17,7 +17,15 @@ import { resolveSources } from "./source-resolver.js";
17
17
  import { evaluateAll, aggregateEval } from "./eval/index.js";
18
18
  import { evaluateRequires } from "./requires.js";
19
19
  import { buildRetryPreamble } from "./retry.js";
20
+ import { resolveExecutionModel } from "./model.js";
20
21
  import { buildToolAliases } from "./skills/index.js";
22
+ /**
23
+ * Default workflow-level step budget. Counts total node executions across the
24
+ * whole run (loop iterations included). Generous enough for legitimate deep
25
+ * workflows with controlled cycles, tight enough to terminate a runaway loop
26
+ * before it burns meaningful model spend.
27
+ */
28
+ export const DEFAULT_MAX_STEPS = 200;
21
29
  /**
22
30
  * Execute a workflow from entry to completion.
23
31
  *
@@ -31,11 +39,13 @@ export async function execute(workflow, input, options) {
31
39
  // Inline skills (instruction/mcp only) become Skill objects with empty tools/config.
32
40
  // The caller's skill map takes precedence — inline skills only fill gaps.
33
41
  const skills = mergeInlineSkills(options.skills, workflow.skills);
34
- const config = resolveConfig(skills, options.config);
42
+ const config = resolveConfig(skills, options.config, options.env ?? process.env);
35
43
  const logger = options.logger ?? consoleLogger;
36
44
  const results = new Map();
37
45
  const edgeCounts = new Map(); // "from→to" → times followed
38
46
  const nodeRunCounts = new Map(); // node → times executed
47
+ const maxSteps = options.max_steps ?? DEFAULT_MAX_STEPS;
48
+ let stepCount = 0; // total node executions across the run (loop iterations included)
39
49
  const trace = { steps: [], edges: [], sources: {} };
40
50
  validate(workflow, skills);
41
51
  // Build an eval-time alias table from the loaded skills. Each skill owns
@@ -86,6 +96,17 @@ export async function execute(workflow, input, options) {
86
96
  const node = workflow.nodes[currentId];
87
97
  if (!node)
88
98
  throw new Error(`Unknown node: "${currentId}"`);
99
+ // Workflow-level hard cap. Per-edge max_iterations bounds individual
100
+ // back-edges, but a self-loop (or LLM route) with no max_iterations is
101
+ // otherwise unbounded. This budget terminates the run loudly instead of
102
+ // hanging CI / the action and burning model spend.
103
+ stepCount++;
104
+ if (stepCount > maxSteps) {
105
+ throw new Error(`step budget exceeded: workflow '${workflow.id}' ran ${stepCount} steps (max_steps: ${maxSteps}). ` +
106
+ `This usually means an unbounded cycle, e.g. a back-edge with no 'max_iterations' or a route that keeps ` +
107
+ `looping. Add 'max_iterations' to the offending edge or raise 'max_steps' if the workflow legitimately ` +
108
+ `needs more steps.`);
109
+ }
89
110
  const iteration = (nodeRunCounts.get(currentId) ?? 0) + 1;
90
111
  nodeRunCounts.set(currentId, iteration);
91
112
  const resolvedInstruction = resolvedSources[`nodes.${currentId}.instruction`].content;
@@ -159,6 +180,9 @@ export async function execute(workflow, input, options) {
159
180
  let result;
160
181
  let currentInstruction = instruction;
161
182
  const retry = node.retry;
183
+ // Per-node execution model: node.model ?? workflow.model. When undefined,
184
+ // claude.run falls back to its own client default (then Claude Code's).
185
+ const nodeModel = resolveExecutionModel(node, workflow);
162
186
  while (true) {
163
187
  result = await claude.run({
164
188
  instruction: currentInstruction,
@@ -167,6 +191,7 @@ export async function execute(workflow, input, options) {
167
191
  outputSchema: node.output,
168
192
  maxTurns: node.max_turns,
169
193
  disallowedTools: node.disallowed_tools,
194
+ model: nodeModel,
170
195
  onProgress: (message) => {
171
196
  safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
172
197
  },
@@ -174,17 +199,48 @@ export async function execute(workflow, input, options) {
174
199
  // Retry only triggers on eval failure. Bail on tool/API errors.
175
200
  if (result.status !== "success")
176
201
  break;
177
- const evalResults = await evaluateAll(node.eval, result, {
178
- aliases: toolAliases,
179
- claude,
180
- node,
181
- workflow,
182
- });
183
- result.evals = evalResults;
184
- const outcome = aggregateEval(evalResults, node.eval_policy ?? "all_pass");
202
+ // Required-field gate: if the source node's declared output schema
203
+ // marks fields as `required`, those fields are the routing contract.
204
+ // An agent that omits a required field has violated the contract; we
205
+ // fail the node loudly rather than letting routing guess against a
206
+ // half-built data view. See the offload release-notes field bug:
207
+ // an optional quality_retry_count was declared in the schema but
208
+ // omitted by the agent, the route view silently dropped the key,
209
+ // and an "is 0 OR is undefined" condition ghost-matched.
210
+ //
211
+ // We only check declared `required` fields, not full JSON Schema
212
+ // validation. Type mismatches, formats, additionalProperties, etc.
213
+ // stay the workflow author's problem and don't have the same
214
+ // routing-correctness blast radius.
215
+ // A missing required field is a contract violation handled on the same
216
+ // footing as an eval failure: an otherwise-recoverable one-off omission
217
+ // should self-heal on a second attempt when the node has `retry`
218
+ // configured. We synthesize an eval-shaped failure so the retry preamble
219
+ // and trace bookkeeping below treat it identically. When no retry is
220
+ // configured (or the budget is exhausted), it still hard-fails the node.
221
+ const missingRequired = findMissingRequiredFields(result.data, node.output);
222
+ let outcome;
223
+ if (missingRequired.length > 0) {
224
+ const msg = `output schema violation: required field(s) missing from emitted data: ${missingRequired.join(", ")}`;
225
+ const failure = { name: "output_required", kind: "value", pass: false, reasoning: msg };
226
+ outcome = { pass: false, error: msg, failures: [failure] };
227
+ // Still record the synthesized failure as the node's eval result so
228
+ // observers and downstream context see the contract violation.
229
+ result.evals = [failure];
230
+ }
231
+ else {
232
+ const evalResults = await evaluateAll(node.eval, result, {
233
+ aliases: toolAliases,
234
+ claude,
235
+ node,
236
+ workflow,
237
+ });
238
+ result.evals = evalResults;
239
+ outcome = aggregateEval(evalResults, node.eval_policy ?? "all_pass");
240
+ }
185
241
  if (outcome.pass)
186
242
  break;
187
- // Apply eval failure to the result.
243
+ // Apply the failure to the result.
188
244
  result.status = "failed";
189
245
  result.data = { ...result.data, error: outcome.error };
190
246
  if (!retry || attempt >= retry.max) {
@@ -204,6 +260,7 @@ export async function execute(workflow, input, options) {
204
260
  claude,
205
261
  logger,
206
262
  context,
263
+ model: nodeModel,
207
264
  });
208
265
  currentInstruction = `${preamble}\n\n---\n\n${instruction}`;
209
266
  safeObserve(observer, { type: "node:retry", node: currentId, attempt: attempt + 1, reason: outcome.error ?? "eval failed", preamble }, logger);
@@ -344,16 +401,44 @@ function buildPriorNodeContext(result) {
344
401
  function buildRouteEvalEntry(result, sourceNode) {
345
402
  const fullData = (result.data ?? {});
346
403
  const declaredProps = getDeclaredOutputProperties(sourceNode?.output);
347
- const dataView = declaredProps ? pickKeys(fullData, declaredProps) : fullData;
404
+ // When the source node declared an `output.properties` block, the routing
405
+ // view is contract-shaped: every declared property is present in the
406
+ // view, with an explicit `null` when the agent did not emit it. The
407
+ // alternative (silently dropping the key) lets the LLM evaluator
408
+ // ghost-match conditions like "is 0", "is N", or "is undefined" against
409
+ // a structurally-absent field. That was the offload release-notes field
410
+ // bug: an optional `quality_retry_count` declared in the schema but
411
+ // omitted by the agent, evaluated against a condition like
412
+ // `quality_retry_count is 0 OR is undefined`, took the wrong branch.
413
+ //
414
+ // Returning `missing` separately lets the caller emit a loud warning so
415
+ // operators see the contract violation in the log stream.
416
+ let dataView;
417
+ const missing = [];
418
+ if (declaredProps) {
419
+ dataView = {};
420
+ for (const k of declaredProps) {
421
+ if (k in fullData) {
422
+ dataView[k] = fullData[k];
423
+ }
424
+ else {
425
+ dataView[k] = null;
426
+ missing.push(k);
427
+ }
428
+ }
429
+ }
430
+ else {
431
+ dataView = fullData;
432
+ }
348
433
  const evals = result.evals ?? [];
349
434
  if (evals.length === 0)
350
- return dataView;
435
+ return { view: dataView, missing };
351
436
  const evalsByName = {};
352
437
  for (const e of evals) {
353
438
  evalsByName[e.name] = e;
354
439
  }
355
440
  // Spread evals first so a data-side `evals` key shadows it (back-compat).
356
- return { evals: evalsByName, ...dataView };
441
+ return { view: { evals: evalsByName, ...dataView }, missing };
357
442
  }
358
443
  /**
359
444
  * Return the declared output property names for a node, or null when the
@@ -375,13 +460,34 @@ function getDeclaredOutputProperties(output) {
375
460
  return null;
376
461
  return new Set(keys);
377
462
  }
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;
463
+ /**
464
+ * Return the declared `required` field names for a node's output schema,
465
+ * or an empty array when no required block is declared. Tolerant of
466
+ * malformed schemas (mirrors getDeclaredOutputProperties).
467
+ */
468
+ function getDeclaredRequiredFields(output) {
469
+ if (!output || typeof output !== "object")
470
+ return [];
471
+ const required = output.required;
472
+ if (!Array.isArray(required))
473
+ return [];
474
+ return required.filter((r) => typeof r === "string");
475
+ }
476
+ /**
477
+ * Validate a node's emitted data against the declared output schema's
478
+ * required fields. Returns the list of required-but-missing field names.
479
+ *
480
+ * This is intentionally narrow: we do NOT do full JSON Schema validation.
481
+ * The routing impact comes from required fields being absent, so that's
482
+ * what we check. Type mismatches, format violations, additionalProperties
483
+ * etc. stay the workflow author's problem.
484
+ */
485
+ function findMissingRequiredFields(data, output) {
486
+ const required = getDeclaredRequiredFields(output);
487
+ if (required.length === 0)
488
+ return [];
489
+ const obj = (data ?? {});
490
+ return required.filter((k) => !(k in obj));
385
491
  }
386
492
  function nodeSourcesToArray(ns) {
387
493
  if (!ns)
@@ -492,13 +598,18 @@ function resolveSkillInstructions(skillIds, skills) {
492
598
  /**
493
599
  * Resolve config values: check explicit overrides first, then env vars.
494
600
  * Throws if a required field is missing.
601
+ *
602
+ * `env` is the environment map to read `field.env` from. Callers thread
603
+ * `options.env ?? process.env` so an explicit env map is honored consistently
604
+ * with Source resolution (which reads the same map). Defaults to
605
+ * `process.env` when omitted.
495
606
  */
496
- function resolveConfig(skills, overrides) {
607
+ function resolveConfig(skills, overrides, env = process.env) {
497
608
  const config = {};
498
609
  const missing = [];
499
610
  for (const skill of skills.values()) {
500
611
  for (const [key, field] of Object.entries(skill.config)) {
501
- const value = overrides?.[key] ?? (field.env ? process.env[field.env] : undefined);
612
+ const value = overrides?.[key] ?? (field.env ? env[field.env] : undefined);
502
613
  if (value) {
503
614
  config[key] = value;
504
615
  }
@@ -587,10 +698,28 @@ async function resolveNext(workflow, current, results, input, claude, observer,
587
698
  // always-injected `summary` prose from the reference Claude client) can
588
699
  // sway the natural-language route evaluator and override the actual
589
700
  // 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
- };
701
+ const context = { input };
702
+ for (const [k, v] of results.entries()) {
703
+ const { view, missing } = buildRouteEvalEntry(v, workflow.nodes[k]);
704
+ context[k] = view;
705
+ if (missing.length > 0) {
706
+ // Loud signal for the operator. The view itself already replaces the
707
+ // missing keys with explicit `null`, so the LLM evaluator cannot
708
+ // ghost-match "is N" or "is undefined" against a structurally-absent
709
+ // field. The warning surfaces the contract violation in the log
710
+ // stream and to observer-based telemetry.
711
+ logger?.warn(` route eval: source node '${k}' declared properties not in emitted data: ${missing.join(", ")}`, {
712
+ node: k,
713
+ fields: missing,
714
+ });
715
+ safeObserve(observer, {
716
+ type: "node:warning",
717
+ node: k,
718
+ reason: `declared output properties missing from emitted data; routing view filled them with null`,
719
+ fields: missing,
720
+ }, logger);
721
+ }
722
+ }
594
723
  const choices = conditionalEdges.map((e) => ({
595
724
  id: e.to,
596
725
  description: e.when,
@@ -603,14 +732,36 @@ async function resolveNext(workflow, current, results, input, claude, observer,
603
732
  context,
604
733
  choices,
605
734
  });
606
- // Validate that Claude returned a valid target
735
+ // Validate that Claude returned a valid target.
607
736
  const validTargets = new Set(outEdges.map((e) => e.to));
608
- const resolved = validTargets.has(chosen) ? chosen : (defaultEdge?.to ?? outEdges[0].to); // fall back to default or first edge
737
+ if (!validTargets.has(chosen)) {
738
+ // The evaluator returned a target that is not a live out-edge. If there's
739
+ // an explicit default (unconditional) edge, take it as the documented
740
+ // fallback. Otherwise this is a hard error: silently jumping to
741
+ // `outEdges[0]` (often the loop-back edge) launders garbage model output
742
+ // into a plausible-looking route and, combined with an unbounded cycle,
743
+ // produces an infinite loop. Stop loudly instead.
744
+ if (!defaultEdge) {
745
+ logger?.warn(` route eval: evaluator returned invalid target '${chosen}' for node '${current}' and there is no ` +
746
+ `default (unconditional) edge; terminating this branch. Valid targets: ${[...validTargets].join(", ")}`, { node: current, chosen, validTargets: [...validTargets] });
747
+ safeObserve(observer, {
748
+ type: "route",
749
+ from: current,
750
+ to: "(end)",
751
+ reason: `invalid route target '${chosen}' with no default edge`,
752
+ }, logger);
753
+ return null;
754
+ }
755
+ }
756
+ const resolved = validTargets.has(chosen) ? chosen : defaultEdge.to;
609
757
  // Track edge usage for max_iterations
610
758
  if (edgeCounts) {
611
759
  const key = `${current}→${resolved}`;
612
760
  edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
613
761
  }
762
+ // Report the matched choice's description. The `?? "default"` fallback now
763
+ // only fires when a default edge genuinely exists (the no-default invalid
764
+ // case returned null above), so it can no longer mislabel a garbage route.
614
765
  safeObserve(observer, {
615
766
  type: "route",
616
767
  from: current,
package/dist/index.d.ts CHANGED
@@ -21,18 +21,22 @@
21
21
  * })
22
22
  * ```
23
23
  */
24
- export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, } from "./types.js";
24
+ export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, 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, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
25
25
  export { WORKFLOW_INPUT_TYPES } from "./types.js";
26
+ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
26
27
  export { consoleLogger } from "./types.js";
27
28
  export { execute } from "./executor.js";
28
29
  export type { ExecuteOptions } from "./executor.js";
29
- export { ClaudeClient } from "./claude.js";
30
- export type { ClaudeClientOptions } from "./claude.js";
30
+ export { ClaudeClient, resolveAuthEnv } from "./claude.js";
31
+ export type { ClaudeClientOptions, SwenyAuthMode, ResolveAuthEnvOpts } from "./claude.js";
32
+ export { resolveExecutionModel } from "./model.js";
31
33
  export { github, linear, slack, sentry, datadog, notification, supabase, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
32
34
  export type { SkillValidationResult } from "./skills/index.js";
33
35
  export { loadCustomSkills, discoverSkills, configuredSkills } from "./skills/custom-loader.js";
34
- export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, } from "./schema.js";
36
+ export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
35
37
  export type { WorkflowError } from "./schema.js";
38
+ export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
39
+ export type { LoaderResult, LoaderError, LoaderOptions } from "./loader.js";
36
40
  export { validateRuntimeInput, summarizeInputShape } from "./inputs.js";
37
41
  export { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from "./mcp.js";
38
42
  export type { ProviderContextOptions, SkillMcpOptions } from "./mcp.js";
package/dist/index.js CHANGED
@@ -22,17 +22,25 @@
22
22
  * ```
23
23
  */
24
24
  export { WORKFLOW_INPUT_TYPES } from "./types.js";
25
+ // Runtime enum constants + skill-id helpers
26
+ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
25
27
  export { consoleLogger } from "./types.js";
26
28
  // Executor
27
29
  export { execute } from "./executor.js";
28
30
  // Claude client
29
- export { ClaudeClient } from "./claude.js";
31
+ export { ClaudeClient, resolveAuthEnv } from "./claude.js";
32
+ // Execution model resolution
33
+ export { resolveExecutionModel } from "./model.js";
30
34
  // Skills
31
35
  export { github, linear, slack, sentry, datadog, notification, supabase, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
32
36
  // Node-only skill discovery (filesystem-based)
33
37
  export { loadCustomSkills, discoverSkills, configuredSkills } from "./skills/custom-loader.js";
34
38
  // Schema & validation
35
- export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, } from "./schema.js";
39
+ export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
40
+ // Loader — canonical read → parse → structural-validate pipeline (the path
41
+ // the CLI uses for `workflow run`, `workflow validate`, and `publish`).
42
+ // Node-only: loader.ts imports `node:fs`, so this is NOT mirrored in browser.ts.
43
+ export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
36
44
  // Workflow input validation
37
45
  export { validateRuntimeInput, summarizeInputShape } from "./inputs.js";
38
46
  // MCP auto-injection
package/dist/inputs.js CHANGED
@@ -161,7 +161,16 @@ export function validateRuntimeInput(declared, raw) {
161
161
  // and the cascade `rules`/`context` mechanism inject extra keys into
162
162
  // the input bag. The declaration narrows the contract for documented
163
163
  // fields without locking down the whole object.
164
+ //
165
+ // Prototype-pollution guard: `raw` is `unknown` and callers (Studio,
166
+ // programmatic scripts) may hand us hand-built objects, or `JSON.parse`
167
+ // output containing an enumerable own `__proto__` key. Writing such a
168
+ // key with `out[k] = v` would reassign the result's prototype. Skip the
169
+ // three dangerous keys so untrusted input can never touch the prototype
170
+ // chain. `out` stays a normal object literal for downstream consumers.
164
171
  for (const [k, v] of Object.entries(provided)) {
172
+ if (isUnsafeKey(k))
173
+ continue;
165
174
  if (!Object.prototype.hasOwnProperty.call(declared, k)) {
166
175
  out[k] = v;
167
176
  }
@@ -195,6 +204,15 @@ export function summarizeInputShape(declared, resolved) {
195
204
  return shape;
196
205
  }
197
206
  // ─── Internals ──────────────────────────────────────────────────
207
+ /**
208
+ * Keys that must never be copied from untrusted input onto a plain object,
209
+ * because assigning them mutates the prototype chain rather than adding an
210
+ * own property. `JSON.parse('{"__proto__":{...}}')` produces an enumerable
211
+ * own `__proto__` key that `Object.entries` surfaces.
212
+ */
213
+ function isUnsafeKey(key) {
214
+ return key === "__proto__" || key === "constructor" || key === "prototype";
215
+ }
198
216
  function checkValueType(value, type) {
199
217
  switch (type) {
200
218
  case "string":
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 } from "./schema.js";
16
+ import { workflowZ, validateWorkflow, preflightLegacyVerify } from "./schema.js";
17
17
  /**
18
18
  * Load a workflow YAML or JSON file, parse with the canonical Zod schema,
19
19
  * and run structural validation. All three phases contribute to `errors`.
@@ -47,6 +47,20 @@ export function validateParsed(raw, options = {}) {
47
47
  const kind = raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
48
48
  return { ok: false, errors: [{ message: `Expected a workflow object, got ${kind}`, code: "SCHEMA" }] };
49
49
  }
50
+ // Detect legacy `verify:` blocks (renamed to `eval:` in v0.2.0) before the
51
+ // Zod parse. workflowZ is intentionally non-strict at the top level, so a
52
+ // top-level `verify:` would be silently STRIPPED instead of surfacing the
53
+ // migration message. parseWorkflow runs this preflight already; validateParsed
54
+ // (the entrypoint for loadAndValidateWorkflow / Studio import) must too. The
55
+ // loader contract is "return errors, do not throw", so map the throw to a
56
+ // SCHEMA LoaderError rather than letting it propagate.
57
+ try {
58
+ preflightLegacyVerify(raw);
59
+ }
60
+ catch (err) {
61
+ const msg = err instanceof Error ? err.message : String(err);
62
+ return { ok: false, errors: [{ message: msg, code: "SCHEMA" }] };
63
+ }
50
64
  let parsed;
51
65
  try {
52
66
  parsed = workflowZ.parse(raw);
@@ -0,0 +1,11 @@
1
+ import type { Node, Workflow } from "./types.js";
2
+ /**
3
+ * Resolve the execution model for a node: `node.model ?? workflow.model`.
4
+ *
5
+ * Returns `undefined` when neither is set. The client default (if any) is
6
+ * applied one layer down, inside `ClaudeClient.run` (`model ?? this.model`),
7
+ * so the full three-layer cascade is node → workflow → client default →
8
+ * Claude Code default. Callers SHOULD pass `undefined` through to `run`
9
+ * rather than substituting a hardcoded model.
10
+ */
11
+ export declare function resolveExecutionModel(node: Node | undefined, workflow: Workflow | undefined): string | undefined;
package/dist/model.js ADDED
@@ -0,0 +1,19 @@
1
+ // ─── Execution model resolution ─────────────────────────────────────
2
+ //
3
+ // Mirrors the judge-model cascade in eval/index.ts, but for the model that
4
+ // executes a node (not the model that evaluates it). Unlike the judge
5
+ // resolver, there is NO package-level default: when no layer specifies a
6
+ // model, this returns undefined so the executor emits no SDK `model` option
7
+ // and Claude Code's own default applies.
8
+ /**
9
+ * Resolve the execution model for a node: `node.model ?? workflow.model`.
10
+ *
11
+ * Returns `undefined` when neither is set. The client default (if any) is
12
+ * applied one layer down, inside `ClaudeClient.run` (`model ?? this.model`),
13
+ * so the full three-layer cascade is node → workflow → client default →
14
+ * Claude Code default. Callers SHOULD pass `undefined` through to `run`
15
+ * rather than substituting a hardcoded model.
16
+ */
17
+ export function resolveExecutionModel(node, workflow) {
18
+ return node?.model ?? workflow?.model;
19
+ }
package/dist/retry.d.ts CHANGED
@@ -12,6 +12,11 @@ export interface BuildRetryPreambleOptions {
12
12
  claude: Claude;
13
13
  logger: Logger;
14
14
  context: Record<string, unknown>;
15
+ /**
16
+ * Per-node execution model. The reflection reasons about this node, so it
17
+ * runs on the node's model. When undefined, the client default applies.
18
+ */
19
+ model?: string;
15
20
  }
16
21
  /**
17
22
  * Build the retry preamble for a single retry attempt.
package/dist/retry.js CHANGED
@@ -21,7 +21,7 @@ const DEFAULT_PREAMBLE_HEADER = "## Previous attempt failed evaluation";
21
21
  * default static preamble and logs a warning.
22
22
  */
23
23
  export async function buildRetryPreamble(opts) {
24
- const { retry, evalFailures, toolCalls, nodeInstruction, claude, logger, context } = opts;
24
+ const { retry, evalFailures, toolCalls, nodeInstruction, claude, logger, context, model } = opts;
25
25
  const failureList = formatEvalFailures(evalFailures);
26
26
  const inst = retry.instruction;
27
27
  if (typeof inst === "string") {
@@ -31,7 +31,7 @@ export async function buildRetryPreamble(opts) {
31
31
  const reflectPrompt = "reflect" in inst && typeof inst.reflect === "string" ? inst.reflect : DEFAULT_REFLECTION_PROMPT;
32
32
  const askInstruction = buildReflectionPrompt(reflectPrompt, nodeInstruction, failureList, toolCalls);
33
33
  try {
34
- const diagnosis = await claude.ask({ instruction: askInstruction, context });
34
+ const diagnosis = await claude.ask({ instruction: askInstruction, context, model });
35
35
  const trimmed = diagnosis.trim();
36
36
  if (trimmed.length > 0) {
37
37
  return `## Reflection on previous attempt\n\n${trimmed}\n\n${DEFAULT_PREAMBLE_HEADER}\n\n${failureList}`;