@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/decider.d.ts CHANGED
@@ -1,32 +1,68 @@
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
- /** Accept a decider answer only at or above this confidence. */
14
- export declare const DECIDER_MIN_CONFIDENCE = 0.85;
15
- /** ... and only when the top label leads the runner-up by at least this much. */
16
- export declare const DECIDER_MIN_MARGIN = 0.2;
17
- /** Per-call timeout when the provider does not set one. */
27
+ import dns from "node:dns";
28
+ /** Per-call timeout. */
18
29
  export declare const DECIDER_DEFAULT_TIMEOUT_MS = 2000;
19
- export type DeciderMode = "off" | "shadow";
20
- /** Workflow-level `decider` block. */
30
+ /** Consecutive failed calls that open the breaker for the rest of the run. */
31
+ export declare const DECIDER_BREAKER_FAILURES = 3;
32
+ /** Decider calls one logical run (resumes included) may make. */
33
+ export declare const DECIDER_DEFAULT_MAX_CALLS = 200;
34
+ /** Probabilities must sum to 1 within this. */
35
+ export declare const DECIDER_SUM_TOLERANCE = 0.01;
36
+ /** Workflow-level `decider` block: thresholds only. */
21
37
  export interface DeciderConfig {
22
- mode: DeciderMode;
23
- provider?: {
24
- /** Server root, e.g. `http://localhost:11434` or `https://api.typesafe.ai`. */
25
- base_url: string;
26
- model: string;
27
- /** Name of the env var holding the bearer key. Never the key itself. */
28
- api_key_env?: string;
29
- };
38
+ /** Default 0.85, never below 0.7. */
39
+ min_confidence?: number;
40
+ /** Default 0.2, never below 0.1. */
41
+ min_margin?: number;
42
+ }
43
+ /**
44
+ * Operator config: where the decision model lives. Never read from a
45
+ * workflow file. The CLI builds it with one trust domain per credential: a
46
+ * URL from operator env (SWENY_DECIDER_URL) may be remote and gets the key
47
+ * (SWENY_DECIDER_API_KEY); a URL from repo content (`.sweny.yml`) is
48
+ * `loopbackOnly` and never gets a key. Library callers are the operator.
49
+ */
50
+ export interface DeciderOperatorConfig {
51
+ /** Server root serving POST /v1/systemone. http or https only. */
52
+ url: string;
53
+ model: string;
54
+ /** Bearer key. Never sent when `loopbackOnly`. */
55
+ apiKey?: string;
56
+ /** Allow loopback and private addresses. Link-local and metadata addresses stay refused. */
57
+ allowPrivate?: boolean;
58
+ /** The URL came from repo content: it must resolve to loopback only, and no key is ever sent. */
59
+ loopbackOnly?: boolean;
60
+ /** DNS lookup to resolve the host with (test seam). Default: `dns.lookup`. */
61
+ lookup?: LookupFn;
62
+ /** Per-call timeout in ms. Default 2000. */
63
+ timeoutMs?: number;
64
+ /** Calls per logical run. Default 200. */
65
+ maxCalls?: number;
30
66
  }
31
67
  /** One Choice question: pick a key of `criteria`. */
