@sweny-ai/core 0.1.177 → 0.1.179

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
@@ -7,7 +7,7 @@
7
7
  * parsing untrusted input (JSON/YAML import, Studio editor).
8
8
  */
9
9
  import { z } from "zod";
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";
10
+ import { AUTHOR_ASSOCIATIONS, CONTEXT_MODES, DECIDER_CONFIDENCE_FLOOR, DECIDER_MARGIN_FLOOR, DECIDER_MIN_CONFIDENCE, DECIDER_MIN_MARGIN, DECIDER_MODE_REMOVED, DECIDER_PROVIDER_MOVED, EVALUATOR_KINDS, EVAL_POLICIES, MCP_TRANSPORTS, NODE_ACCESS, NODE_ON_FAIL, REQUIRES_ON_FAIL, ROUTE_BY, 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 { resolveNodePermissions } from "./node-policy.js";
13
13
  import { workflowInputsZ, WORKFLOW_INPUT_TYPES } from "./inputs.js";
@@ -280,26 +280,22 @@ export const budgetZ = z
280
280
  message: "budget must declare at least one of tokens, cost_usd",
281
281
  });
282
282
  /**
283
- * Decision model for route choices (#357). `shadow` needs a provider; the
284
- * state only ever goes to that `base_url`, there is no fallback. The key is
285
- * named by env var, never inlined.
283
+ * Decision model thresholds (#357) for the nodes that set `route_by:
284
+ * decider`. Thresholds may be raised, and lowered no further than the floors.
285
+ * The provider (endpoint, model, key) comes only from operator config.
286
+ *
287
+ * Shapes from the shadow-mode release fail with an explanation instead of
288
+ * silently changing routing: `mode` ({@link DECIDER_MODE_REMOVED}) and
289
+ * `provider` ({@link DECIDER_PROVIDER_MOVED}).
286
290
  */
287
291
  export const deciderZ = z
288
292
  .object({
289
- mode: z.enum(["off", "shadow"]),
290
- provider: z
291
- .object({
292
- base_url: z.string().url(),
293
- model: z.string().min(1),
294
- api_key_env: z.string().min(1).optional(),
295
- })
296
- .strict()
297
- .optional(),
293
+ mode: z.custom(() => false, { message: DECIDER_MODE_REMOVED }).optional(),
294
+ provider: z.custom(() => false, { message: DECIDER_PROVIDER_MOVED }).optional(),
295
+ min_confidence: z.number().min(DECIDER_CONFIDENCE_FLOOR).max(1).optional(),
296
+ min_margin: z.number().min(DECIDER_MARGIN_FLOOR).max(1).optional(),
298
297
  })
299
- .strict()
300
- .refine((d) => d.mode === "off" || d.provider !== undefined, {
301
- message: "decider.provider is required when mode is shadow",
302
- });
298
+ .strict();
303
299
  /** One typed write intent a node may emit (#365). */
304
300
  export const safeOutputDeclarationZ = z
305
301
  .object({
@@ -342,6 +338,7 @@ export const nodeZ = z
342
338
  tools: nodeToolsZ.optional(),
343
339
  fail_soft: z.boolean().optional(),
344
340
  on_fail: z.enum(NODE_ON_FAIL).optional(),
341
+ route_by: z.enum(ROUTE_BY).optional(),
345
342
  permissions: nodePermissionsZ.optional(),
346
343
  outputs: z.array(safeOutputDeclarationZ).min(1).optional(),
347
344
  rules: nodeSourcesZ.optional(),
@@ -361,6 +358,7 @@ export const edgeZ = z
361
358
  // Natural language (the harness picks the edge) or `{ expr }`, a
362
359
  // deterministic expression sweny evaluates itself (#461). One or the other.
363
360
  when: z.union([z.string(), z.object({ expr: z.string().min(1).max(WHEN_EXPR_MAX_LENGTH) }).strict()]).optional(),
361
+ description: z.string().min(1).optional(),
364
362
  max_iterations: z.number().int().min(1).max(EDGE_MAX_ITERATIONS_CEILING).optional(),
365
363
  })
