@sweny-ai/core 0.1.176 → 0.1.178

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.
Files changed (49) hide show
  1. package/dist/agent-env.d.ts +9 -0
  2. package/dist/agent-env.js +4 -0
  3. package/dist/cli/comment-output.js +4 -5
  4. package/dist/cli/config-file.d.ts +37 -2
  5. package/dist/cli/config-file.js +68 -3
  6. package/dist/cli/final-output.d.ts +4 -1
  7. package/dist/cli/final-output.js +6 -8
  8. package/dist/cli/main.d.ts +2 -1
  9. package/dist/cli/main.js +18 -10
  10. package/dist/cli/resume.js +1 -0
  11. package/dist/cli/run-history.d.ts +3 -7
  12. package/dist/cli/run-history.js +5 -12
  13. package/dist/cli/run-output.d.ts +16 -10
  14. package/dist/cli/run-output.js +30 -16
  15. package/dist/cli/ticket.js +3 -2
  16. package/dist/cli/try.js +2 -3
  17. package/dist/decider.d.ts +176 -64
  18. package/dist/decider.js +475 -155
  19. package/dist/executor.d.ts +55 -7
  20. package/dist/executor.js +511 -99
  21. package/dist/harness/acp.js +2 -2
  22. package/dist/harness/claude-code.js +6 -4
  23. package/dist/harness/codex.js +2 -2
  24. package/dist/harness/pi.js +2 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +2 -2
  27. package/dist/journal.d.ts +34 -5
  28. package/dist/journal.js +42 -13
  29. package/dist/safe-file.d.ts +74 -0
  30. package/dist/safe-file.js +211 -0
  31. package/dist/safe-outputs.d.ts +7 -1
  32. package/dist/safe-outputs.js +7 -1
  33. package/dist/schema.d.ts +189 -241
  34. package/dist/schema.js +56 -36
  35. package/dist/skills/git-push.d.ts +99 -10
  36. package/dist/skills/git-push.js +413 -52
  37. package/dist/skills/github.d.ts +19 -15
  38. package/dist/skills/github.js +91 -15
  39. package/dist/startup-env.d.ts +30 -0
  40. package/dist/startup-env.js +48 -0
  41. package/dist/types.d.ts +86 -4
  42. package/dist/types.js +22 -0
  43. package/dist/when.d.ts +8 -1
  44. package/dist/when.js +8 -1
  45. package/dist/workflow-builder.js +3 -1
  46. package/dist/workflows/browser.js +2 -2
  47. package/dist/workflows/implement.yml +37 -5
  48. package/dist/workflows/triage.yml +40 -6
  49. package/package.json +1 -1
package/dist/decider.js CHANGED
@@ -1,22 +1,44 @@
1
1
  /**
2
- * Decision models (#357), shadow mode.
2
+ * Decision models (#357): a fast typed classifier that decides routes.
3
3
  *
4
4
  * A System One decision model (Ollama `/v1/systemone`, TypeSafe Jev) answers
5
- * one typed Choice question over a state in milliseconds. In shadow mode the
6
- * executor asks it the same route question the agent answers, in parallel,
7
- * and records whether they agree. The route is ALWAYS the agent's.
5
+ * one Choice question over a state in milliseconds. For a node that sets
6
+ * `route_by: decider` and whose conditional out-edges are natural language,
7
+ * the route ladder is:
8
8
  *
9
- * Privacy: the state goes only to the provider the workflow configured, never
10
- * to a default or fallback URL. What this module logs is metadata only
11
- * (labels are edge target node ids, plus numbers and a hash).
9
+ * 1. `when` expressions: sweny evaluates them, no model call (executor.ts).
10
+ * 2. The decider: its answer is the route only when the label is one of the
11
+ * node's live out-edges, confidence is at least `min_confidence` and the
12
+ * top label leads the runner-up by at least `min_margin`. Then the agent
13
+ * is never asked.
14
+ * 3. The agent's route evaluation, exactly as without a decider.
15
+ *
16
+ * Any decider problem (timeout, HTTP error, malformed answer, unknown label,
17
+ * low confidence or margin, no routable state, open breaker, spent cap) falls
18
+ * through to rung 3.
19
+ *
20
+ * Trust boundary: the endpoint, model and key come only from operator config
21
+ * (`.sweny.yml` or SWENY_DECIDER_* env), never from the workflow, and the
22
+ * URL must not point at a private, link-local or metadata address unless the
23
+ * operator allows private addresses. The state sent is an explicit projection
24
+ * of declared typed fields (see executor.ts), redacted and fenced. What this
25
+ * module logs is metadata only (edge target ids, numbers and a hash).
12
26
  */