32
68
  export interface ChoiceAsk {
@@ -34,16 +70,16 @@ export interface ChoiceAsk {
34
70
  instructions: string;
35
71
  criteria: Record<string, string>;
36
72
  }
37
- /** Only Choice exists today. Noul and Score arrive as further union members. */
38
73
  export type Ask = ChoiceAsk;
39
74
  export interface ChoiceVerdict {
40
75
  kind: "choice";
41
76
  label: string;
77
+ /** Exactly the criteria keys (null prototype). */
42
78
  probabilities: Record<string, number>;
43
79
  confidence: number;
44
80
  }
45
81
  export type Verdict = ChoiceVerdict;
46
- export type DecideErrorCode = "timeout" | "aborted" | "network" | "unauthorized" | "invalid_request" | "rate_limited" | "overloaded" | "http_error" | "malformed" | "unknown_label";
82
+ export type DecideErrorCode = "timeout" | "aborted" | "network" | "unauthorized" | "invalid_request" | "rate_limited" | "overloaded" | "http_error" | "malformed" | "unknown_label" | "blocked_address";
47
83
  export declare class DecideError extends Error {
48
84
  readonly code: DecideErrorCode;
49
85
  constructor(code: DecideErrorCode, message: string);
@@ -61,84 +97,160 @@ export interface DecisionProvider {
61
97
  /** Throws {@link DecideError}; never returns a partial answer. */
62
98
  decide(req: DecideRequest): Promise<Record<string, Verdict>>;
63
99
  }
100
+ /** Which addresses a decider connection may reach. */
101
+ export interface AddressPolicy {
102
+ /** Loopback and private ranges allowed (operator env only). */
103
+ allowPrivate?: boolean;
104
+ /** Only loopback allowed (a URL that came from repo content). */
105
+ loopbackOnly?: boolean;
106
+ }
107
+ /** Why connecting to this resolved address is refused, or null. */
108
+ export declare function addressProblem(address: string, policy: AddressPolicy): string | null;
109
+ /**
110
+ * Static checks on the operator's decider URL, or null when it may be tried:
111
+ * http(s) only, no credentials in it, no metadata host name, and an IP
112
+ * literal must pass {@link addressProblem}. A host name is checked again on
113
+ * every connection, against the addresses it resolves to.
114
+ */
115
+ export declare function deciderUrlProblem(raw: string, policy?: AddressPolicy): string | null;
116
+ /** Node's `lookup` shape for `net.connect` / `http.request`. */
117
+ export type LookupFn = (hostname: string, options: dns.LookupOptions, callback: (err: NodeJS.ErrnoException | null, address: string | dns.LookupAddress[], family?: number) => void) => void;
118
+ /**
119
+ * A `lookup` for `http.request` that resolves every address, refuses the
120
+ * connection when any of them breaks the policy, and hands the socket the
121
+ * validated address itself. The socket connects to what was checked, so a
122
+ * second resolution cannot rebind the name to another address.
123
+ */
124
+ export declare function guardedLookup(policy: AddressPolicy, base?: LookupFn): LookupFn;
64
125
  export interface SystemOneOptions {
65
126
  baseUrl: string;
66
127
  model: string;
67
128
  apiKey?: string;
68
129
  /** Default timeout when `decide()` is not given one. Default 2000 ms. */
69
130
  timeoutMs?: number;
131
+ /** Addresses the connection may reach. Default: public only. */
132
+ policy?: AddressPolicy;
133
+ /** DNS lookup the guard resolves with (test seam). Default: `dns.lookup`. */
134
+ lookup?: LookupFn;
70
135
  }
71
136
  /**
72
137
  * `POST {baseUrl}/v1/systemone`. Ollama (local) and TypeSafe Jev (hosted)
73
138
  * share this wire shape; only base URL, key and model differ. Response
74
139
  * validation is strict: any deviation is a `malformed` or `unknown_label`
75
- * error, never a guess.
140
+ * error, never a guess. Error messages carry no URL and no key.
76
141
  */
77
142
  export declare function systemOneProvider(opts: SystemOneOptions): DecisionProvider;
78
- export type FallThroughReason = DecideErrorCode | "low_confidence" | "low_margin" | "label_outside_edge_set";
143
+ /**
144
+ * Strict: `probabilities` holds exactly the criteria keys, each finite in
145
+ * [0, 1], summing to 1 within {@link DECIDER_SUM_TOLERANCE}; the chosen label
146
+ * is a criteria key, compared exactly (no normalization), and the unique
147
+ * maximum. Anything else throws.
148
+ */
149
+ export declare function parseAnswers(body: unknown, asks: Record<string, Ask>): Record<string, Verdict>;
150
+ export type FallThroughReason = DecideErrorCode | "low_confidence" | "low_margin" | "label_outside_edge_set" | "no_routable_state";
151
+ export interface DeciderThresholds {
152
+ minConfidence: number;
153
+ minMargin: number;
154
+ }
79
155
  export interface GateResult {
80
156
  accepted: boolean;
81
157
  reason?: FallThroughReason;
82
158
  /** Top probability minus the runner-up (0 when there is no runner-up). */
83
159
  margin: number;
84
160
  }
85
- /** All gates must pass: label in the edge set, confidence, then top-two margin. */
86
- export declare function gateVerdict(v: ChoiceVerdict, edgeSet: ReadonlySet<string>): GateResult;
87
- /** One logged shadow decision. Metadata only: no state, no prompts, no prose. */
161
+ /** The workflow's thresholds, else the defaults, never looser than the floors, never above 1. */
162
+ export declare function resolveThresholds(config?: DeciderConfig): DeciderThresholds;
163
+ /** All gates must pass: label in the edge set, confidence, then top-two margin (each with float slack). */
164
+ export declare function gateVerdict(v: ChoiceVerdict, edgeSet: ReadonlySet<string>, thresholds?: DeciderThresholds): GateResult;
165
+ /** One decider consultation. Metadata only: no state, no prompts, no prose. */
88
166
  export interface DeciderRecord {
89
167
  /** Node the route was decided at. */
90
168
  node: string;
169
+ /** `decided`: the label is the route. `fell_through`: the agent was asked. `skipped`: no call was made. */
170
+ outcome: "decided" | "fell_through" | "skipped";
171
+ reason?: FallThroughReason;
91
172
  /** Edge target the decider picked (a node id). Null when it gave no answer. */
92
- decider_label: string | null;
93
- /** Edge target the agent picked. Null when the agent's evaluation failed. */
94
- agent_label: string | null;
173
+ label: string | null;
95
174
  confidence: number | null;
96
175
  margin: number | null;
97
- /** `compared`: the decider passed every gate. `fell_through`: it did not, see `reason`. */
98
- outcome: "compared" | "fell_through";
99
- reason?: FallThroughReason | "agent_failed";
100
- /** Decider label equals agent label. Null unless `outcome` is `compared`. */
101
- agree: boolean | null;
102
176
  latency_ms: number;
103
177
  model: string;
104
178
  /** First 16 hex chars of sha256 over the state and criteria sent. */
105
179
  input_hash: string;
106
180
  }
107
- /** What the executor holds for one run in shadow mode. */
108
- export interface ShadowDecider {
109
- provider: DecisionProvider;
110
- records: DeciderRecord[];
181
+ /** The breaker and cap counters, journaled so they span resumes. */
182
+ export interface DeciderCounters {
183
+ calls: number;
184
+ failures: number;
185
+ open: boolean;
111
186
  }
112
187
  export declare function hashDecisionInput(state: unknown, criteria: Record<string, string>): string;
113
- export interface PendingShadow {
114
- /** Never rejects. */
115
- settled: Promise<Omit<DeciderRecord, "agent_label" | "agree" | "node">>;
116
- edgeSet: ReadonlySet<string>;
117
- }
118
- /**
119
- * Start the decider call. Returns at once; the promise never rejects, so the
120
- * agent's own evaluation is never delayed or failed by it.
121
- */
122
- export declare function startShadowDecision(provider: DecisionProvider, req: {
188
+ export interface RouteQuestion {
189
+ node: string;
123
190
  question: string;
191
+ /** The fenced, redacted routing projection. */
124
192
  state: unknown;
193
+ /** Every live out-edge target of the node, with its condition. */
125
194
  choices: {
126
195
  id: string;
127
196
  description: string;
128
197
  }[];
129
198
  signal?: AbortSignal;
130
- }): PendingShadow;
131
- /** Join the decider's settled result with the agent's choice. */
132
- export declare function finishShadowDecision(pending: PendingShadow, node: string, agentLabel: string | null): Promise<DeciderRecord>;
133
- /** `{ compared, agreed, fell_through }` over a run's records. */
134
- export declare function summarizeDecisions(records: readonly DeciderRecord[]): {
135
- compared: number;
136
- agreed: number;
137
- fell_through: number;
138
- };
199
+ }
200
+ export interface RunDeciderOptions {
201
+ /** Raw workflow thresholds; clamped to the floors here. */
202
+ config?: DeciderConfig;
203
+ maxCalls?: number;
204
+ breakerFailures?: number;
205
+ timeoutMs?: number;
206
+ /** Counters carried over from an earlier attempt of the same run (resume). */
207
+ counters?: DeciderCounters;
208
+ /** Operator-facing messages (breaker opened, cap reached). Metadata only. */
209
+ warn?: (msg: string) => void;
210
+ /** Per-call log line. Metadata only. */
211
+ info?: (msg: string, data?: Record<string, unknown>) => void;
212
+ }
139
213
  /**
140
- * Resolve the run's shadow decider, or null (mode off, nothing configured, or
141
- * a declared key env var is unset). No hosted fallback exists: null means no
142
- * HTTP at all.
214
+ * The decider for one logical run: thresholds, a circuit breaker and a call
215
+ * cap. `decide()` returns the edge target to take, or null to ask the agent.
216
+ * It never throws.
143
217
  */
144
- export declare function createShadowDecider(config: DeciderConfig | undefined, modeOverride: DeciderMode | undefined, env: NodeJS.ProcessEnv, warn: (msg: string) => void): ShadowDecider | null;
218
+ export declare class RunDecider {
219
+ readonly provider: DecisionProvider;
220
+ private readonly opts;
221
+ readonly records: DeciderRecord[];
222
+ readonly thresholds: DeciderThresholds;
223
+ /** Set when the breaker opened or the cap was reached. */
224
+ offReason?: string;
225
+ private calls;
226
+ private failures;
227
+ private open;
228
+ private capped;
229
+ private readonly maxCalls;
230
+ private readonly breakerFailures;
231
+ private readonly timeoutMs;
232
+ constructor(provider: DecisionProvider, opts?: RunDeciderOptions);
233
+ get counters(): DeciderCounters;
234
+ /** Record that a route skipped the decider without a call (no HTTP). */
235
+ skip(node: string, reason: FallThroughReason): void;
236
+ decide(q: RouteQuestion): Promise<string | null>;
237
+ private log;
238
+ }
239
+ /** True when some node opts into the decider. */
240
+ export declare function wantsDecider(nodes: Record<string, {
241
+ route_by?: string;
242
+ }>): boolean;
243
+ /**
244
+ * Resolve the run's decider. `decider` is null with `off` set to the reason
245
+ * when the workflow opted nodes in but the decider cannot run; both are empty
246
+ * when no node opted in. No hosted fallback exists: null means no HTTP.
247
+ */
248
+ export declare function createRunDecider(operator: DeciderOperatorConfig | false | undefined, workflow: {
249
+ decider?: DeciderConfig;
250
+ nodes: Record<string, {
251
+ route_by?: string;
252
+ }>;
253
+ }, opts?: Omit<RunDeciderOptions, "config" | "maxCalls" | "timeoutMs">): {
254
+ decider: RunDecider | null;
255
+ off?: string;
256
+ };