@sweny-ai/core 0.1.177 → 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.
- package/dist/cli/config-file.d.ts +22 -1
- package/dist/cli/config-file.js +42 -0
- package/dist/cli/main.d.ts +2 -1
- package/dist/cli/main.js +4 -6
- package/dist/cli/resume.js +1 -0
- package/dist/cli/run-history.d.ts +3 -7
- package/dist/cli/run-history.js +1 -10
- package/dist/cli/run-output.d.ts +16 -10
- package/dist/cli/run-output.js +19 -13
- package/dist/cli/ticket.js +3 -2
- package/dist/decider.d.ts +176 -64
- package/dist/decider.js +475 -155
- package/dist/executor.d.ts +49 -6
- package/dist/executor.js +456 -89
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/journal.d.ts +33 -4
- package/dist/journal.js +36 -6
- package/dist/schema.d.ts +189 -241
- package/dist/schema.js +56 -36
- package/dist/types.d.ts +49 -3
- package/dist/types.js +22 -0
- package/dist/when.d.ts +8 -1
- package/dist/when.js +8 -1
- package/dist/workflow-builder.js +3 -1
- package/dist/workflows/browser.js +2 -2
- package/dist/workflows/implement.yml +37 -5
- package/dist/workflows/triage.yml +40 -6
- package/package.json +1 -1
package/dist/decider.js
CHANGED
|
@@ -1,22 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Decision models (#357)
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/** Per-call timeout
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
326
|
+
const answers = body.answers;
|
|
327
|
+
const out = Object.create(null);
|
|
112
328
|
for (const [name, ask] of Object.entries(asks)) {
|
|
113
|
-
const a =
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
throw new DecideError("
|
|
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
|
-
/**
|
|
141
|
-
export function
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 >=
|
|
385
|
+
if (!(v.confidence >= thresholds.minConfidence - EPS))
|
|
150
386
|
return { accepted: false, reason: "low_confidence", margin };
|
|
151
|
-
if (!(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
|
-
*
|
|
167
|
-
*
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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:
|
|
182
|
-
asks: { route: { kind: "choice", instructions:
|
|
183
|
-
timeoutMs:
|
|
184
|
-
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (!
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
}
|