@sweny-ai/core 0.1.161 → 0.1.163

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
@@ -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";
@@ -25,6 +26,7 @@ import { fenceUntrusted } from "./untrusted.js";
25
26
  import { asClaude } from "./harness/compat.js";
26
27
  import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
27
28
  import { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
29
+ import { createShadowDecider, finishShadowDecision, startShadowDecision } from "./decider.js";
28
30
  import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
29
31
  import { applySafeOutputs, createEmitOutputTool, createWriteStageState, resolveActor, safeOutputsInstruction, unresolvedOutputs, SAFE_OUTPUT_SCREEN_INSTRUCTION, } from "./safe-outputs.js";
30
32
  /**
@@ -87,6 +89,10 @@ export async function execute(workflow, input, options) {
87
89
  // `budget` and the caller's (`--max-tokens`, `--max-cost`).
88
90
  const budgetGuard = new BudgetGuard(minLimits(toLimits(workflow.budget), toLimits(options.budget)));
89
91
  const harnessPolicy = options.harnessPolicy ?? resolveHarnessPolicy(runEnv);
92
+ // Decision model (#357): shadow only. Null (the default) means no HTTP at all.
93
+ const shadow = createShadowDecider(workflow.decider, options.decider, runEnv, (m) => logger.warn(m));
94
+ if (shadow)
95
+ trace.decisions = shadow.records;
90
96
  // Build an eval-time alias table from the loaded skills. Each skill owns
91
97
  // its own mapping between skill-tool names and equivalent MCP names. Core
92
98
  // stays vendor-neutral; this call just unions what the skills declare.
@@ -253,7 +259,7 @@ export async function execute(workflow, input, options) {
253
259
  }
254
260
  // Apply normal routing rules (dry run gate + resolveNext).
255
261
  // TODO: dedupe with requires path — see advanceFromNode helper below
256
- const next = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, { signal, timeoutMs });
262
+ const next = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, { signal, timeoutMs, shadow });
257
263
  currentId = next;
258
264
  continue;
259
265
  }
@@ -590,6 +596,7 @@ export async function execute(workflow, input, options) {
590
596
  currentId = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, {
591
597
  signal,
592
598
  timeoutMs,
599
+ shadow,
593
600
  });
594
601
  }
595
602
  safeObserve(observer, {
@@ -1118,15 +1125,17 @@ function resolveConfig(skills, overrides, env = process.env) {
1118
1125
  * Returns the next node ID, or null when execution should stop.
1119
1126
  */
1120
1127
  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.
1128
+ // Dry run path gate: stop at the first natural-language routing decision.
1122
1129
  // 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.
1130
+ // runs read-only (see execute()). The stop keeps dry-run routing free of
1131
+ // model route evaluation, so the path is reproducible from the node outputs
1132
+ // alone. Expression edges (#461) are evaluated by sweny with no model call,
1133
+ // so dry-run follows them: a preview can reach the nodes past a
1134
+ // deterministic branch (still read-only, outputs staged) and stops only
1135
+ // where a model would have to pick the branch.
1127
1136
  if (isDryRunInput(input)) {
1128
1137
  const outEdges = workflow.edges.filter((e) => e.from === currentId);
1129
- if (outEdges.some((e) => e.when)) {
1138
+ if (outEdges.some((e) => e.when && !isWhenExpression(e.when))) {
1130
1139
  safeObserve(observer, { type: "route", from: currentId, to: "(end)", reason: "dry run" }, logger);
1131
1140
  return null;
1132
1141
  }
@@ -1134,7 +1143,7 @@ async function advanceFromNode(workflow, currentId, results, input, claude, obse
1134
1143
  const prevId = currentId;
1135
1144
  const nextId = await resolveNext(workflow, currentId, results, input, claude, observer, edgeCounts, logger, abort);
1136
1145
  if (nextId) {
1137
- const reason = workflow.edges.find((e) => e.from === prevId && e.to === nextId)?.when ?? "only path";
1146
+ const reason = whenLabel(workflow.edges.find((e) => e.from === prevId && e.to === nextId)?.when) ?? "only path";
1138
1147
  trace.edges.push({ from: prevId, to: nextId, reason });
1139
1148
  }
1140
1149
  return nextId;
@@ -1144,7 +1153,8 @@ async function advanceFromNode(workflow, currentId, results, input, claude, obse
1144
1153
  *
1145
1154
  * - 0 out-edges → terminal (return null)
1146
1155
  * - 1 unconditional edge → follow it
1147
- * - Multiple or conditional → Claude evaluates
1156
+ * - Every conditional edge is an `{ expr }` expression → sweny evaluates them, no model call
1157
+ * - Otherwise multiple or conditional → Claude evaluates
1148
1158
  *
1149
1159
  * Edges with max_iterations are filtered out once exhausted.
1150
1160
  */
@@ -1192,6 +1202,12 @@ async function resolveNext(workflow, current, results, input, claude, observer,
1192
1202
  safeObserve(observer, { type: "route", from: current, to: defaultEdge.to, reason: "only path" }, logger);
1193
1203
  return defaultEdge.to;
1194
1204
  }
1205
+ // Deterministic routing (#461): every conditional out-edge is an expression,
1206
+ // so sweny decides without a model call. validateWorkflow rejects a node
1207
+ // that mixes expression and natural-language edges.
1208
+ if (conditionalEdges.every((e) => isWhenExpression(e.when))) {
1209
+ return resolveByExpressions(workflow, current, results, conditionalEdges, defaultEdge, observer, edgeCounts, logger);
1210
+ }
1195
1211
  // Claude evaluates which condition matches. Include input so conditions
1196
1212
  // can reference workflow-level flags like dryRun, and expose evals so
1197
1213
  // routing edges can read `priorNode.evals.X.pass`.
@@ -1233,18 +1249,36 @@ async function resolveNext(workflow, current, results, input, claude, observer,
1233
1249
  }
1234
1250
  const choices = conditionalEdges.map((e) => ({
1235
1251
  id: e.to,
1236
- description: e.when,
1252
+ description: whenLabel(e.when),
1237
1253
  }));
1238
1254
  if (defaultEdge) {
1239
1255
  choices.push({ id: defaultEdge.to, description: "None of the above / default path" });
1240
1256
  }
1257
+ const question = "Based on the results so far, which condition is true?";
1258
+ // Shadow mode (#357): the decider gets the same question in parallel. Its
1259
+ // promise never rejects and its answer is only logged, so the route below
1260
+ // is the agent's in every case.
1261
+ const pending = abort?.shadow
1262
+ ? startShadowDecision(abort.shadow.provider, { question, state: context, choices, signal: abort.signal })
1263
+ : undefined;
1241
1264
  const chosen = await claude.evaluate({
1242
- question: "Based on the results so far, which condition is true?",
1265
+ question,
1243
1266
  context,
1244
1267
  choices,
1245
1268
  signal: abort?.signal,
1246
1269
  timeoutMs: abort?.timeoutMs,
1247
1270
  });
1271
+ if (pending && abort?.shadow) {
1272
+ try {
1273
+ const rec = await finishShadowDecision(pending, current, chosen);
1274
+ abort.shadow.records.push(rec);
1275
+ const verdict = rec.outcome === "compared" ? (rec.agree ? "agreed" : "disagreed") : `fell through (${rec.reason})`;
1276
+ logger?.info(` decider (shadow): node '${current}' ${verdict}`, { ...rec });
1277
+ }
1278
+ catch {
1279
+ // shadow logging must never affect a route
1280
+ }
1281
+ }
1248
1282
  // Fail closed. `evaluate` returns null when the routing decision could not
1249
1283
  // be made (SDK error, timeout, non-success subtype, or an unparseable
1250
1284
  // answer). We must NOT fall through to a conditional edge: on a node with a
@@ -1306,6 +1340,76 @@ async function resolveNext(workflow, current, results, input, claude, observer,
1306
1340
  }, logger);
1307
1341
  return resolved;
1308
1342
  }
1343
+ /**
1344
+ * Pick the out-edge whose `{ expr }` condition is true, with no model call
1345
+ * (#461). Exactly one true edge is taken. None true takes the unconditional
1346
+ * default edge when there is one. Otherwise (no edge true and no default, or
1347
+ * two or more true) it fails closed with a RouteEvaluationError, the same
1348
+ * contract as a failed model route evaluation: sweny never guesses an edge.
1349
+ *
1350
+ * An expression reads only successful nodes' outputs. A missing field, or a
1351
+ * node that did not run or did not succeed, makes that expression false and
1352
+ * is logged as a warning, never a silent true.
1353
+ */
1354
+ function resolveByExpressions(workflow, current, results, conditionalEdges, defaultEdge, observer, edgeCounts, logger) {
1355
+ const scope = {};
1356
+ for (const [id, r] of results.entries()) {
1357
+ if (r.status === "success")
1358
+ scope[id] = buildPriorNodeContext(r);
1359
+ }
1360
+ const matched = [];
1361
+ for (const edge of conditionalEdges) {
1362
+ const expr = whenLabel(edge.when);
1363
+ let outcome;
1364
+ try {
1365
+ outcome = evaluateExpression(parseExpression(expr), scope);
1366
+ }
1367
+ catch (err) {
1368
+ // validateWorkflow parses every expression before a run, so this only
1369
+ // fires for a workflow that skipped validation. Fail closed.
1370
+ const msg = err instanceof Error ? err.message : String(err);
1371
+ safeObserve(observer, { type: "route", from: current, to: "(end)", reason: `invalid when expression` }, logger);
1372
+ throw new RouteEvaluationError(current, `invalid when expression on edge '${current}' -> '${edge.to}': ${msg}`);
1373
+ }
1374
+ if (outcome.problem) {
1375
+ logger?.warn(` route expr: '${current}' -> '${edge.to}' is false: ${outcome.problem} (${expr})`, {
1376
+ node: current,
1377
+ to: edge.to,
1378
+ });
1379
+ safeObserve(observer, {
1380
+ type: "node:warning",
1381
+ node: current,
1382
+ reason: `when expression on edge to '${edge.to}' evaluated false: ${outcome.problem}`,
1383
+ fields: [],
1384
+ }, logger);
1385
+ }
1386
+ if (outcome.value)
1387
+ matched.push({ to: edge.to, expr });
1388
+ }
1389
+ const take = (to, reason) => {
1390
+ if (edgeCounts) {
1391
+ const key = `${current}→${to}`;
1392
+ edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
1393
+ }
1394
+ safeObserve(observer, { type: "route", from: current, to, reason }, logger);
1395
+ return to;
1396
+ };
1397
+ if (matched.length === 1)
1398
+ return take(matched[0].to, matched[0].expr);
1399
+ if (matched.length === 0 && defaultEdge) {
1400
+ logger?.info(` route expr: no expression matched for '${current}'; taking default edge '${defaultEdge.to}'`, {
1401
+ node: current,
1402
+ });
1403
+ return take(defaultEdge.to, "no expression matched; default edge");
1404
+ }
1405
+ const why = matched.length === 0
1406
+ ? `no when expression on node '${current}' is true and there is no default (unconditional) edge`
1407
+ : `${matched.length} when expressions on node '${current}' are true (to ${matched.map((m) => m.to).join(", ")}); ` +
1408
+ `exactly one may match`;
1409
+ logger?.error(` route expr: ${why}; refusing to guess. Terminating the run.`, { node: current });
1410
+ safeObserve(observer, { type: "route", from: current, to: "(end)", reason: why }, logger);
1411
+ throw new RouteEvaluationError(current, `${why}; refusing to guess`);
1412
+ }
1309
1413
  /**
1310
1414
  * Validate a workflow definition before execution.
1311
1415
  */
package/dist/index.d.ts CHANGED
@@ -21,12 +21,14 @@
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";
28
28
  export { execute, RouteEvaluationError } from "./executor.js";
29
29
  export type { ExecuteOptions } from "./executor.js";
30
+ export { systemOneProvider, DecideError, gateVerdict, DECIDER_MIN_CONFIDENCE, DECIDER_MIN_MARGIN } from "./decider.js";
31
+ export type { DecisionProvider, DeciderConfig, DeciderMode, DeciderRecord, SystemOneOptions } from "./decider.js";
30
32
  export { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
31
33
  export type { Budget, BudgetOverrun, BudgetUnit, SpendLimits } from "./budget.js";
32
34
  export { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
@@ -43,6 +45,8 @@ export type { SkillValidationResult } from "./skills/index.js";
43
45
  export { loadCustomSkills, discoverSkills, configuredSkills } from "./skills/custom-loader.js";
44
46
  export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
45
47
  export type { WorkflowError } from "./schema.js";
48
+ export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
49
+ export type { ExprNode, ExpressionScope, ExpressionResult } from "./when.js";
46
50
  export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
47
51
  export type { LoaderResult, LoaderError, LoaderOptions } from "./loader.js";
48
52
  export { CURRENT_SPEC_VERSION, MIGRATIONS, migrateWorkflow } from "./migrations.js";
package/dist/index.js CHANGED
@@ -27,6 +27,8 @@ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRA
27
27
  export { consoleLogger } from "./types.js";
28
28
  // Executor
29
29
  export { execute, RouteEvaluationError } from "./executor.js";
30
+ // Decision models, shadow mode (#357)
31
+ export { systemOneProvider, DecideError, gateVerdict, DECIDER_MIN_CONFIDENCE, DECIDER_MIN_MARGIN } from "./decider.js";
30
32
  // Least-privilege nodes and safe outputs (#365)
31
33
  export { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
32
34
  export { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
@@ -43,6 +45,8 @@ export { github, linear, slack, sentry, datadog, notification, supabase, builtin
43
45
  export { loadCustomSkills, discoverSkills, configuredSkills } from "./skills/custom-loader.js";
44
46
  // Schema & validation
45
47
  export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
48
+ // Deterministic `when` expressions (#461). Pure, browser-safe.
49
+ export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
46
50
  // Loader — canonical read → parse → structural-validate pipeline (the path
47
51
  // the CLI uses for `workflow run`, `workflow validate`, and `publish`).
48
52
  // 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