13
27
  import crypto from "node:crypto";
14
- /** Accept a decider answer only at or above this confidence. */
15
- export const DECIDER_MIN_CONFIDENCE = 0.85;
16
- /** ... and only when the top label leads the runner-up by at least this much. */
17
- export const DECIDER_MIN_MARGIN = 0.2;
18
- /** Per-call timeout when the provider does not set one. */
28
+ import dns from "node:dns";
29
+ import http from "node:http";
30
+ import https from "node:https";
31
+ import { DECIDER_CONFIDENCE_FLOOR, DECIDER_MARGIN_FLOOR, DECIDER_MIN_CONFIDENCE, DECIDER_MIN_MARGIN } from "./types.js";
32
+ /** Per-call timeout. */
19
33
  export const DECIDER_DEFAULT_TIMEOUT_MS = 2000;
34
+ /** Consecutive failed calls that open the breaker for the rest of the run. */
35
+ export const DECIDER_BREAKER_FAILURES = 3;
36
+ /** Decider calls one logical run (resumes included) may make. */
37
+ export const DECIDER_DEFAULT_MAX_CALLS = 200;
38
+ /** Probabilities must sum to 1 within this. */
39
+ export const DECIDER_SUM_TOLERANCE = 0.01;
40
+ /** Float slack for the threshold comparisons (0.6 - 0.4 is 0.19999999999999996). */
41
+ const EPS = 1e-9;
20
42
  export class DecideError extends Error {
21
43
  code;
22
44
  constructor(code, message) {
@@ -25,13 +47,175 @@ export class DecideError extends Error {
25
47
  this.name = "DecideError";
26
48
  }
27
49
  }
50
+ // ─── URL policy ─────────────────────────────────────────────────
51
+ function ipv4(host) {
52
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
53
+ if (!m)
54
+ return null;
55
+ const parts = m.slice(1).map(Number);
56
+ return parts.every((p) => p <= 255) ? parts : null;
57
+ }
58
+ function ipv6(host) {
59
+ if (!host.includes(":"))
60
+ return null;
61
+ let h = host;
62
+ // Trailing dotted IPv4 (::ffff:1.2.3.4).
63
+ const tail = /:(\d+\.\d+\.\d+\.\d+)$/.exec(h);
64
+ if (tail) {
65
+ const v4 = ipv4(tail[1]);
66
+ if (!v4)
67
+ return null;
68
+ h = h.slice(0, -tail[1].length) + ((v4[0] << 8) | v4[1]).toString(16) + ":" + ((v4[2] << 8) | v4[3]).toString(16);
69
+ }
70
+ const halves = h.split("::");
71
+ if (halves.length > 2)
72
+ return null;
73
+ const parse = (s) => s === "" ? [] : s.split(":").map((g) => (/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN));
74
+ const head = parse(halves[0]);
75
+ const rest = halves.length === 2 ? parse(halves[1]) : [];
76
+ if ([...head, ...rest].some((n) => Number.isNaN(n)))
77
+ return null;
78
+ const fill = halves.length === 2 ? 8 - head.length - rest.length : 0;
79
+ if (fill < 0 || (halves.length === 1 && head.length !== 8))
80
+ return null;
81
+ return [...head, ...Array(fill).fill(0), ...rest];
82
+ }
83
+ function classifyV4([a, b]) {
84
+ if (a === 169 && b === 254)
85
+ return "forbidden"; // link-local, cloud metadata (169.254.169.254)
86
+ if (a === 0)
87
+ return "forbidden";
88
+ if (a === 100 && b === 100)
89
+ return "forbidden"; // 100.100.100.200 metadata
90
+ if (a === 127)
91
+ return "loopback";
92
+ if (a === 10)
93
+ return "private";
94
+ if (a === 172 && b >= 16 && b <= 31)
95
+ return "private";
96
+ if (a === 192 && b === 168)
97
+ return "private";
98
+ if (a === 100 && b >= 64 && b <= 127)
99
+ return "private"; // CGNAT
100
+ if (a >= 224)
101
+ return "forbidden"; // multicast, reserved
102
+ return "public";
103
+ }
104
+ /** Class of an IP literal, or null when `host` is not one. */
105
+ function classifyIp(host) {
106
+ const v4 = ipv4(host);
107
+ if (v4)
108
+ return classifyV4(v4);
109
+ const v6 = ipv6(host);
110
+ if (v6) {
111
+ if (v6.every((g) => g === 0))
112
+ return "forbidden"; // ::
113
+ if (v6.slice(0, 7).every((g) => g === 0) && v6[7] === 1)
114
+ return "loopback"; // ::1
115
+ if (v6.slice(0, 5).every((g) => g === 0) && v6[5] === 0xffff) {
116
+ return classifyV4([v6[6] >> 8, v6[6] & 0xff, v6[7] >> 8, v6[7] & 0xff]); // IPv4-mapped
117
+ }
118
+ if ((v6[0] & 0xffc0) === 0xfe80)
119
+ return "forbidden"; // link-local
120
+ if (v6[0] === 0xfd00 && v6[1] === 0x0ec2)
121
+ return "forbidden"; // AWS metadata fd00:ec2::254
122
+ if ((v6[0] & 0xfe00) === 0xfc00)
123
+ return "private"; // unique local
124
+ if ((v6[0] & 0xff00) === 0xff00)
125
+ return "forbidden"; // multicast
126
+ return "public";
127
+ }
128
+ return null;
129
+ }
130
+ const REPO_URL_NOT_LOOPBACK = "a decider url from repo content (.sweny.yml or a workspace .env) must be loopback; set SWENY_DECIDER_URL in the environment for a remote server";
131
+ const bare = (hostname) => hostname
132
+ .replace(/^\[|\]$/g, "")
133
+ .toLowerCase()
134
+ .replace(/\.$/, "");
135
+ /** Why connecting to this resolved address is refused, or null. */
136
+ export function addressProblem(address, policy) {
137
+ const cls = classifyIp(bare(address)) ?? "forbidden"; // not an IP: never connect to it
138
+ if (cls === "forbidden")
139
+ return "decider address is link-local, metadata or reserved";
140
+ if (policy.loopbackOnly)
141
+ return cls === "loopback" ? null : REPO_URL_NOT_LOOPBACK;
142
+ if ((cls === "loopback" || cls === "private") && !policy.allowPrivate) {
143
+ return "decider address is loopback or private; set SWENY_DECIDER_ALLOW_PRIVATE=true in the environment to use it";
144
+ }
145
+ return null;
146
+ }
147
+ /**
148
+ * Static checks on the operator's decider URL, or null when it may be tried:
149
+ * http(s) only, no credentials in it, no metadata host name, and an IP
150
+ * literal must pass {@link addressProblem}. A host name is checked again on
151
+ * every connection, against the addresses it resolves to.
152
+ */
153
+ export function deciderUrlProblem(raw, policy = {}) {
154
+ let url;
155
+ try {
156
+ url = new URL(raw);
157
+ }
158
+ catch {
159
+ return "decider url is not a valid URL";
160
+ }
161
+ if (url.protocol !== "http:" && url.protocol !== "https:")
162
+ return "decider url must be http or https";
163
+ if (url.username || url.password)
164
+ return "decider url must not carry credentials (use SWENY_DECIDER_API_KEY)";
165
+ const host = bare(url.hostname);
166
+ if (host === "metadata" || host === "metadata.google.internal" || host === "instance-data") {
167
+ return "decider url points at a cloud metadata host";
168
+ }
169
+ if (classifyIp(host))
170
+ return addressProblem(host, policy);
171
+ return null;
172
+ }
173
+ /**
174
+ * A `lookup` for `http.request` that resolves every address, refuses the
175
+ * connection when any of them breaks the policy, and hands the socket the
176
+ * validated address itself. The socket connects to what was checked, so a
177
+ * second resolution cannot rebind the name to another address.
178
+ */
179
+ export function guardedLookup(policy, base = dns.lookup) {
180
+ return (hostname, options, callback) => {
181
+ base(hostname, { ...(typeof options === "object" ? options : {}), all: true }, (err, addresses) => {
182
+ if (err)
183
+ return callback(err, "");
184
+ const list = (Array.isArray(addresses) ? addresses : [{ address: addresses, family: 4 }]);
185
+ if (list.length === 0)
186
+ return callback(Object.assign(new Error("no address"), { code: "ENOTFOUND" }), "");
187
+ for (const a of list) {
188
+ const why = addressProblem(a.address, policy);
189
+ if (why)
190
+ return callback(Object.assign(new Error(why), { code: "EDECIDERBLOCKED" }), "");
191
+ }
192
+ if (typeof options === "object" && options.all)
193
+ return callback(null, list);
194
+ return callback(null, list[0].address, list[0].family);
195
+ });
196
+ };
197
+ }
198
+ /** Largest response body read. A decision is a few hundred bytes. */
199
+ const MAX_RESPONSE_BYTES = 1_000_000;
200
+ function statusCode(s) {
201
+ if (s === 401 || s === 403)
202
+ return "unauthorized";
203
+ if (s === 400 || s === 422)
204
+ return "invalid_request";
205
+ if (s === 429)
206
+ return "rate_limited";
207
+ if (s === 529 || s === 503)
208
+ return "overloaded";
209
+ return "http_error"; // includes 3xx: redirects are never followed
210
+ }
28
211
  const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
29
212
  const isProb = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
213
+ const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
30
214
  /**
31
215
  * `POST {baseUrl}/v1/systemone`. Ollama (local) and TypeSafe Jev (hosted)
32
216
  * share this wire shape; only base URL, key and model differ. Response
33
217
  * validation is strict: any deviation is a `malformed` or `unknown_label`
34
- * error, never a guess.
218
+ * error, never a guess. Error messages carry no URL and no key.
35
219
  */
36
220
  export function systemOneProvider(opts) {
37
221
  const url = `${opts.baseUrl.replace(/\/+$/, "")}/v1/systemone`;
@@ -44,73 +228,105 @@ export function systemOneProvider(opts) {
44
228
  for (const [name, ask] of Object.entries(req.asks)) {
45
229
  questions[name] = { type: ask.kind, instructions: ask.instructions, criteria: ask.criteria };
46
230
  }
47
- const ctl = new AbortController();
48
- let timedOut = false;
49
- const timer = setTimeout(() => {
50
- timedOut = true;
51
- ctl.abort();
52
- }, timeoutMs);
53
- const onAbort = () => ctl.abort();
54
- if (req.signal?.aborted)
55
- ctl.abort();
56
- req.signal?.addEventListener("abort", onAbort, { once: true });
57
- try {
58
- let res;
59
- try {
60
- res = await fetch(url, {
61
- method: "POST",
62
- headers: {
63
- "content-type": "application/json",
64
- ...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
65
- },
66
- body: JSON.stringify({ model: opts.model, state: req.state, questions }),
67
- signal: ctl.signal,
68
- });
69
- }
70
- catch (err) {
71
- if (timedOut)
72
- throw new DecideError("timeout", `no answer within ${timeoutMs}ms`);
73
- if (ctl.signal.aborted)
74
- throw new DecideError("aborted", "request aborted");
75
- throw new DecideError("network", err instanceof Error ? err.name : "fetch failed");
76
- }
77
- if (!res.ok) {
78
- const s = res.status;
79
- const code = s === 401 || s === 403
80
- ? "unauthorized"
81
- : s === 400 || s === 422
82
- ? "invalid_request"
83
- : s === 429
84
- ? "rate_limited"
85
- : s === 529 || s === 503
86
- ? "overloaded"
87
- : "http_error";
88
- throw new DecideError(code, `HTTP ${s}`);
89
- }
90
- let body;
91
- try {
92
- body = await res.json();
93
- }
94
- catch {
95
- if (timedOut)
96
- throw new DecideError("timeout", `no answer within ${timeoutMs}ms`);
97
- throw new DecideError("malformed", "response is not JSON");
98
- }
99
- return parseAnswers(body, req.asks);
100
- }
101
- finally {
102
- clearTimeout(timer);
103
- req.signal?.removeEventListener("abort", onAbort);
231
+ const target = new URL(url);
232
+ const policy = opts.policy ?? {};
233
+ // An IP literal never goes through `lookup`: check it here.
234
+ if (classifyIp(bare(target.hostname))) {
235
+ const why = addressProblem(target.hostname, policy);
236
+ if (why)
237
+ throw new DecideError("blocked_address", why);
104
238
  }
239
+ const payload = JSON.stringify({ model: opts.model, state: req.state, questions });
240
+ const transport = target.protocol === "https:" ? https : http;
241
+ const body = await new Promise((resolve, reject) => {
242
+ let finished = false;
243
+ let timedOut = false;
244
+ let aborted = false;
245
+ let request;
246
+ const finish = (err, value) => {
247
+ if (finished)
248
+ return;
249
+ finished = true;
250
+ clearTimeout(timer);
251
+ req.signal?.removeEventListener("abort", onAbort);
252
+ if (err) {
253
+ request?.destroy();
254
+ reject(err);
255
+ }
256
+ else
257
+ resolve(value);
258
+ };
259
+ const timer = setTimeout(() => {
260
+ timedOut = true;
261
+ finish(new DecideError("timeout", `no answer within ${timeoutMs}ms`));
262
+ }, timeoutMs);
263
+ const onAbort = () => {
264
+ aborted = true;
265
+ finish(new DecideError("aborted", "request aborted"));
266
+ };
267
+ if (req.signal?.aborted)
268
+ return onAbort();
269
+ req.signal?.addEventListener("abort", onAbort, { once: true });
270
+ request = transport.request(target, {
271
+ method: "POST",
272
+ agent: false,
273
+ lookup: guardedLookup(policy, opts.lookup),
274
+ headers: {
275
+ "content-type": "application/json",
276
+ "content-length": Buffer.byteLength(payload),
277
+ ...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
278
+ },
279
+ }, (res) => {
280
+ const status = res.statusCode ?? 0;
281
+ if (status < 200 || status >= 300) {
282
+ res.resume();
283
+ return finish(new DecideError(statusCode(status), `HTTP ${status}`));
284
+ }
285
+ const chunks = [];
286
+ let size = 0;
287
+ res.on("data", (c) => {
288
+ size += c.length;
289
+ if (size > MAX_RESPONSE_BYTES)
290
+ return finish(new DecideError("malformed", "response too large"));
291
+ chunks.push(c);
292
+ });
293
+ res.on("end", () => {
294
+ try {
295
+ finish(null, JSON.parse(Buffer.concat(chunks).toString("utf8")));
296
+ }
297
+ catch {
298
+ finish(new DecideError("malformed", "response is not JSON"));
299
+ }
300
+ });
301
+ res.on("error", () => finish(new DecideError("network", "response failed")));
302
+ });
303
+ request.on("error", (err) => {
304
+ if (timedOut || aborted || finished)
305
+ return;
306
+ if (err.code === "EDECIDERBLOCKED")
307
+ return finish(new DecideError("blocked_address", err.message));
308
+ // Never the URL, the address or the key: only that it failed.
309
+ finish(new DecideError("network", "request failed"));
310
+ });
311
+ request.end(payload);
312
+ });
313
+ return parseAnswers(body, req.asks);
105
314
  },
106
315
  };
107
316
  }
108
- function parseAnswers(body, asks) {
317
+ /**
318
+ * Strict: `probabilities` holds exactly the criteria keys, each finite in
319
+ * [0, 1], summing to 1 within {@link DECIDER_SUM_TOLERANCE}; the chosen label
320
+ * is a criteria key, compared exactly (no normalization), and the unique
321
+ * maximum. Anything else throws.
322
+ */
323
+ export function parseAnswers(body, asks) {
109
324
  if (!isRecord(body) || !isRecord(body.answers))
110
325
  throw new DecideError("malformed", "missing answers object");
111
- const out = {};
326
+ const answers = body.answers;
327
+ const out = Object.create(null);
112
328
  for (const [name, ask] of Object.entries(asks)) {
113
- const a = body.answers[name];
329
+ const a = own(answers, name) ? answers[name] : undefined;
114
330
  if (!isRecord(a))
115
331
  throw new DecideError("malformed", `no answer for question '${name}'`);
116
332
  if (a.type !== undefined && a.type !== ask.kind)
@@ -121,34 +337,54 @@ function parseAnswers(body, asks) {
121
337
  throw new DecideError("malformed", "confidence is not a number in 0..1");
122
338
  if (!isRecord(a.probabilities))
123
339
  throw new DecideError("malformed", "probabilities is not an object");
124
- const probabilities = {};
125
- for (const [k, v] of Object.entries(a.probabilities)) {
340
+ const known = new Set(Object.keys(ask.criteria));
341
+ if (!known.has(a.choice))
342
+ throw new DecideError("unknown_label", "answer names a label outside the criteria");
343
+ const got = Object.keys(a.probabilities);
344
+ if (got.some((k) => !known.has(k)))
345
+ throw new DecideError("unknown_label", "probabilities name a label outside the criteria");
346
+ if (got.length !== known.size)
347
+ throw new DecideError("malformed", "probabilities do not cover every label");
348
+ const probabilities = Object.create(null);
349
+ let sum = 0;
350
+ for (const k of got) {
351
+ const v = a.probabilities[k];
126
352
  if (!isProb(v))
127
353
  throw new DecideError("malformed", "probability is not a number in 0..1");
128
354
  probabilities[k] = v;
355
+ sum += v;
129
356
  }
130
- const known = Object.keys(ask.criteria);
131
- if (!known.includes(a.choice) ||
132
- !(a.choice in probabilities) ||
133
- Object.keys(probabilities).some((k) => !known.includes(k))) {
134
- throw new DecideError("unknown_label", "answer names a label outside the criteria");
357
+ if (Math.abs(sum - 1) > DECIDER_SUM_TOLERANCE)
358
+ throw new DecideError("malformed", "probabilities do not sum to 1");
359
+ const top = probabilities[a.choice];
360
+ if (got.some((k) => k !== a.choice && probabilities[k] >= top)) {
361
+ throw new DecideError("malformed", "the chosen label is not the unique most probable one");
135
362
  }
136
363
  out[name] = { kind: "choice", label: a.choice, probabilities, confidence: a.confidence };
137
364
  }
138
365
  return out;
139
366
  }
140
- /** All gates must pass: label in the edge set, confidence, then top-two margin. */
141
- export function gateVerdict(v, edgeSet) {
142
- const top = v.probabilities[v.label] ?? 0;
143
- const second = Math.max(0, ...Object.entries(v.probabilities)
144
- .filter(([k]) => k !== v.label)
145
- .map(([, p]) => p));
367
+ /** The workflow's thresholds, else the defaults, never looser than the floors, never above 1. */
368
+ export function resolveThresholds(config) {
369
+ const pick = (v, dflt, floor) => typeof v === "number" && Number.isFinite(v) ? Math.min(1, Math.max(floor, v)) : dflt;
370
+ return {
371
+ minConfidence: pick(config?.min_confidence, DECIDER_MIN_CONFIDENCE, DECIDER_CONFIDENCE_FLOOR),
372
+ minMargin: pick(config?.min_margin, DECIDER_MIN_MARGIN, DECIDER_MARGIN_FLOOR),
373
+ };
374
+ }
375
+ /** All gates must pass: label in the edge set, confidence, then top-two margin (each with float slack). */
376
+ export function gateVerdict(v, edgeSet, thresholds = resolveThresholds()) {
377
+ const top = own(v.probabilities, v.label) ? v.probabilities[v.label] : 0;
378
+ let second = 0;
379
+ for (const [k, p] of Object.entries(v.probabilities))
380
+ if (k !== v.label && p > second)
381
+ second = p;
146
382
  const margin = top - second;
147
383
  if (!edgeSet.has(v.label))
148
384
  return { accepted: false, reason: "label_outside_edge_set", margin };
149
- if (!(v.confidence >= DECIDER_MIN_CONFIDENCE))
385
+ if (!(v.confidence >= thresholds.minConfidence - EPS))
150
386
  return { accepted: false, reason: "low_confidence", margin };
151
- if (!(margin >= DECIDER_MIN_MARGIN))
387
+ if (!(margin >= thresholds.minMargin - EPS))
152
388
  return { accepted: false, reason: "low_margin", margin };
153
389
  return { accepted: true, margin };
154
390
  }
@@ -163,98 +399,182 @@ export function hashDecisionInput(state, criteria) {
163
399
  return crypto.createHash("sha256").update(json).digest("hex").slice(0, 16);
164
400
  }
165
401
  /**
166
- * Start the decider call. Returns at once; the promise never rejects, so the
167
- * agent's own evaluation is never delayed or failed by it.
402
+ * The decider for one logical run: thresholds, a circuit breaker and a call
403
+ * cap. `decide()` returns the edge target to take, or null to ask the agent.
404
+ * It never throws.
168
405
  */
169
- export function startShadowDecision(provider, req) {
170
- const criteria = {};
171
- for (const c of req.choices)
172
- if (!(c.id in criteria))
173
- criteria[c.id] = c.description;
174
- const edgeSet = new Set(Object.keys(criteria));
175
- const input_hash = hashDecisionInput(req.state, criteria);
176
- const started = Date.now();
177
- const base = { model: provider.model, input_hash };
178
- const settled = (async () => {
406
+ export class RunDecider {
407
+ provider;
408
+ opts;
409
+ records = [];
410
+ thresholds;
411
+ /** Set when the breaker opened or the cap was reached. */
412
+ offReason;
413
+ calls;
414
+ failures;
415
+ open;
416
+ capped = false;
417
+ maxCalls;
418
+ breakerFailures;
419
+ timeoutMs;
420
+ constructor(provider, opts = {}) {
421
+ this.provider = provider;
422
+ this.opts = opts;
423
+ this.thresholds = resolveThresholds(opts.config);
424
+ this.maxCalls = Math.max(0, Math.floor(opts.maxCalls ?? DECIDER_DEFAULT_MAX_CALLS));
425
+ this.breakerFailures = Math.max(1, Math.floor(opts.breakerFailures ?? DECIDER_BREAKER_FAILURES));
426
+ this.timeoutMs = opts.timeoutMs ?? DECIDER_DEFAULT_TIMEOUT_MS;
427
+ this.calls = opts.counters?.calls ?? 0;
428
+ this.failures = opts.counters?.failures ?? 0;
429
+ this.open = opts.counters?.open ?? false;
430
+ if (this.open)
431
+ this.offReason = `breaker open after ${this.breakerFailures} failed calls`;
432
+ }
433
+ get counters() {
434
+ return { calls: this.calls, failures: this.failures, open: this.open };
435
+ }
436
+ /** Record that a route skipped the decider without a call (no HTTP). */
437
+ skip(node, reason) {
438
+ const rec = {
439
+ node,
440
+ outcome: "skipped",
441
+ reason,
442
+ label: null,
443
+ confidence: null,
444
+ margin: null,
445
+ latency_ms: 0,
446
+ model: this.provider.model,
447
+ input_hash: "",
448
+ };
449
+ this.records.push(rec);
450
+ this.log(` route decider: '${node}' skipped (${reason}); asking the agent`, rec);
451
+ }
452
+ async decide(q) {
453
+ if (this.open)
454
+ return null;
455
+ if (this.calls >= this.maxCalls) {
456
+ if (!this.capped) {
457
+ this.capped = true;
458
+ this.offReason = `call cap of ${this.maxCalls} reached`;
459
+ this.opts.warn?.(` decider: reached the ${this.maxCalls}-call cap for this run; the agent routes the rest.`);
460
+ }
461
+ return null;
462
+ }
463
+ this.calls++;
464
+ const criteria = Object.create(null);
465
+ for (const c of q.choices)
466
+ if (!own(criteria, c.id))
467
+ criteria[c.id] = c.description;
468
+ const edgeSet = new Set(Object.keys(criteria));
469
+ const input_hash = hashDecisionInput(q.state, criteria);
470
+ const started = Date.now();
471
+ const base = { node: q.node, model: this.provider.model, input_hash };
472
+ let rec;
179
473
  try {
180
- const out = await provider.decide({
181
- state: req.state,
182
- asks: { route: { kind: "choice", instructions: req.question, criteria } },
183
- timeoutMs: DECIDER_DEFAULT_TIMEOUT_MS,
184
- signal: req.signal,
474
+ const out = await this.provider.decide({
475
+ state: q.state,
476
+ asks: { route: { kind: "choice", instructions: q.question, criteria } },
477
+ timeoutMs: this.timeoutMs,
478
+ signal: q.signal,
185
479
  });
186
- const v = out.route;
480
+ const v = own(out, "route") ? out.route : undefined;
187
481
  if (!v || v.kind !== "choice")
188
482
  throw new DecideError("malformed", "no route answer");
189
- const gate = gateVerdict(v, edgeSet);
190
- return {
483
+ // A valid answer, even a low-confidence one, means the provider works.
484
+ this.failures = 0;
485
+ const gate = gateVerdict(v, edgeSet, this.thresholds);
486
+ rec = {
191
487
  ...base,
192
- decider_label: v.label,
488
+ outcome: gate.accepted ? "decided" : "fell_through",
489
+ ...(gate.reason ? { reason: gate.reason } : {}),
490
+ label: v.label,
193
491
  confidence: v.confidence,
194
492
  margin: gate.margin,
195
- outcome: gate.accepted ? "compared" : "fell_through",
196
- ...(gate.reason ? { reason: gate.reason } : {}),
197
493
  latency_ms: Date.now() - started,
198
494
  };
199
495
  }
200
496
  catch (err) {
201
- return {
497
+ const code = err instanceof DecideError ? err.code : "malformed";
498
+ // An aborted run is not the provider's fault.
499
+ if (code !== "aborted")
500
+ this.failures++;
501
+ rec = {
202
502
  ...base,
203
- decider_label: null,
503
+ outcome: "fell_through",
504
+ reason: code,
505
+ label: null,
204
506
  confidence: null,
205
507
  margin: null,
206
- outcome: "fell_through",
207
- reason: err instanceof DecideError ? err.code : "malformed",
208
508
  latency_ms: Date.now() - started,
209
509
  };
510
+ if (this.failures >= this.breakerFailures && !this.open) {
511
+ this.open = true;
512
+ this.offReason = `breaker open after ${this.failures} failed calls`;
513
+ this.opts.warn?.(` decider: ${this.failures} failed calls in a row (last: ${code}); not calling it again this run.`);
514
+ }
210
515
  }
211
- })();
212
- return { settled, edgeSet };
213
- }
214
- /** Join the decider's settled result with the agent's choice. */
215
- export async function finishShadowDecision(pending, node, agentLabel) {
216
- const s = await pending.settled;
217
- if (s.outcome === "fell_through")
218
- return { node, agent_label: agentLabel, agree: null, ...s };
219
- if (agentLabel === null) {
220
- return { node, agent_label: null, agree: null, ...s, outcome: "fell_through", reason: "agent_failed" };
516
+ this.records.push(rec);
517
+ this.log(rec.outcome === "decided"
518
+ ? ` route decider: '${q.node}' -> '${rec.label}'`
519
+ : ` route decider: '${q.node}' fell through (${rec.reason}); asking the agent`, rec);
520
+ return rec.outcome === "decided" ? rec.label : null;
221
521
  }
222
- return { node, agent_label: agentLabel, agree: s.decider_label === agentLabel, ...s };
223
- }
224
- /** `{ compared, agreed, fell_through }` over a run's records. */
225
- export function summarizeDecisions(records) {
226
- let compared = 0;
227
- let agreed = 0;
228
- for (const r of records) {
229
- if (r.outcome === "compared") {
230
- compared++;
231
- if (r.agree)
232
- agreed++;
522
+ log(msg, rec) {
523
+ try {
524
+ this.opts.info?.(msg, { ...rec });
525
+ }
526
+ catch {
527
+ // logging never affects a route
233
528
  }
234
529
  }
235
- return { compared, agreed, fell_through: records.length - compared };
530
+ }
531
+ /** True when some node opts into the decider. */
532
+ export function wantsDecider(nodes) {
533
+ return Object.values(nodes).some((n) => n.route_by === "decider");
236
534
  }
237
535
  /**
238
- * Resolve the run's shadow decider, or null (mode off, nothing configured, or
239
- * a declared key env var is unset). No hosted fallback exists: null means no
240
- * HTTP at all.
536
+ * Resolve the run's decider. `decider` is null with `off` set to the reason
537
+ * when the workflow opted nodes in but the decider cannot run; both are empty
538
+ * when no node opted in. No hosted fallback exists: null means no HTTP.
241
539
  */
242
- export function createShadowDecider(config, modeOverride, env, warn) {
243
- const mode = modeOverride ?? config?.mode ?? "off";
244
- if (mode !== "shadow")
245
- return null;
246
- const p = config?.provider;
247
- if (!p) {
248
- warn(" decider: shadow mode needs decider.provider in the workflow; running without it.");
249
- return null;
540
+ export function createRunDecider(operator, workflow, opts = {}) {
541
+ if (!wantsDecider(workflow.nodes))
542
+ return { decider: null };
543
+ if (operator === false)
544
+ return { decider: null, off: "disabled for this run (--no-decider)" };
545
+ if (!operator || !operator.url || !operator.model) {
546
+ return {
547
+ decider: null,
548
+ off: "no operator config (set SWENY_DECIDER_URL and SWENY_DECIDER_MODEL, or decider: in .sweny.yml)",
549
+ };
250
550
  }
251
- let apiKey;
252
- if (p.api_key_env) {
253
- apiKey = env[p.api_key_env];
254
- if (!apiKey) {
255
- warn(` decider: env var ${p.api_key_env} is not set; running without the decider.`);
256
- return null;
551
+ const policy = operator.loopbackOnly
552
+ ? { loopbackOnly: true }
553
+ : { allowPrivate: operator.allowPrivate === true };
554
+ const problem = deciderUrlProblem(operator.url, policy);
555
+ if (problem)
556
+ return { decider: null, off: problem };
557
+ if (operator.loopbackOnly) {
558
+ // A repo-content URL may only be a local name; anything else needs operator env.
559
+ const host = bare(new URL(operator.url).hostname);
560
+ if (!classifyIp(host) && host !== "localhost" && !host.endsWith(".localhost")) {
561
+ return { decider: null, off: REPO_URL_NOT_LOOPBACK };
257
562
  }
258
563
  }
259
- return { provider: systemOneProvider({ baseUrl: p.base_url, model: p.model, apiKey }), records: [] };
564
+ const provider = systemOneProvider({
565
+ baseUrl: operator.url,
566
+ model: operator.model,
567
+ policy,
568
+ // Never a key for a URL that repo content chose.
569
+ ...(operator.apiKey && !operator.loopbackOnly ? { apiKey: operator.apiKey } : {}),
570
+ ...(operator.lookup ? { lookup: operator.lookup } : {}),
571
+ });
572
+ return {
573
+ decider: new RunDecider(provider, {
574
+ ...opts,
575
+ config: workflow.decider,
576
+ ...(operator.maxCalls !== undefined ? { maxCalls: operator.maxCalls } : {}),
577
+ ...(operator.timeoutMs !== undefined ? { timeoutMs: operator.timeoutMs } : {}),
578
+ }),
579
+ };
260
580
  }