@sweny-ai/core 0.1.161 → 0.1.162

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/browser.d.ts CHANGED
@@ -13,12 +13,14 @@
13
13
  * - `buildWorkflow` / `refineWorkflow` — depend on the Claude interface.
14
14
  * - Testing helpers — `MockClaude`, `createFileSkill` use `node:fs`.
15
15
  */
16
- export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
16
+ export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, EdgeWhen, WhenExpression, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
17
17
  export { consoleLogger } from "./types.js";
18
18
  export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
19
19
  export { github, linear, slack, sentry, datadog, betterstack, notification, supabase, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
20
20
  export type { SkillValidationResult } from "./skills/index.js";
21
21
  export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
22
22
  export type { WorkflowError } from "./schema.js";
23
+ export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
24
+ export type { ExprNode, ExpressionScope, ExpressionResult } from "./when.js";
23
25
  export { workflowToFlow, flowToWorkflow, applyExecutionEvent, exportAsTypescript, getSkillCatalog } from "./studio.js";
24
26
  export type { FlowNode, SkillNodeData, FlowEdge } from "./studio.js";
package/dist/browser.js CHANGED
@@ -23,5 +23,7 @@ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRA
23
23
  export { github, linear, slack, sentry, datadog, betterstack, notification, supabase, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
24
24
  // Schema & validation
25
25
  export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
26
+ // Deterministic `when` expressions (#461). Pure, browser-safe.
27
+ export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
26
28
  // Studio adapter
27
29
  export { workflowToFlow, flowToWorkflow, applyExecutionEvent, exportAsTypescript, getSkillCatalog } from "./studio.js";
@@ -262,7 +262,7 @@ export const WORKFLOW_RUN_OPTIONS = [
262
262
  ["-y, --yes", "Skip the batch confirmation prompt (for CI)"],
263
263
  [
264
264
  "--dry-run",
265
- "Run with read-only tools only: write tools, external MCP servers, and shell/file-edit tools are withheld, so nothing is created, posted, or sent. Stops at the first conditional edge. To inspect nodes without running, use --list-nodes.",
265
+ "Run with read-only tools only: write tools, external MCP servers, and shell/file-edit tools are withheld, so nothing is created, posted, or sent. Stops at the first natural-language conditional edge. To inspect nodes without running, use --list-nodes.",
266
266
  ],
267
267
  [
268
268
  "--stage",
package/dist/executor.js CHANGED
@@ -16,6 +16,7 @@ import { consoleLogger } from "./types.js";
16
16
  import { resolveSources } from "./source-resolver.js";
17
17
  import { evaluateAll, aggregateEval } from "./eval/index.js";
18
18
  import { evaluateRequires } from "./requires.js";
19
+ import { evaluateExpression, isWhenExpression, parseExpression, whenLabel } from "./when.js";
19
20
  import { buildRetryPreamble } from "./retry.js";
20
21
  import { resolveExecutionModel } from "./model.js";
21
22
  import { buildToolAliases } from "./skills/index.js";
@@ -1118,15 +1119,17 @@ function resolveConfig(skills, overrides, env = process.env) {
1118
1119
  * Returns the next node ID, or null when execution should stop.
1119
1120
  */
1120
1121
  async function advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, abort) {
1121
- // Dry run path gate: stop at the first conditional routing decision.
1122
+ // Dry run path gate: stop at the first natural-language routing decision.
1122
1123
  // Safety does not depend on this (#380): under dry-run every node already
1123
- // runs read-only (see execute()). The stop keeps the dry-run path a pure
1124
- // function of the graph (no LLM route evaluation, so the same workflow
1125
- // always visits the same nodes) and skips action branches whose output
1126
- // would only describe writes that cannot happen.
1124
+ // runs read-only (see execute()). The stop keeps dry-run routing free of
1125
+ // model route evaluation, so the path is reproducible from the node outputs
1126
+ // alone. Expression edges (#461) are evaluated by sweny with no model call,
1127
+ // so dry-run follows them: a preview can reach the nodes past a
1128
+ // deterministic branch (still read-only, outputs staged) and stops only
1129
+ // where a model would have to pick the branch.
1127
1130
  if (isDryRunInput(input)) {
1128
1131
  const outEdges = workflow.edges.filter((e) => e.from === currentId);
1129
- if (outEdges.some((e) => e.when)) {
1132
+ if (outEdges.some((e) => e.when && !isWhenExpression(e.when))) {
1130
1133
  safeObserve(observer, { type: "route", from: currentId, to: "(end)", reason: "dry run" }, logger);
1131
1134
  return null;
1132
1135
  }
@@ -1134,7 +1137,7 @@ async function advanceFromNode(workflow, currentId, results, input, claude, obse
1134
1137
  const prevId = currentId;
1135
1138
  const nextId = await resolveNext(workflow, currentId, results, input, claude, observer, edgeCounts, logger, abort);
1136
1139
  if (nextId) {
1137
- const reason = workflow.edges.find((e) => e.from === prevId && e.to === nextId)?.when ?? "only path";
1140
+ const reason = whenLabel(workflow.edges.find((e) => e.from === prevId && e.to === nextId)?.when) ?? "only path";
1138
1141
  trace.edges.push({ from: prevId, to: nextId, reason });
1139
1142
  }
1140
1143
  return nextId;
@@ -1144,7 +1147,8 @@ async function advanceFromNode(workflow, currentId, results, input, claude, obse
1144
1147
  *
1145
1148
  * - 0 out-edges → terminal (return null)
1146
1149
  * - 1 unconditional edge → follow it
1147
- * - Multiple or conditional → Claude evaluates
1150
+ * - Every conditional edge is an `{ expr }` expression → sweny evaluates them, no model call
1151
+ * - Otherwise multiple or conditional → Claude evaluates
1148
1152
  *
1149
1153
  * Edges with max_iterations are filtered out once exhausted.
1150
1154
  */
@@ -1192,6 +1196,12 @@ async function resolveNext(workflow, current, results, input, claude, observer,
1192
1196
  safeObserve(observer, { type: "route", from: current, to: defaultEdge.to, reason: "only path" }, logger);
1193
1197
  return defaultEdge.to;
1194
1198
  }
1199
+ // Deterministic routing (#461): every conditional out-edge is an expression,
1200
+ // so sweny decides without a model call. validateWorkflow rejects a node
1201
+ // that mixes expression and natural-language edges.
1202
+ if (conditionalEdges.every((e) => isWhenExpression(e.when))) {
1203
+ return resolveByExpressions(workflow, current, results, conditionalEdges, defaultEdge, observer, edgeCounts, logger);
1204
+ }
1195
1205
  // Claude evaluates which condition matches. Include input so conditions
1196
1206
  // can reference workflow-level flags like dryRun, and expose evals so
1197
1207
  // routing edges can read `priorNode.evals.X.pass`.
@@ -1233,7 +1243,7 @@ async function resolveNext(workflow, current, results, input, claude, observer,
1233
1243
  }
1234
1244
  const choices = conditionalEdges.map((e) => ({
1235
1245
  id: e.to,
1236
- description: e.when,
1246
+ description: whenLabel(e.when),
1237
1247
  }));
1238
1248
  if (defaultEdge) {
1239
1249
  choices.push({ id: defaultEdge.to, description: "None of the above / default path" });
@@ -1306,6 +1316,76 @@ async function resolveNext(workflow, current, results, input, claude, observer,
1306
1316
  }, logger);
1307
1317
  return resolved;
1308
1318
  }
1319
+ /**
1320
+ * Pick the out-edge whose `{ expr }` condition is true, with no model call
1321
+ * (#461). Exactly one true edge is taken. None true takes the unconditional
1322
+ * default edge when there is one. Otherwise (no edge true and no default, or
1323
+ * two or more true) it fails closed with a RouteEvaluationError, the same
1324
+ * contract as a failed model route evaluation: sweny never guesses an edge.
1325
+ *
1326
+ * An expression reads only successful nodes' outputs. A missing field, or a
1327
+ * node that did not run or did not succeed, makes that expression false and
1328
+ * is logged as a warning, never a silent true.
1329
+ */
1330
+ function resolveByExpressions(workflow, current, results, conditionalEdges, defaultEdge, observer, edgeCounts, logger) {
1331
+ const scope = {};
1332
+ for (const [id, r] of results.entries()) {
1333
+ if (r.status === "success")
1334
+ scope[id] = buildPriorNodeContext(r);
1335
+ }
1336
+ const matched = [];
1337
+ for (const edge of conditionalEdges) {
1338
+ const expr = whenLabel(edge.when);
1339
+ let outcome;
1340
+ try {
1341
+ outcome = evaluateExpression(parseExpression(expr), scope);
1342
+ }
1343
+ catch (err) {
1344
+ // validateWorkflow parses every expression before a run, so this only
1345
+ // fires for a workflow that skipped validation. Fail closed.
1346
+ const msg = err instanceof Error ? err.message : String(err);
1347
+ safeObserve(observer, { type: "route", from: current, to: "(end)", reason: `invalid when expression` }, logger);
1348
+ throw new RouteEvaluationError(current, `invalid when expression on edge '${current}' -> '${edge.to}': ${msg}`);
1349
+ }
1350
+ if (outcome.problem) {
1351
+ logger?.warn(` route expr: '${current}' -> '${edge.to}' is false: ${outcome.problem} (${expr})`, {
1352
+ node: current,
1353
+ to: edge.to,
1354
+ });
1355
+ safeObserve(observer, {
1356
+ type: "node:warning",
1357
+ node: current,
1358
+ reason: `when expression on edge to '${edge.to}' evaluated false: ${outcome.problem}`,
1359
+ fields: [],
1360
+ }, logger);
1361
+ }
1362
+ if (outcome.value)
1363
+ matched.push({ to: edge.to, expr });
1364
+ }
1365
+ const take = (to, reason) => {
1366
+ if (edgeCounts) {
1367
+ const key = `${current}→${to}`;
1368
+ edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
1369
+ }
1370
+ safeObserve(observer, { type: "route", from: current, to, reason }, logger);
1371
+ return to;
1372
+ };
1373
+ if (matched.length === 1)
1374
+ return take(matched[0].to, matched[0].expr);
1375
+ if (matched.length === 0 && defaultEdge) {
1376
+ logger?.info(` route expr: no expression matched for '${current}'; taking default edge '${defaultEdge.to}'`, {
1377
+ node: current,
1378
+ });
1379
+ return take(defaultEdge.to, "no expression matched; default edge");
1380
+ }
1381
+ const why = matched.length === 0
1382
+ ? `no when expression on node '${current}' is true and there is no default (unconditional) edge`
1383
+ : `${matched.length} when expressions on node '${current}' are true (to ${matched.map((m) => m.to).join(", ")}); ` +
1384
+ `exactly one may match`;
1385
+ logger?.error(` route expr: ${why}; refusing to guess. Terminating the run.`, { node: current });
1386
+ safeObserve(observer, { type: "route", from: current, to: "(end)", reason: why }, logger);
1387
+ throw new RouteEvaluationError(current, `${why}; refusing to guess`);
1388
+ }
1309
1389
  /**
1310
1390
  * Validate a workflow definition before execution.
1311
1391
  */
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@
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, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, NodeAccess, NodePermissions, NodePermissionsSpec, SafeOutputType, SafeOutputDeclaration, SafeOutputsPolicy, SafeOutputReceipt, SafeOutputPin, AuthorAssociation, } from "./types.js";
24
+ export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, EdgeWhen, WhenExpression, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, NodeAccess, NodePermissions, NodePermissionsSpec, SafeOutputType, SafeOutputDeclaration, SafeOutputsPolicy, SafeOutputReceipt, SafeOutputPin, AuthorAssociation, } from "./types.js";
25
25
  export { WORKFLOW_INPUT_TYPES } from "./types.js";
26
26
  export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, NODE_ACCESS, TOOL_CLASSES, SAFE_OUTPUT_TYPES, SAFE_OUTPUT_APPLIERS, AUTHOR_ASSOCIATIONS, } from "./types.js";
27
27
  export { consoleLogger } from "./types.js";
@@ -43,6 +43,8 @@ export type { SkillValidationResult } from "./skills/index.js";
43
43
  export { loadCustomSkills, discoverSkills, configuredSkills } from "./skills/custom-loader.js";
44
44
  export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
45
45
  export type { WorkflowError } from "./schema.js";
46
+ export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
47
+ export type { ExprNode, ExpressionScope, ExpressionResult } from "./when.js";
46
48
  export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
47
49
  export type { LoaderResult, LoaderError, LoaderOptions } from "./loader.js";
48
50
  export { CURRENT_SPEC_VERSION, MIGRATIONS, migrateWorkflow } from "./migrations.js";
package/dist/index.js CHANGED
@@ -43,6 +43,8 @@ export { github, linear, slack, sentry, datadog, notification, supabase, builtin
43
43
  export { loadCustomSkills, discoverSkills, configuredSkills } from "./skills/custom-loader.js";
44
44
  // Schema & validation
45
45
  export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
46
+ // Deterministic `when` expressions (#461). Pure, browser-safe.
47
+ export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
46
48
  // Loader — canonical read → parse → structural-validate pipeline (the path
47
49
  // the CLI uses for `workflow run`, `workflow validate`, and `publish`).
48
50
  // Node-only: loader.ts imports `node:fs`, so this is NOT mirrored in browser.ts.
package/dist/mermaid.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * (current/success/failed/skipped) and optional execution trace
10
10
  * to highlight which edges were taken and show loop iterations.
11
11
  */
12
+ import { whenLabel } from "./when.js";
12
13
  /**
13
14
  * Convert a Workflow to a Mermaid flowchart diagram string.
14
15
  *
@@ -102,8 +103,9 @@ export function toMermaid(workflow, options = {}) {
102
103
  const takenCount = edgeTakenCounts.get(key) ?? 0;
103
104
  // Build label parts
104
105
  const labelParts = [];
105
- if (edge.when)
106
- labelParts.push(edge.when);
106
+ const when = whenLabel(edge.when);
107
+ if (when)
108
+ labelParts.push(when);
107
109
  if (edge.max_iterations)
108
110
  labelParts.push(`max ${edge.max_iterations}x`);
109
111
  // Show how many times this edge was actually followed
package/dist/schema.d.ts CHANGED
@@ -10,6 +10,7 @@ import { z } from "zod";
10
10
  import type { Workflow, WorkflowType } from "./types.js";
11
11
  import { sourceZ } from "./sources.js";
12
12
  import { workflowInputsZ } from "./inputs.js";
13
+ export { isWhenExpression, whenLabel } from "./when.js";
13
14
  export { sourceZ };
14
15
  export { workflowInputsZ };
15
16
  export declare const jsonSchemaZ: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -1683,17 +1684,27 @@ export declare const nodeZ: z.ZodObject<{
1683
1684
  export declare const edgeZ: z.ZodObject<{
1684
1685
  from: z.ZodString;
1685
1686
  to: z.ZodString;
1686
- when: z.ZodOptional<z.ZodString>;
1687
+ when: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodObject<{
1688
+ expr: z.ZodString;
1689
+ }, "strict", z.ZodTypeAny, {
1690
+ expr: string;
1691
+ }, {
1692
+ expr: string;
1693
+ }>]>>;
1687
1694
  max_iterations: z.ZodOptional<z.ZodNumber>;
1688
1695
  }, "strict", z.ZodTypeAny, {
1689
1696
  from: string;
1690
1697
  to: string;
1691
- when?: string | undefined;
1698
+ when?: string | {
1699
+ expr: string;
1700
+ } | undefined;
1692
1701
  max_iterations?: number | undefined;
1693
1702
  }, {
1694
1703
  from: string;
1695
1704
  to: string;
1696
- when?: string | undefined;
1705
+ when?: string | {
1706
+ expr: string;
1707
+ } | undefined;
1697
1708
  max_iterations?: number | undefined;
1698
1709
  }>;
1699
1710
  /**
@@ -2466,17 +2477,27 @@ export declare const workflowZ: z.ZodObject<{
2466
2477
  edges: z.ZodArray<z.ZodObject<{
2467
2478
  from: z.ZodString;
2468
2479
  to: z.ZodString;
2469
- when: z.ZodOptional<z.ZodString>;
2480
+ when: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodObject<{
2481
+ expr: z.ZodString;
2482
+ }, "strict", z.ZodTypeAny, {
2483
+ expr: string;
2484
+ }, {
2485
+ expr: string;
2486
+ }>]>>;
2470
2487
  max_iterations: z.ZodOptional<z.ZodNumber>;
2471
2488
  }, "strict", z.ZodTypeAny, {
2472
2489
  from: string;
2473
2490
  to: string;
2474
- when?: string | undefined;
2491
+ when?: string | {
2492
+ expr: string;
2493
+ } | undefined;
2475
2494
  max_iterations?: number | undefined;
2476
2495
  }, {
2477
2496
  from: string;
2478
2497
  to: string;
2479
- when?: string | undefined;
2498
+ when?: string | {
2499
+ expr: string;
2500
+ } | undefined;
2480
2501
  max_iterations?: number | undefined;
2481
2502
  }>, "many">;
2482
2503
  entry: z.ZodString;
@@ -2846,7 +2867,9 @@ export declare const workflowZ: z.ZodObject<{
2846
2867
  edges: {
2847
2868
  from: string;
2848
2869
  to: string;
2849
- when?: string | undefined;
2870
+ when?: string | {
2871
+ expr: string;
2872
+ } | undefined;
2850
2873
  max_iterations?: number | undefined;
2851
2874
  }[];
2852
2875
  entry: string;
@@ -3021,7 +3044,9 @@ export declare const workflowZ: z.ZodObject<{
3021
3044
  edges: {
3022
3045
  from: string;
3023
3046
  to: string;
3024
- when?: string | undefined;
3047
+ when?: string | {
3048
+ expr: string;
3049
+ } | undefined;
3025
3050
  max_iterations?: number | undefined;
3026
3051
  }[];
3027
3052
  entry: string;
@@ -3238,7 +3263,9 @@ export declare function parseWorkflow(raw: unknown): {
3238
3263
  edges: {
3239
3264
  from: string;
3240
3265
  to: string;
3241
- when?: string | undefined;
3266
+ when?: string | {
3267
+ expr: string;
3268
+ } | undefined;
3242
3269
  max_iterations?: number | undefined;
3243
3270
  }[];
3244
3271
  entry: string;
@@ -3290,7 +3317,7 @@ export declare function parseWorkflow(raw: unknown): {
3290
3317
  } | undefined;
3291
3318
  };
3292
3319
  export interface WorkflowError {
3293
- code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE" | "AMBIGUOUS_EDGES" | "UNSUPPORTED_EVAL_POLICY" | "INVALID_INLINE_SKILL" | "EDGE_ITERATIONS_EXCEEDED" | "RETRY_MAX_EXCEEDED" | "PERMISSION_CEILING" | "BUDGET_CEILING" | "OUTPUT_NOT_ALLOWED" | "DUPLICATE_OUTPUT" | "UNSUPPORTED_OUTPUT";
3320
+ code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE" | "AMBIGUOUS_EDGES" | "UNSUPPORTED_EVAL_POLICY" | "INVALID_INLINE_SKILL" | "EDGE_ITERATIONS_EXCEEDED" | "RETRY_MAX_EXCEEDED" | "PERMISSION_CEILING" | "BUDGET_CEILING" | "OUTPUT_NOT_ALLOWED" | "DUPLICATE_OUTPUT" | "UNSUPPORTED_OUTPUT" | "INVALID_WHEN_EXPRESSION" | "MIXED_EDGE_CONDITIONS";
3294
3321
  message: string;
3295
3322
  nodeId?: string;
3296
3323
  }
@@ -4099,8 +4126,22 @@ export declare const workflowJsonSchema: {
4099
4126
  readonly minLength: 1;
4100
4127
  };
4101
4128
  readonly when: {
4102
- readonly type: "string";
4103
- readonly description: "Natural language condition, evaluated at runtime by the workflow's harness.";
4129
+ readonly description: "Condition for taking this edge. A string is natural language, evaluated at runtime by the workflow's harness. An object { expr } is a deterministic expression over prior nodes' declared output fields, evaluated by sweny with no model call.";
4130
+ readonly oneOf: readonly [{
4131
+ readonly type: "string";
4132
+ }, {
4133
+ readonly type: "object";
4134
+ readonly required: readonly ["expr"];
4135
+ readonly additionalProperties: false;
4136
+ readonly properties: {
4137
+ readonly expr: {
4138
+ readonly type: "string";
4139
+ readonly minLength: 1;
4140
+ readonly maxLength: 2000;
4141
+ readonly description: "Boolean expression: == != < <= > >= in, && || !, exists, string/number/boolean/null literals, and node.field paths to declared output fields.";
4142
+ };
4143
+ };
4144
+ }];
4104
4145
  };
4105
4146
  readonly max_iterations: {
4106
4147
  readonly type: "integer";
package/dist/schema.js CHANGED
@@ -10,6 +10,9 @@ import { z } from "zod";
10
10
  import { AUTHOR_ASSOCIATIONS, CONTEXT_MODES, EVALUATOR_KINDS, EVAL_POLICIES, MCP_TRANSPORTS, NODE_ACCESS, NODE_ON_FAIL, REQUIRES_ON_FAIL, SAFE_OUTPUT_APPLIERS, SAFE_OUTPUT_EXPIRES_PATTERN, SAFE_OUTPUT_MAX_CEILING, SAFE_OUTPUT_STATES, SAFE_OUTPUT_TYPES, SKILL_CATEGORIES, SKILL_ID_MAX_LENGTH, SKILL_ID_PATTERN, TOOL_ACCESS, TOOL_CLASSES, WORKFLOW_TYPES, } from "./types.js";
11
11
  import { sourceZ } from "./sources.js";
12
12
  import { workflowInputsZ, WORKFLOW_INPUT_TYPES } from "./inputs.js";
13
+ import { checkExpression, isWhenExpression, WHEN_EXPR_MAX_LENGTH } from "./when.js";
14
+ // Studio and other schema-only consumers label edges without importing the root entry.
15
+ export { isWhenExpression, whenLabel } from "./when.js";
13
16
  export { sourceZ };
14
17
  export { workflowInputsZ };
15
18
  // ─── Zod Schemas ─────────────────────────────────────────────────
@@ -330,7 +333,9 @@ export const edgeZ = z
330
333
  .object({
331
334
  from: z.string().min(1),
332
335
  to: z.string().min(1),
333
- when: z.string().optional(),
336
+ // Natural language (the harness picks the edge) or `{ expr }`, a
337
+ // deterministic expression sweny evaluates itself (#461). One or the other.
338
+ when: z.union([z.string(), z.object({ expr: z.string().min(1).max(WHEN_EXPR_MAX_LENGTH) }).strict()]).optional(),
334
339
  max_iterations: z.number().int().min(1).max(EDGE_MAX_ITERATIONS_CEILING).optional(),
335
340
  })
336
341
  .strict();
@@ -507,6 +512,68 @@ export function validateWorkflow(workflow, knownSkills) {
507
512
  });
508
513
  }
509
514
  }
515
+ // Deterministic `when` expressions (#461). Every path must name a declared
516
+ // output field of the edge's source node or one of its ancestors, so a typo
517
+ // is a load error rather than a branch that never fires. A node routes on
518
+ // expressions or on natural language, never both: a mixed node would hand
519
+ // the expression edges to the model and lose the determinism the author
520
+ // asked for.
521
+ const ancestorsCache = new Map();
522
+ const inAll = new Map();
523
+ for (const [from, tos] of outAll)
524
+ for (const to of tos)
525
+ push(inAll, to, from);
526
+ const ancestorsOf = (id) => {
527
+ let seen = ancestorsCache.get(id);
528
+ if (seen)
529
+ return seen;
530
+ seen = new Set([id]);
531
+ const queue = [id];
532
+ for (let i = 0; i < queue.length; i++) {
533
+ for (const from of inAll.get(queue[i]) ?? []) {
534
+ if (!seen.has(from)) {
535
+ seen.add(from);
536
+ queue.push(from);
537
+ }
538
+ }
539
+ }
540
+ ancestorsCache.set(id, seen);
541
+ return seen;
542
+ };
543
+ const outputs = Object.fromEntries(Object.entries(workflow.nodes).map(([id, n]) => [id, n.output]));
544
+ const conditionKinds = new Map();
545
+ for (const edge of workflow.edges) {
546
+ if (!nodeIds.has(edge.from) || !edge.when)
547
+ continue;
548
+ const kinds = conditionKinds.get(edge.from) ?? { expr: 0, nl: 0 };
549
+ conditionKinds.set(edge.from, kinds);
550
+ if (!isWhenExpression(edge.when)) {
551
+ kinds.nl++;
552
+ continue;
553
+ }
554
+ kinds.expr++;
555
+ const problems = checkExpression(edge.when.expr, {
556
+ from: edge.from,
557
+ ancestors: ancestorsOf(edge.from),
558
+ outputs,
559
+ });
560
+ for (const problem of problems) {
561
+ errors.push({
562
+ code: "INVALID_WHEN_EXPRESSION",
563
+ message: `Edge "${edge.from}" -> "${edge.to}": ${problem}`,
564
+ nodeId: edge.from,
565
+ });
566
+ }
567
+ }
568
+ for (const [from, kinds] of conditionKinds) {
569
+ if (kinds.expr > 0 && kinds.nl > 0) {
570
+ errors.push({
571
+ code: "MIXED_EDGE_CONDITIONS",
572
+ message: `Node "${from}" mixes expression and natural-language 'when' conditions on its out-edges; use one kind per node (an unconditional default edge is fine with either)`,
573
+ nodeId: from,
574
+ });
575
+ }
576
+ }
510
577
  // Reachability: BFS from entry. Skipped only when the entry is missing
511
578
  // (every node would be reported, which is noise). All other diagnostics
512
579
  // accumulate so one pass reports every structural problem.
@@ -1299,8 +1366,23 @@ export const workflowJsonSchema = {
1299
1366
  from: { type: "string", minLength: 1 },
1300
1367
  to: { type: "string", minLength: 1 },
1301
1368
  when: {
1302
- type: "string",
1303
- description: "Natural language condition, evaluated at runtime by the workflow's harness.",
1369
+ description: "Condition for taking this edge. A string is natural language, evaluated at runtime by the workflow's harness. An object { expr } is a deterministic expression over prior nodes' declared output fields, evaluated by sweny with no model call.",
1370
+ oneOf: [
1371
+ { type: "string" },
1372
+ {
1373
+ type: "object",
1374
+ required: ["expr"],
1375
+ additionalProperties: false,
1376
+ properties: {
1377
+ expr: {
1378
+ type: "string",
1379
+ minLength: 1,
1380
+ maxLength: WHEN_EXPR_MAX_LENGTH,
1381
+ description: "Boolean expression: == != < <= > >= in, && || !, exists, string/number/boolean/null literals, and node.field paths to declared output fields.",
1382
+ },
1383
+ },
1384
+ },
1385
+ ],
1304
1386
  },
1305
1387
  max_iterations: {
1306
1388
  type: "integer",
package/dist/studio.d.ts CHANGED
@@ -38,7 +38,7 @@
38
38
  * directly to node highlights. node:enter → glow blue,
39
39
  * tool:call → show tool name, node:exit → green/red.
40
40
  */
41
- import type { Workflow, Node, ExecutionEvent, NodeResult, Skill } from "./types.js";
41
+ import type { Workflow, Node, Edge, ExecutionEvent, NodeResult, Skill } from "./types.js";
42
42
  export interface FlowNode {
43
43
  id: string;
44
44
  type: "skillNode";
@@ -74,7 +74,7 @@ export interface FlowEdge {
74
74
  /** Array index of this edge in workflow.edges — used for stable lookups. */
75
75
  edgeIndex: number;
76
76
  data: {
77
- when?: string;
77
+ when?: Edge["when"];
78
78
  max_iterations?: number;
79
79
  isConditional: boolean;
80
80
  };
package/dist/types.d.ts CHANGED
@@ -489,12 +489,26 @@ export interface NodeToolFilter {
489
489
  /** Skill tool names removed from the node (applied after `allow`). */
490
490
  deny?: string[];
491
491
  }
492
+ /**
493
+ * A deterministic edge condition (#461): a boolean expression over prior
494
+ * nodes' declared output fields, evaluated by sweny with no model call.
495
+ * Grammar and semantics: `when.ts`.
496
+ */
497
+ export interface WhenExpression {
498
+ expr: string;
499
+ }
500
+ /** An edge condition: natural language (model-evaluated) or a `{ expr }` expression. */
501
+ export type EdgeWhen = string | WhenExpression;
492
502
  /** An edge connecting two nodes */
493
503
  export interface Edge {
494
504
  from: string;
495
505
  to: string;
496
- /** Natural language condition, evaluated at runtime by the workflow's harness. */
497
- when?: string;
506
+ /**
507
+ * Condition for taking this edge. A string is natural language, evaluated at
508
+ * runtime by the workflow's harness. `{ expr }` is a deterministic expression
509
+ * evaluated by sweny with no model call.
510
+ */
511
+ when?: EdgeWhen;
498
512
  /** Max times this edge can be followed (enables retry loops). Default: unlimited. */
499
513
  max_iterations?: number;
500
514
  }
package/dist/when.d.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Deterministic `when` expressions (#461).
3
+ *
4
+ * An edge's `when` is either natural language (a model picks the edge at
5
+ * runtime) or `{ expr: "..." }`, a small boolean expression over prior nodes'
6
+ * declared output fields that sweny evaluates itself, with no model call.
7
+ *
8
+ * Grammar (precedence low to high):
9
+ *
10
+ * expr := or
11
+ * or := and ( "||" and )*
12
+ * and := unary ( "&&" unary )*
13
+ * unary := "!" unary | comparison
14
+ * comparison := operand ( ( "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" ) operand )?
15
+ * operand := literal | list | path | "exists" path | "(" expr ")"
16
+ * list := "[" ( literal ( "," literal )* )? "]"
17
+ * literal := number | 'string' | "string" | true | false | null
18
+ * path := ident ( "." ident )+ (node id, then field, then nested fields)
19
+ *
20
+ * Semantics are strict and total. There is no coercion and no truthiness:
21
+ * `&&`, `||`, `!` and the expression itself need booleans; `<` and friends
22
+ * need two numbers or two strings; `in` needs a list on the right. A missing
23
+ * field (undefined, or a node that did not run or did not succeed) or a type
24
+ * mismatch makes the WHOLE expression false and is reported, so a negation
25
+ * can never turn a missing field into a silent true. `exists path` is the
26
+ * one explicit presence test: true when the value is present and not null,
27
+ * never a warning. `&&` and `||` short-circuit, so `exists a.x && a.x > 0`
28
+ * is safe.
29
+ *
30
+ * Pure module: no I/O, no eval, no Function, browser-safe. Strings in the
31
+ * routed data are compared as data only; nothing in them is ever parsed.
32
+ */
33
+ import type { EdgeWhen, JSONSchema, WhenExpression } from "./types.js";
34
+ /** Longest expression source accepted. Routing conditions are short. */
35
+ export declare const WHEN_EXPR_MAX_LENGTH = 2000;
36
+ /** True when an edge `when` is the structured `{ expr }` form. */
37
+ export declare function isWhenExpression(when: unknown): when is WhenExpression;
38
+ /** Human-readable label for a `when`: the NL text or the expression source. */
39
+ export declare function whenLabel(when: EdgeWhen | undefined): string | undefined;
40
+ export type Literal = string | number | boolean | null;
41
+ export type ExprNode = {
42
+ kind: "literal";
43
+ value: Literal;
44
+ } | {
45
+ kind: "list";
46
+ items: Literal[];
47
+ } | {
48
+ kind: "path";
49
+ segments: string[];
50
+ } | {
51
+ kind: "exists";
52
+ path: {
53
+ kind: "path";
54
+ segments: string[];
55
+ };
56
+ } | {
57
+ kind: "not";
58
+ operand: ExprNode;
59
+ } | {
60
+ kind: "and" | "or";
61
+ left: ExprNode;
62
+ right: ExprNode;
63
+ } | {
64
+ kind: "cmp";
65
+ op: CmpOp;
66
+ left: ExprNode;
67
+ right: ExprNode;
68
+ };
69
+ export type CmpOp = "==" | "!=" | "<" | "<=" | ">" | ">=" | "in";
70
+ export declare class ExpressionSyntaxError extends Error {
71
+ readonly position: number;
72
+ constructor(message: string, position: number);
73
+ }
74
+ /**
75
+ * Parse an expression source into an AST. Throws `ExpressionSyntaxError` on
76
+ * any syntax problem, including an empty source, an over-long source, or
77
+ * nesting deeper than the cap.
78
+ */
79
+ export declare function parseExpression(src: string): ExprNode;
80
+ /**
81
+ * Per-node data an expression may read, keyed by node id. A node that did not
82
+ * run or did not succeed is absent, so every path into it reads as missing.
83
+ */
84
+ export type ExpressionScope = Record<string, Record<string, unknown> | undefined>;
85
+ export interface ExpressionResult {
86
+ /** The edge is taken only when this is true. */
87
+ value: boolean;
88
+ /**
89
+ * Why the expression was forced false: a missing field or a type mismatch.
90
+ * Absent when it evaluated normally (to true or false).
91
+ */
92
+ problem?: string;
93
+ }
94
+ /**
95
+ * Evaluate a parsed expression against node outputs. Never throws: a missing
96
+ * field or a type mismatch yields `{ value: false, problem }`.
97
+ */
98
+ export declare function evaluateExpression(ast: ExprNode, scope: ExpressionScope): ExpressionResult;
99
+ /**
100
+ * Check an expression against the workflow it routes. Returns one message per
101
+ * problem, empty when the expression is valid:
102
+ * - the source must parse;
103
+ * - every path's node must be the edge's source or one of its ancestors;
104
+ * - that node must declare `output.properties`, and the field (and any
105
+ * nested field) must be declared there;
106
+ * - a literal compared with a declared field must fit its declared type,
107
+ * and a string literal compared with an enum field must be in the enum;
108
+ * - a field used where a boolean is needed must be declared boolean.
109
+ */
110
+ export declare function checkExpression(src: string, ctx: {
111
+ from: string;
112
+ ancestors: Set<string>;
113
+ outputs: Record<string, JSONSchema | undefined>;
114
+ }): string[];