@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/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 ─────────────────────────────────────────────────
@@ -275,6 +278,27 @@ export const budgetZ = z
275
278
  .refine((b) => b.tokens !== undefined || b.cost_usd !== undefined, {
276
279
  message: "budget must declare at least one of tokens, cost_usd",
277
280
  });
281
+ /**
282
+ * Decision model for route choices (#357). `shadow` needs a provider; the
283
+ * state only ever goes to that `base_url`, there is no fallback. The key is
284
+ * named by env var, never inlined.
285
+ */
286
+ export const deciderZ = z
287
+ .object({
288
+ mode: z.enum(["off", "shadow"]),
289
+ provider: z
290
+ .object({
291
+ base_url: z.string().url(),
292
+ model: z.string().min(1),
293
+ api_key_env: z.string().min(1).optional(),
294
+ })
295
+ .strict()
296
+ .optional(),
297
+ })
298
+ .strict()
299
+ .refine((d) => d.mode === "off" || d.provider !== undefined, {
300
+ message: "decider.provider is required when mode is shadow",
301
+ });
278
302
  /** One typed write intent a node may emit (#365). */
279
303
  export const safeOutputDeclarationZ = z
280
304
  .object({
@@ -330,7 +354,9 @@ export const edgeZ = z
330
354
  .object({
331
355
  from: z.string().min(1),
332
356
  to: z.string().min(1),
333
- when: z.string().optional(),
357
+ // Natural language (the harness picks the edge) or `{ expr }`, a
358
+ // deterministic expression sweny evaluates itself (#461). One or the other.
359
+ when: z.union([z.string(), z.object({ expr: z.string().min(1).max(WHEN_EXPR_MAX_LENGTH) }).strict()]).optional(),
334
360
  max_iterations: z.number().int().min(1).max(EDGE_MAX_ITERATIONS_CEILING).optional(),
335
361
  })
336
362
  .strict();
@@ -372,6 +398,7 @@ export const workflowZ = z.object({
372
398
  model: z.string().min(1).optional(),
373
399
  judge_budget: z.number().int().min(0).optional(),
374
400
  budget: budgetZ.optional(),
401
+ decider: deciderZ.optional(),
375
402
  context_mode: z.enum(CONTEXT_MODES).optional(),
376
403
  inputs: workflowInputsZ.optional(),
377
404
  permissions: nodePermissionsZ.optional(),
@@ -507,6 +534,68 @@ export function validateWorkflow(workflow, knownSkills) {
507
534
  });
508
535
  }
509
536
  }
537
+ // Deterministic `when` expressions (#461). Every path must name a declared
538
+ // output field of the edge's source node or one of its ancestors, so a typo
539
+ // is a load error rather than a branch that never fires. A node routes on
540
+ // expressions or on natural language, never both: a mixed node would hand
541
+ // the expression edges to the model and lose the determinism the author
542
+ // asked for.
543
+ const ancestorsCache = new Map();
544
+ const inAll = new Map();
545
+ for (const [from, tos] of outAll)
546
+ for (const to of tos)
547
+ push(inAll, to, from);
548
+ const ancestorsOf = (id) => {
549
+ let seen = ancestorsCache.get(id);
550
+ if (seen)
551
+ return seen;
552
+ seen = new Set([id]);
553
+ const queue = [id];
554
+ for (let i = 0; i < queue.length; i++) {
555
+ for (const from of inAll.get(queue[i]) ?? []) {
556
+ if (!seen.has(from)) {
557
+ seen.add(from);
558
+ queue.push(from);
559
+ }
560
+ }
561
+ }
562
+ ancestorsCache.set(id, seen);
563
+ return seen;
564
+ };
565
+ const outputs = Object.fromEntries(Object.entries(workflow.nodes).map(([id, n]) => [id, n.output]));
566
+ const conditionKinds = new Map();
567
+ for (const edge of workflow.edges) {
568
+ if (!nodeIds.has(edge.from) || !edge.when)
569
+ continue;
570
+ const kinds = conditionKinds.get(edge.from) ?? { expr: 0, nl: 0 };
571
+ conditionKinds.set(edge.from, kinds);
572
+ if (!isWhenExpression(edge.when)) {
573
+ kinds.nl++;
574
+ continue;
575
+ }
576
+ kinds.expr++;
577
+ const problems = checkExpression(edge.when.expr, {
578
+ from: edge.from,
579
+ ancestors: ancestorsOf(edge.from),
580
+ outputs,
581
+ });
582
+ for (const problem of problems) {
583
+ errors.push({
584
+ code: "INVALID_WHEN_EXPRESSION",
585
+ message: `Edge "${edge.from}" -> "${edge.to}": ${problem}`,
586
+ nodeId: edge.from,
587
+ });
588
+ }
589
+ }
590
+ for (const [from, kinds] of conditionKinds) {
591
+ if (kinds.expr > 0 && kinds.nl > 0) {
592
+ errors.push({
593
+ code: "MIXED_EDGE_CONDITIONS",
594
+ 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)`,
595
+ nodeId: from,
596
+ });
597
+ }
598
+ }
510
599
  // Reachability: BFS from entry. Skipped only when the entry is missing
511
600
  // (every node would be reported, which is noise). All other diagnostics
512
601
  // accumulate so one pass reports every structural problem.
@@ -894,6 +983,31 @@ export const workflowJsonSchema = {
894
983
  cost_usd: { type: "number", exclusiveMinimum: 0, description: "Max reported cost in USD." },
895
984
  },
896
985
  },
986
+ Decider: {
987
+ type: "object",
988
+ required: ["mode"],
989
+ additionalProperties: false,
990
+ description: "Decision model for route choices. 'shadow' asks it alongside the agent and logs whether they agree; the route is always the agent's. The workflow state goes only to provider.base_url, with no fallback.",
991
+ properties: {
992
+ mode: { type: "string", enum: ["off", "shadow"] },
993
+ provider: {
994
+ type: "object",
995
+ required: ["base_url", "model"],
996
+ additionalProperties: false,
997
+ properties: {
998
+ base_url: {
999
+ type: "string",
1000
+ format: "uri",
1001
+ description: "Server root serving POST /v1/systemone (Ollama or TypeSafe Jev).",
1002
+ },
1003
+ model: { type: "string", minLength: 1 },
1004
+ api_key_env: { type: "string", minLength: 1, description: "Env var holding the bearer key." },
1005
+ },
1006
+ },
1007
+ },
1008
+ if: { properties: { mode: { const: "shadow" } } },
1009
+ then: { required: ["provider"] },
1010
+ },
897
1011
  SafeOutput: {
898
1012
  type: "object",
899
1013
  required: ["type"],
@@ -1043,6 +1157,10 @@ export const workflowJsonSchema = {
1043
1157
  $ref: "#/$defs/Budget",
1044
1158
  description: "Spend ceiling for the whole run and for every node. A node's own budget may only narrow it. The CLI's --max-tokens and --max-cost tighten it further.",
1045
1159
  },
1160
+ decider: {
1161
+ $ref: "#/$defs/Decider",
1162
+ description: "Decision model for route choices (shadow mode only). Default: off.",
1163
+ },
1046
1164
  context_mode: {
1047
1165
  type: "string",
1048
1166
  enum: [...CONTEXT_MODES],
@@ -1299,8 +1417,23 @@ export const workflowJsonSchema = {
1299
1417
  from: { type: "string", minLength: 1 },
1300
1418
  to: { type: "string", minLength: 1 },
1301
1419
  when: {
1302
- type: "string",
1303
- description: "Natural language condition, evaluated at runtime by the workflow's harness.",
1420
+ 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.",
1421
+ oneOf: [
1422
+ { type: "string" },
1423
+ {
1424
+ type: "object",
1425
+ required: ["expr"],
1426
+ additionalProperties: false,
1427
+ properties: {
1428
+ expr: {
1429
+ type: "string",
1430
+ minLength: 1,
1431
+ maxLength: WHEN_EXPR_MAX_LENGTH,
1432
+ description: "Boolean expression: == != < <= > >= in, && || !, exists, string/number/boolean/null literals, and node.field paths to declared output fields.",
1433
+ },
1434
+ },
1435
+ },
1436
+ ],
1304
1437
  },
1305
1438
  max_iterations: {
1306
1439
  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
@@ -4,6 +4,7 @@ export { WORKFLOW_INPUT_TYPES } from "./inputs.js";
4
4
  import type { Source as _Source, ResolvedSource as _ResolvedSource } from "./sources.js";
5
5
  import type { ToolClass } from "./harness/types.js";
6
6
  import type { Budget, BudgetOverrun } from "./budget.js";
7
+ import type { DeciderConfig, DeciderRecord } from "./decider.js";
7
8
  export type JSONSchema = Record<string, unknown>;
8
9
  /** Context passed to tool handlers at execution time */
9
10
  export interface ToolContext {
@@ -489,12 +490,26 @@ export interface NodeToolFilter {
489
490
  /** Skill tool names removed from the node (applied after `allow`). */
490
491
  deny?: string[];
491
492
  }
493
+ /**
494
+ * A deterministic edge condition (#461): a boolean expression over prior
495
+ * nodes' declared output fields, evaluated by sweny with no model call.
496
+ * Grammar and semantics: `when.ts`.
497
+ */
498
+ export interface WhenExpression {
499
+ expr: string;
500
+ }
501
+ /** An edge condition: natural language (model-evaluated) or a `{ expr }` expression. */
502
+ export type EdgeWhen = string | WhenExpression;
492
503
  /** An edge connecting two nodes */
493
504
  export interface Edge {
494
505
  from: string;
495
506
  to: string;
496
- /** Natural language condition, evaluated at runtime by the workflow's harness. */
497
- when?: string;
507
+ /**
508
+ * Condition for taking this edge. A string is natural language, evaluated at
509
+ * runtime by the workflow's harness. `{ expr }` is a deterministic expression
510
+ * evaluated by sweny with no model call.
511
+ */
512
+ when?: EdgeWhen;
498
513
  /** Max times this edge can be followed (enables retry loops). Default: unlimited. */
499
514
  max_iterations?: number;
500
515
  }
@@ -537,6 +552,11 @@ export interface Workflow {
537
552
  * `budget` may only narrow it. The CLI's `--max-tokens` / `--max-cost` tighten it.
538
553
  */
539
554
  budget?: Budget;
555
+ /**
556
+ * Decision model for route choices (#357). `shadow` asks it alongside the
557
+ * agent and logs agreement; the route is always the agent's. Default: off.
558
+ */
559
+ decider?: DeciderConfig;
540
560
  /**
541
561
  * What prior results a node's prompt receives (#337). `bounded` (default):
542
562
  * only nodes it can depend on (graph ancestors, nodes named by `requires`
@@ -751,6 +771,8 @@ export interface ExecutionTrace {
751
771
  edges: TraceEdge[];
752
772
  /** Resolved sources keyed by field path (e.g. "nodes.gather.instruction") */
753
773
  sources: Record<string, _ResolvedSource>;
774
+ /** Shadow-mode decider records, one per route decision (#357). Metadata only. Absent when the decider is off. */
775
+ decisions?: DeciderRecord[];
754
776
  }
755
777
  /** Result of execute() — final node results + full execution trace */
756
778
  export interface ExecutionResult {
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[];