366
364
  .strict();
@@ -478,6 +476,22 @@ export function validateWorkflow(workflow, knownSkills) {
478
476
  else
479
477
  m.set(k, [v]);
480
478
  };
479
+ // One edge per (from, to) pair (#357). A route picks a target node, so two
480
+ // edges to the same target would make their conditions ambiguous (which
481
+ // one's max_iterations counts, which one's condition the router saw). Write
482
+ // one edge whose condition covers both cases instead.
483
+ const seenPairs = new Set();
484
+ for (const edge of workflow.edges) {
485
+ const key = JSON.stringify([edge.from, edge.to]);
486
+ if (seenPairs.has(key)) {
487
+ errors.push({
488
+ code: "DUPLICATE_EDGE",
489
+ message: `More than one edge from "${edge.from}" to "${edge.to}"; merge them into one edge (combine the conditions with OR)`,
490
+ nodeId: edge.from,
491
+ });
492
+ }
493
+ seenPairs.add(key);
494
+ }
481
495
  // Edge targets must exist
482
496
  for (const edge of workflow.edges) {
483
497
  if (!nodeIds.has(edge.from)) {
@@ -1000,28 +1014,24 @@ export const workflowJsonSchema = {
1000
1014
  },
1001
1015
  Decider: {
1002
1016
  type: "object",
1003
- required: ["mode"],
1004
1017
  additionalProperties: false,
1005
- 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.",
1018
+ description: "Decision model thresholds for the nodes that set route_by: decider. The provider (URL, model, key) comes only from operator config (.sweny.yml or SWENY_DECIDER_* env), never from the workflow; 'provider' and the removed 'mode' key are rejected.",
1006
1019
  properties: {
1007
- mode: { type: "string", enum: ["off", "shadow"] },
1008
- provider: {
1009
- type: "object",
1010
- required: ["base_url", "model"],
1011
- additionalProperties: false,
1012
- properties: {
1013
- base_url: {
1014
- type: "string",
1015
- format: "uri",
1016
- description: "Server root serving POST /v1/systemone (Ollama or TypeSafe Jev).",
1017
- },
1018
- model: { type: "string", minLength: 1 },
1019
- api_key_env: { type: "string", minLength: 1, description: "Env var holding the bearer key." },
1020
- },
1020
+ min_confidence: {
1021
+ type: "number",
1022
+ minimum: DECIDER_CONFIDENCE_FLOOR,
1023
+ maximum: 1,
1024
+ default: DECIDER_MIN_CONFIDENCE,
1025
+ description: "Accept the decider's answer only at or above this confidence.",
1026
+ },
1027
+ min_margin: {
1028
+ type: "number",
1029
+ minimum: DECIDER_MARGIN_FLOOR,
1030
+ maximum: 1,
1031
+ default: DECIDER_MIN_MARGIN,
1032
+ description: "Accept it only when the top label leads the runner-up by at least this much.",
1021
1033
  },
1022
1034
  },
1023
- if: { properties: { mode: { const: "shadow" } } },
1024
- then: { required: ["provider"] },
1025
1035
  },
1026
1036
  SafeOutput: {
1027
1037
  type: "object",
@@ -1174,7 +1184,7 @@ export const workflowJsonSchema = {
1174
1184
  },
1175
1185
  decider: {
1176
1186
  $ref: "#/$defs/Decider",
1177
- description: "Decision model for route choices (shadow mode only). Default: off.",
1187
+ description: "Thresholds for the decision model that decides the routes of route_by: decider nodes when confident. Default: 0.85 confidence, 0.2 margin.",
1178
1188
  },
1179
1189
  context_mode: {
1180
1190
  type: "string",
@@ -1335,6 +1345,11 @@ export const workflowJsonSchema = {
1335
1345
  enum: [...NODE_ON_FAIL],
1336
1346
  description: "What to do when this node finishes 'failed' (agent-level failure, or an eval failure that exhausted retries and was not softened by fail_soft). 'halt' (default) stops the workflow with the failure surfaced so a broken node never advances down a conditional edge; 'continue' preserves the legacy fall-through where routing proceeds from the failed node. Distinct from requires.on_fail (the pre-condition gate).",
1337
1347
  },
1348
+ route_by: {
1349
+ type: "string",
1350
+ enum: [...ROUTE_BY],
1351
+ description: "Who picks this node's natural-language route. 'agent' (default): the agent's route evaluation. 'decider': the operator's decision model first, the agent when it is not confident. Expressions always win over both; put safety conditions in expressions.",
1352
+ },
1338
1353
  permissions: {
1339
1354
  $ref: "#/$defs/Permissions",
1340
1355
  description: "What this node's agent may do. Absent: read when the node declares outputs, else the workflow's permissions, else write.",
@@ -1456,6 +1471,11 @@ export const workflowJsonSchema = {
1456
1471
  },
1457
1472
  ],
1458
1473
  },
1474
+ description: {
1475
+ type: "string",
1476
+ minLength: 1,
1477
+ description: "The condition in natural language. For an { expr } edge, what the next rung reads when the expression cannot be evaluated because a field it needs is missing or invalid.",
1478
+ },
1459
1479
  max_iterations: {
1460
1480
  type: "integer",
1461
1481
  minimum: 1,
@@ -181,6 +181,45 @@ export const github = {
181
181
  return gh(`/search/issues?q=${encodeURIComponent(q)}&per_page=20`, ctx);
182
182
  },
183
183
  },
184
+ {
185
+ name: "github_list_issues",
186
+ access: "read",
187
+ description: "List a repository's most recently created issues (open and closed), newest first",
188
+ input_schema: {
189
+ type: "object",
190
+ properties: {
191
+ repo: { type: "string", description: "owner/repo" },
192
+ per_page: { type: "number", description: "How many per page (max 100, default 30)" },
193
+ page: { type: "number", description: "Page number, from 1 (default 1)" },
194
+ },
195
+ required: ["repo"],
196
+ },
197
+ handler: async (input, ctx) => {
198
+ const n = Math.min(100, Math.max(1, Math.floor(input.per_page ?? 30)));
199
+ const page = Math.max(1, Math.floor(input.page ?? 1));
200
+ return gh(`/repos/${input.repo}/issues?state=all&sort=created&direction=desc&per_page=${n}&page=${page}`, ctx);
201
+ },
202
+ },
203
+ {
204
+ name: "github_list_issue_comments",
205
+ access: "read",
206
+ description: "List comments on an issue or pull request, optionally only those updated since a time",
207
+ input_schema: {
208
+ type: "object",
209
+ properties: {
210
+ repo: { type: "string", description: "owner/repo" },
211
+ issue_number: { type: "number", description: "Issue or PR number" },
212
+ since: { type: "string", description: "ISO 8601 time: only comments updated at or after it" },
213
+ page: { type: "number", description: "Page of 100 comments, oldest first, from 1 (default 1)" },
214
+ },
215
+ required: ["repo", "issue_number"],
216
+ },
217
+ handler: async (input, ctx) => {
218
+ const since = input.since ? `&since=${encodeURIComponent(input.since)}` : "";
219
+ const page = Math.max(1, Math.floor(input.page ?? 1));
220
+ return gh(`/repos/${input.repo}/issues/${Number(input.issue_number)}/comments?per_page=100&page=${page}${since}`, ctx);
221
+ },
222
+ },
184
223
  {
185
224
  name: "github_create_issue",
186
225
  access: "write",
package/dist/types.d.ts CHANGED
@@ -418,6 +418,13 @@ export interface Node {
418
418
  * Distinct from `requires.on_fail`, which is the pre-condition gate.
419
419
  */
420
420
  on_fail?: NodeOnFail;
421
+ /**
422
+ * Who picks this node's natural-language route (#357). `agent` (default):
423
+ * the agent's route evaluation. `decider`: the operator's decision model is
424
+ * asked first and its confident answer is the route; otherwise the agent.
425
+ * Expressions always win over both. Safety conditions belong in expressions.
426
+ */
427
+ route_by?: RouteBy;
421
428
  /**
422
429
  * What this node's agent may do (#365). `read` runs the node read-only:
423
430
  * only `access: "read"` skill tools, no external skill MCP servers, no
@@ -555,6 +562,12 @@ export interface Edge {
555
562
  * evaluated by sweny with no model call.
556
563
  */
557
564
  when?: EdgeWhen;
565
+ /**
566
+ * The condition in natural language (#357). For an `{ expr }` edge it is
567
+ * what the next rung (decider or agent) reads when the expression cannot be
568
+ * evaluated because a field it needs is missing or invalid.
569
+ */
570
+ description?: string;
558
571
  /** Max times this edge can be followed (enables retry loops). Default: unlimited. */
559
572
  max_iterations?: number;
560
573
  }
@@ -569,6 +582,25 @@ export interface Edge {
569
582
  */
570
583
  export declare const WORKFLOW_TYPES: readonly ["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"];
571
584
  export type WorkflowType = (typeof WORKFLOW_TYPES)[number];
585
+ /**
586
+ * Decision model gates (#357). Shared by the Zod schema, the JSON schema and
587
+ * the executor; kept here so schema-only (browser) consumers need no Node code.
588
+ */
589
+ /** Default: accept a decider answer only at or above this confidence. */
590
+ export declare const DECIDER_MIN_CONFIDENCE = 0.85;
591
+ /** Default: ... and only when the top label leads the runner-up by at least this much. */
592
+ export declare const DECIDER_MIN_MARGIN = 0.2;
593
+ /** A workflow may lower `min_confidence` to this and no further. */
594
+ export declare const DECIDER_CONFIDENCE_FLOOR = 0.7;
595
+ /** A workflow may lower `min_margin` to this and no further. */
596
+ export declare const DECIDER_MARGIN_FLOOR = 0.1;
597
+ /** `route_by` values: who picks a node's natural-language route. */
598
+ export declare const ROUTE_BY: readonly ["agent", "decider"];
599
+ export type RouteBy = (typeof ROUTE_BY)[number];
600
+ /** Load error for a workflow that still declares the removed `decider.mode`. */
601
+ export declare const DECIDER_MODE_REMOVED: string;
602
+ /** Load error for a workflow that declares the provider itself. */
603
+ export declare const DECIDER_PROVIDER_MOVED: string;
572
604
  /** A complete workflow definition. Pure data, fully serializable. */
573
605
  export interface Workflow {
574
606
  id: string;
@@ -598,8 +630,9 @@ export interface Workflow {
598
630
  */
599
631
  budget?: Budget;
600
632
  /**
601
- * Decision model for route choices (#357). `shadow` asks it alongside the
602
- * agent and logs agreement; the route is always the agent's. Default: off.
633
+ * Decision model thresholds (#357) for the nodes that set `route_by:
634
+ * decider`. Never looser than the floors. The provider itself comes only
635
+ * from operator config, never from the workflow.
603
636
  */
604
637
  decider?: DeciderConfig;
605
638
  /**
@@ -694,6 +727,12 @@ export interface NodeResult {
694
727
  * Claude Code harness only; absent for harnesses that do not report it.
695
728
  */
696
729
  policy?: NodePolicyFacts;
730
+ /**
731
+ * The agent ran inside an enforced sandbox that keeps it out of the sweny
732
+ * state dir (run keys and journals). Set by the harness adapters; absent
733
+ * (treated as not contained) for anything that does not report it.
734
+ */
735
+ contained?: boolean;
697
736
  /** Safe outputs (#365): what the write stage did with each intent. Absent when the node declares none. */
698
737
  outputs?: SafeOutputReceipt[];
699
738
  /**
@@ -801,12 +840,19 @@ export interface TraceStep {
801
840
  /** 0-indexed retry attempt for this iteration. Absent when no retry fired. */
802
841
  retryAttempt?: number;
803
842
  }
843
+ /**
844
+ * Which rung of the route ladder chose a conditional route (#357): a `when`
845
+ * expression, the decision model, or the agent's route evaluation.
846
+ */
847
+ export type RouteRung = "expr" | "decider" | "agent";
804
848
  /** A routing decision between nodes */
805
849
  export interface TraceEdge {
806
850
  from: string;
807
851
  to: string;
808
852
  /** Why this edge was chosen (condition text or "only path") */
809
853
  reason: string;
854
+ /** Who chose it. Absent for an unconditional edge (nothing to decide). */
855
+ rung?: RouteRung;
810
856
  }
811
857
  /** Full execution trace — ordered steps + edges taken */
812
858
  export interface ExecutionTrace {
@@ -816,8 +862,14 @@ export interface ExecutionTrace {
816
862
  edges: TraceEdge[];
817
863
  /** Resolved sources keyed by field path (e.g. "nodes.gather.instruction") */
818
864
  sources: Record<string, _ResolvedSource>;
819
- /** Shadow-mode decider records, one per route decision (#357). Metadata only. Absent when the decider is off. */
865
+ /** One record per decision-model consultation (#357). Metadata only. Absent when no decider runs. */
820
866
  decisions?: DeciderRecord[];
867
+ /**
868
+ * Why the decider did not run for some or all of this run's `route_by:
869
+ * decider` nodes (#357): missing operator config, `--no-decider`, a rejected
870
+ * URL, an open breaker or a spent call cap. Absent when nothing turned it off.
871
+ */
872
+ deciderOff?: string;
821
873
  }
822
874
  /** Result of execute() — final node results + full execution trace */
823
875
  export interface ExecutionResult {
package/dist/types.js CHANGED
@@ -117,6 +117,28 @@ export const WORKFLOW_TYPES = [
117
117
  "data_sync",
118
118
  "generic",
119
119
  ];
120
+ /**
121
+ * Decision model gates (#357). Shared by the Zod schema, the JSON schema and
122
+ * the executor; kept here so schema-only (browser) consumers need no Node code.
123
+ */
124
+ /** Default: accept a decider answer only at or above this confidence. */
125
+ export const DECIDER_MIN_CONFIDENCE = 0.85;
126
+ /** Default: ... and only when the top label leads the runner-up by at least this much. */
127
+ export const DECIDER_MIN_MARGIN = 0.2;
128
+ /** A workflow may lower `min_confidence` to this and no further. */
129
+ export const DECIDER_CONFIDENCE_FLOOR = 0.7;
130
+ /** A workflow may lower `min_margin` to this and no further. */
131
+ export const DECIDER_MARGIN_FLOOR = 0.1;
132
+ /** `route_by` values: who picks a node's natural-language route. */
133
+ export const ROUTE_BY = ["agent", "decider"];
134
+ /** Load error for a workflow that still declares the removed `decider.mode`. */
135
+ export const DECIDER_MODE_REMOVED = "decider.mode was removed with shadow mode. A decider now decides the routes of nodes that set `route_by: decider`, " +
136
+ "using the provider from operator config (.sweny.yml `decider:` or SWENY_DECIDER_URL and SWENY_DECIDER_MODEL). " +
137
+ "Delete the mode line; to keep routing with the agent only, delete the whole decider block.";
138
+ /** Load error for a workflow that declares the provider itself. */
139
+ export const DECIDER_PROVIDER_MOVED = "decider.provider is not allowed in a workflow: the endpoint and key come only from operator config " +
140
+ "(.sweny.yml `decider: { url, model }`, or SWENY_DECIDER_URL and SWENY_DECIDER_MODEL; the key only from SWENY_DECIDER_API_KEY). " +
141
+ "Move it there. A workflow may only set min_confidence and min_margin here, and `route_by: decider` on nodes.";
120
142
  export const consoleLogger = {
121
143
  info: (msg, data) => console.log(`[info] ${msg}`, data ?? ""),
122
144
  warn: (msg, data) => console.warn(`[warn] ${msg}`, data ?? ""),
package/dist/when.d.ts CHANGED
@@ -95,7 +95,14 @@ export interface ExpressionResult {
95
95
  * Evaluate a parsed expression against node outputs. Never throws: a missing
96
96
  * field or a type mismatch yields `{ value: false, problem }`.
97
97
  */
98
- export declare function evaluateExpression(ast: ExprNode, scope: ExpressionScope): ExpressionResult;
98
+ export declare function evaluateExpression(ast: ExprNode, scope: ExpressionScope, opts?: {
99
+ /**
100
+ * `node.field` paths whose value broke its declared type or enum. Any
101
+ * reference to one (or below it), `exists` included, is a problem: the
102
+ * value is unknown, which is neither present nor absent.
103
+ */
104
+ invalid?: ReadonlySet<string>;
105
+ }): ExpressionResult;
99
106
  /**
100
107
  * Check an expression against the workflow it routes. Returns one message per
101
108
  * problem, empty when the expression is valid:
package/dist/when.js CHANGED
@@ -324,8 +324,14 @@ function lookup(scope, segments) {
324
324
  * Evaluate a parsed expression against node outputs. Never throws: a missing
325
325
  * field or a type mismatch yields `{ value: false, problem }`.
326
326
  */
327
- export function evaluateExpression(ast, scope) {
327
+ export function evaluateExpression(ast, scope, opts = {}) {
328
+ const checkInvalid = (path) => {
329
+ if (opts.invalid && path.length >= 2 && opts.invalid.has(`${path[0]}.${path[1]}`)) {
330
+ throw new Abort(`field '${path[0]}.${path[1]}' does not match its declared type`);
331
+ }
332
+ };
328
333
  const read = (path) => {
334
+ checkInvalid(path);
329
335
  const v = lookup(scope, path);
330
336
  if (v === undefined)
331
337
  throw new Abort(`field '${path.join(".")}' is missing`);
@@ -345,6 +351,7 @@ export function evaluateExpression(ast, scope) {
345
351
  case "path":
346
352
  return read(node.segments);
347
353
  case "exists": {
354
+ checkInvalid(node.path.segments);
348
355
  const v = lookup(scope, node.path.segments);
349
356
  return v !== undefined && v !== null;
350
357
  }
@@ -56,7 +56,9 @@ export function buildSystemPrompt(skills, existingWorkflow) {
56
56
  "- Use snake_case for node IDs (e.g. gather_errors, create_ticket)",
57
57
  "- Set `entry` to the first node in the flow",
58
58
  "- Only reference skill IDs from the list above in node `skills` arrays",
59
- "- Use natural language for edge `when` conditions",
59
+ '- Prefer `when: { expr: "..." }` expressions for edge conditions: declare the deciding values as enum, number, integer or boolean properties in the source node\'s `output`, then compare them (e.g. `{ "expr": "triage.severity in [\'high\', \'critical\']" }`). Expressions route with no model call and always the same way',
60
+ "- Use a natural-language `when` only for a fuzzy condition no declared field can express",
61
+ "- Put safety conditions (anything that gates a write, a PR or an issue) in expressions, never in natural language",
60
62
  "- Every node must be reachable from the entry node",
61
63
  "- Return ONLY the workflow JSON object — no markdown fences, no explanation",
62
64
  ];